"""
Text injection module for Vocalinux.

This module is responsible for injecting recognized text into the active
application, supporting both X11 and Wayland environments.
"""

import logging
import math
import os
import shutil
import subprocess
import threading
import time
from enum import Enum
from typing import Optional  # noqa: F401

from .ibus_engine import (
    IBusTextInjector,
    is_ibus_active_input_method,
    is_ibus_available,
    is_ibus_daemon_running,
)

logger = logging.getLogger(__name__)


def _is_kde_plasma_session() -> bool:
    """Return True when the current desktop session appears to be KDE Plasma."""
    if os.environ.get("KDE_FULL_SESSION", "").lower() == "true":
        return True

    desktop_values = [
        os.environ.get("XDG_CURRENT_DESKTOP", ""),
        os.environ.get("DESKTOP_SESSION", ""),
        os.environ.get("GDMSESSION", ""),
    ]
    desktop = " ".join(value for value in desktop_values if value).lower()
    return "kde" in desktop or "plasma" in desktop


def _kde_wayland_ibus_hint() -> str:
    """Return the KDE Plasma Wayland IBus setup hint."""
    return (
        "Open System Settings -> Keyboard -> Virtual Keyboard, select "
        "'IBus Wayland', then restart Vocalinux or log out and back in."
    )


class DesktopEnvironment(Enum):
    """Enum representing the desktop environment."""

    X11 = "x11"
    X11_IBUS = "x11-ibus"  # X11 with IBus engine (preferred for non-US layouts)
    WAYLAND = "wayland"
    WAYLAND_XDOTOOL = "wayland-xdotool"  # Wayland with XWayland fallback
    WAYLAND_IBUS = "wayland-ibus"  # Wayland with IBus engine (preferred)
    UNKNOWN = "unknown"


class TextInjector:
    """
    Class for injecting text into the active application.

    This class handles the injection of text into the currently focused
    application window, supporting both X11 and Wayland environments.
    """

    def __init__(self, wayland_mode: bool = False):
        """
        Initialize the text injector.

        Args:
            wayland_mode: Force Wayland compatibility mode
        """
        self._ibus_injector: Optional[IBusTextInjector] = None
        self.environment = self._detect_environment()
        self._session_environment = self.environment
        self._ibus_ready = False
        self._ibus_init_failed = False
        self._ibus_init_thread: Optional[threading.Thread] = None
        self._state_lock = threading.Lock()
        self._clipboard_tool_health = {}
        self._clipboard_timeout = 0.35

        # Force Wayland mode if requested
        if wayland_mode and self.environment == DesktopEnvironment.X11:
            logger.info("Forcing Wayland compatibility mode")
            self.environment = DesktopEnvironment.WAYLAND
            self._session_environment = self.environment

        logger.info(f"Using text injection for {self.environment.value} environment")

        # Check for required tools
        self._check_dependencies()

        # Test if wtype actually works in this environment
        if (
            self.environment == DesktopEnvironment.WAYLAND
            and hasattr(self, "wayland_tool")
            and self.wayland_tool == "wtype"
        ):
            try:
                # Try a test with wtype
                result = subprocess.run(
                    ["wtype", "test"], stderr=subprocess.PIPE, text=True, check=False
                )
                error_output = result.stderr.lower()
                if "compositor does not support" in error_output or result.returncode != 0:
                    logger.warning(
                        "Wayland compositor does not support virtual "
                        f"keyboard protocol: {error_output}"
                    )
                    if _is_kde_plasma_session():
                        logger.warning(
                            "KDE Plasma Wayland detected. wtype is not a reliable "
                            f"text injection path on this compositor. {_kde_wayland_ibus_hint()}"
                        )
                    if shutil.which("xdotool"):
                        logger.info("Automatically switching to XWayland fallback with xdotool")
                        self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
                    else:
                        logger.error("No fallback text injection method available")
            except Exception as e:
                logger.warning(f"Error testing wtype: {e}, will try to use it anyway")

        # Verify XWayland fallback works - perform a test injection
        if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
            logger.info("Testing XWayland text injection fallback")
            try:
                # Wait a moment to ensure any error messages are displayed before test
                time.sleep(0.5)
                # Try xdotool in more verbose mode for better diagnostics
                self._test_xdotool_fallback()
            except Exception as e:
                logger.error(f"XWayland fallback test failed: {e}")

    def stop(self) -> None:
        """
        Clean up resources and restore previous state.

        Call this when shutting down Vocalinux.
        """
        with self._state_lock:
            if self._ibus_injector:
                logger.info("Stopping IBus text injector")
                self._ibus_injector.stop()
                self._ibus_injector = None
            self._ibus_ready = False

    def _detect_environment(self) -> DesktopEnvironment:
        """
        Detect the current desktop environment (X11 or Wayland).

        Returns:
            The detected desktop environment
        """
        session_type = os.environ.get("XDG_SESSION_TYPE", "").lower()
        if session_type == "wayland":
            return DesktopEnvironment.WAYLAND
        elif session_type == "x11":
            return DesktopEnvironment.X11
        else:
            # Try to detect based on other methods
            if "WAYLAND_DISPLAY" in os.environ:
                return DesktopEnvironment.WAYLAND
            elif "DISPLAY" in os.environ:
                return DesktopEnvironment.X11
            else:
                logger.warning("Could not detect desktop environment, defaulting to X11")
                return DesktopEnvironment.X11

    # Wayland compositors that do NOT bridge IBus commits to native Wayland
    # clients. On these, an IBus engine's commit_text() reaches only XWayland and
    # GTK/Qt apps that load the IBus IM module, while native apps (e.g.
    # cosmic-term) receive nothing -- the injection appears to succeed but the
    # text is silently dropped. These are the smithay/wlroots-based compositors
    # that implement input handling without IBus text-input integration.
    _IBUS_UNBRIDGED_COMPOSITORS = (
        "cosmic",
        "sway",
        "hyprland",
        "wayfire",
        "river",
        "niri",
        "labwc",
        "weston",
    )

    def _wayland_compositor_bridges_ibus(self) -> bool:
        """Whether native Wayland clients receive IBus commits on this compositor.

        GNOME (mutter), KDE (kwin) and most full desktops bridge IBus to native
        Wayland clients, so an engine's commit_text() reaches the focused app. A
        handful of compositors (see ``_IBUS_UNBRIDGED_COMPOSITORS``) do not, so on
        those we must use a virtual-keyboard tool (wtype/ydotool) instead. We use
        a denylist rather than an allowlist so that unrecognised desktops keep the
        previous IBus-preferred behaviour.
        """
        if self.environment != DesktopEnvironment.WAYLAND:
            # X11 / XWayland: IBus reaches apps through XIM regardless of DE.
            return True
        desktop = " ".join(
            os.environ.get(var, "")
            for var in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION")
        ).lower()
        return not any(name in desktop for name in self._IBUS_UNBRIDGED_COMPOSITORS)

    def _is_ydotoold_running(self) -> bool:
        """Return True if the ydotoold daemon appears to be running.

        ``ydotool type ""`` exits 0 even with no daemon (it prints a notice and
        silently does nothing), so the exit code alone is not a reliable probe.
        Check for the daemon's socket first, then fall back to scanning for the
        process itself.
        """
        socket_candidates = []
        env_socket = os.environ.get("YDOTOOL_SOCKET")
        if env_socket:
            socket_candidates.append(env_socket)
        runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
        if runtime_dir:
            socket_candidates.append(os.path.join(runtime_dir, ".ydotool_socket"))
        socket_candidates.append("/tmp/.ydotool_socket")
        if any(os.path.exists(path) for path in socket_candidates):
            return True
        try:
            result = subprocess.run(
                ["pgrep", "-x", "ydotoold"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                timeout=2,
            )
            return result.returncode == 0
        except (OSError, subprocess.SubprocessError):
            # pgrep missing, timed out, or otherwise unavailable -> assume not running
            return False

    def _check_dependencies(self):
        """Check for the required tools for text injection."""
        ibus_requested = False

        # Prefer IBus on both X11 and Wayland - it sends Unicode directly,
        # bypassing keyboard layout issues entirely
        if is_ibus_available():
            ibus_active = is_ibus_active_input_method()
            gtk_im = os.environ.get("GTK_IM_MODULE", "").lower()
            qt_im = os.environ.get("QT_IM_MODULE", "").lower()
            xmodifiers = os.environ.get("XMODIFIERS", "").lower()
            explicit_non_ibus_im = (
                (gtk_im and "ibus" not in gtk_im)
                or (qt_im and "ibus" not in qt_im)
                or (xmodifiers and "@im=" in xmodifiers and "@im=ibus" not in xmodifiers)
            )
            # Bridging Wayland DEs (GNOME/KDE): inject_text() switches to the
            # real vocalinux engine for each commit, so a bare xkb:* baseline is
            # fine (#501, #504). Unbridged compositors still bail below.
            wayland_scoped_ibus = (
                self.environment == DesktopEnvironment.WAYLAND and not explicit_non_ibus_im
            )

            # Check if IBus is the active input method (not just installed)
            # This is important because IBus may be installed but not being used,
            # e.g., when the user has configured ydotool or Fcitx instead.
            if not ibus_active and not wayland_scoped_ibus:
                logger.info(
                    "IBus is installed but not the active input method. "
                    "Falling back to alternative text injection method."
                )
            # Check if ibus-daemon is running before attempting setup
            elif not is_ibus_daemon_running():
                logger.info(
                    "IBus daemon not running. This is normal on some desktop environments "
                    "(e.g., KDE Plasma). Using alternative text injection method. "
                    "For IBus setup, see: https://github.com/jatinkrmalik/vocalinux/wiki/IBus-Setup"
                )
            # Some Wayland compositors (COSMIC, Sway, Hyprland, ...) do not deliver
            # IBus commits to native Wayland apps, so IBus would silently drop the
            # text even though commit_text() reports success.
            elif not self._wayland_compositor_bridges_ibus():
                logger.info(
                    "Compositor '%s' does not bridge IBus to native Wayland apps; "
                    "using virtual-keyboard injection (wtype/ydotool) instead.",
                    os.environ.get("XDG_CURRENT_DESKTOP", "unknown"),
                )
            else:
                try:
                    if wayland_scoped_ibus and not ibus_active:
                        logger.info(
                            "Wayland with ibus-daemon running; using scoped IBus injection "
                            "despite bare/inactive baseline engine"
                        )
                    self._ibus_injector = IBusTextInjector(auto_activate=False)
                    ibus_requested = True
                except Exception as e:
                    logger.warning(f"IBus initialization failed: {e}, trying alternatives")
        if self.environment == DesktopEnvironment.X11:
            # Check for xdotool
            if not shutil.which("xdotool"):
                if ibus_requested:
                    self._start_ibus_initialization()
                    return
                logger.error("xdotool not found. Please install it with: sudo apt install xdotool")
                raise RuntimeError("Missing required dependency: xdotool")
        else:
            # Fallback: Check for wtype or ydotool for Wayland
            wtype_available = shutil.which("wtype") is not None
            ydotool_available = shutil.which("ydotool") is not None
            xdotool_available = shutil.which("xdotool") is not None

            if ydotool_available and self._is_ydotoold_running():
                # ydotoold is running; ydotool can inject via the daemon.
                self.wayland_tool = "ydotool"
                logger.info(f"Using {self.wayland_tool} for Wayland text injection")
            elif ydotool_available and not wtype_available:
                # ydotool present but no daemon and no wtype: try ydotool anyway
                # (direct uinput mode, requires access to /dev/uinput).
                self.wayland_tool = "ydotool"
                logger.warning(
                    "ydotoold daemon not running; using ydotool in direct mode "
                    "(may have latency/permission issues)"
                )
            elif wtype_available:
                self.wayland_tool = "wtype"
                logger.info(f"Using {self.wayland_tool} for Wayland text injection")
            elif xdotool_available:
                # Fallback to xdotool with XWayland
                self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
                logger.info(
                    "No native Wayland tools found. Using xdotool with XWayland as fallback"
                )
            else:
                if ibus_requested:
                    self._start_ibus_initialization()
                    return
                logger.error(
                    "No text injection tools found. Please install one of:\n"
                    "- IBus (recommended, usually pre-installed)\n"
                    "- wtype: sudo apt install wtype (GNOME/Sway)\n"
                    "- ydotool: sudo apt install ydotool (works on all Wayland compositors)\n"
                    "- xdotool: sudo apt install xdotool (X11/XWayland only)\n"
                    "\n"
                    "For KDE Plasma Wayland users: wtype is not supported. "
                    f"{_kde_wayland_ibus_hint()}\n"
                    "Or install ydotool/wl-copy for fallback:\n"
                    "  sudo apt install ydotool\n"
                    "  sudo systemctl enable --now ydotoold\n"
                    "Or for clipboard fallback: sudo apt install wl-copy"
                )
                raise RuntimeError("Missing required dependencies for text injection")

        if ibus_requested:
            self._start_ibus_initialization()

    def _start_ibus_initialization(self) -> None:
        if self._ibus_injector is None or self._ibus_init_thread is not None:
            return

        if self.environment == DesktopEnvironment.WAYLAND and not hasattr(self, "wayland_tool"):
            if shutil.which("wtype"):
                self.wayland_tool = "wtype"
            elif shutil.which("ydotool"):
                self.wayland_tool = "ydotool"

        self._ibus_init_failed = False
        self._ibus_init_thread = threading.Thread(
            target=self._initialize_ibus_in_background,
            daemon=True,
        )
        self._ibus_init_thread.start()
        logger.info("Starting IBus warmup in background")

    def _initialize_ibus_in_background(self) -> None:
        if self._ibus_injector is None:
            return

        try:
            # Keep the Vocalinux IBus process warm without selecting it as the
            # user's active keyboard engine. Activation is scoped to each text
            # commit so normal typing keeps layout-specific compose/dead keys.
            self._ibus_injector.prepare_engine()
            with self._state_lock:
                self._ibus_ready = True
                self._ibus_init_failed = False
                if self._session_environment == DesktopEnvironment.X11:
                    self.environment = DesktopEnvironment.X11_IBUS
                else:
                    self.environment = DesktopEnvironment.WAYLAND_IBUS
            logger.info(
                f"Using IBus for {self.environment.value} text injection (best compatibility)"
            )
        except Exception as e:
            with self._state_lock:
                self._ibus_ready = False
                self._ibus_init_failed = True
            logger.warning(f"IBus initialization failed: {e}, continuing with fallback")

    def _get_clipboard_tools(self):
        tools = []
        if self._session_environment == DesktopEnvironment.WAYLAND and shutil.which("wl-copy"):
            tools.append("wl-copy")
        if shutil.which("xclip"):
            tools.append("xclip")
        if shutil.which("xsel"):
            tools.append("xsel")
        if self._session_environment != DesktopEnvironment.WAYLAND and shutil.which("wl-copy"):
            tools.append("wl-copy")
        return tools

    def _run_clipboard_command(self, tool: str, text: str) -> bool:
        # NB: wl-copy/xclip/xsel fork a background process that keeps owning the
        # selection in order to serve it. That child inherits our pipes, so
        # capturing stderr via subprocess.PIPE makes run() block until the child
        # exits (i.e. until the clipboard is next overwritten) and then time out
        # — even though the copy itself succeeded. Redirect to DEVNULL so run()
        # only waits for the short-lived foreground process.
        if tool == "wl-copy":
            subprocess.run(
                ["wl-copy", text],
                check=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                text=True,
                timeout=self._clipboard_timeout,
            )
            return True

        if tool == "xclip":
            subprocess.run(
                ["xclip", "-selection", "clipboard"],
                input=text,
                check=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                text=True,
                timeout=self._clipboard_timeout,
            )
            return True

        if tool == "xsel":
            subprocess.run(
                ["xsel", "--clipboard", "--input"],
                input=text,
                check=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                text=True,
                timeout=self._clipboard_timeout,
            )
            return True

        return False

    def _switch_to_non_ibus_backend(self) -> bool:
        """Switch from IBus mode to a non-IBus backend for runtime fallback."""
        if self.environment == DesktopEnvironment.X11_IBUS:
            if shutil.which("xdotool"):
                with self._state_lock:
                    self.environment = DesktopEnvironment.X11
                logger.warning("IBus injection failed, switching to X11 xdotool fallback")
                return True

            logger.error("IBus fallback failed: xdotool is not available on X11")
            return False

        if self.environment == DesktopEnvironment.WAYLAND_IBUS:
            ydotool_available = shutil.which("ydotool") is not None
            wtype_available = shutil.which("wtype") is not None
            xdotool_available = shutil.which("xdotool") is not None

            if ydotool_available:
                try:
                    subprocess.run(
                        ["ydotool", "type", ""],
                        check=True,
                        stderr=subprocess.PIPE,
                        timeout=2,
                    )
                    with self._state_lock:
                        self.wayland_tool = "ydotool"
                        self.environment = DesktopEnvironment.WAYLAND
                    logger.warning("IBus injection failed, switching to Wayland ydotool fallback")
                    return True
                except (
                    subprocess.CalledProcessError,
                    subprocess.TimeoutExpired,
                    FileNotFoundError,
                ):
                    logger.debug("ydotool fallback unavailable (daemon not running)")

            if wtype_available:
                with self._state_lock:
                    self.wayland_tool = "wtype"
                    self.environment = DesktopEnvironment.WAYLAND
                logger.warning("IBus injection failed, switching to Wayland wtype fallback")
                return True

            if xdotool_available:
                with self._state_lock:
                    self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
                logger.warning("IBus injection failed, switching to XWayland xdotool fallback")
                return True

            logger.error(
                "IBus fallback failed: no Wayland text injection tools available "
                "(ydotool, wtype, xdotool)"
            )
            return False

        return True

    def _test_xdotool_fallback(self):
        """Test if xdotool is working correctly with XWayland."""
        try:
            # Get the DISPLAY environment variable for XWayland
            xwayland_display = os.environ.get("DISPLAY", ":0")
            logger.debug(f"Using DISPLAY={xwayland_display} for XWayland")

            # Try using xdotool with explicit DISPLAY setting
            test_env = os.environ.copy()
            test_env["DISPLAY"] = xwayland_display

            # Check if we can get active window (less intrusive test)
            window_id = subprocess.run(
                ["xdotool", "getwindowfocus"],
                env=test_env,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                check=False,
            )

            if window_id.returncode != 0 or "failed" in window_id.stderr.lower():
                logger.warning(f"XWayland detection test failed: {window_id.stderr}")
                # Try to force XWayland environment more explicitly
                test_env["GDK_BACKEND"] = "x11"
            else:
                logger.debug("XWayland test successful")
        except Exception as e:
            logger.error(f"Failed to test XWayland fallback: {e}")

    def _try_recover_from_fallback(self):
        """
        Try to recover from xdotool fallback mode by re-checking for better tools.

        This allows switching to ydotool if the daemon was started after initial detection,
        or to wtype if the compositor now supports virtual keyboard.

        Returns:
            True if a better tool was found and environment was updated, False otherwise
        """
        if self.environment != DesktopEnvironment.WAYLAND_XDOTOOL:
            return False

        logger.info("Checking for better Wayland text injection tools...")

        # Check for ydotool with daemon running
        if shutil.which("ydotool"):
            try:
                subprocess.run(
                    ["ydotool", "type", ""],
                    check=True,
                    stderr=subprocess.PIPE,
                    timeout=2,
                )
                self.wayland_tool = "ydotool"
                self.environment = DesktopEnvironment.WAYLAND
                logger.info("Recovered to ydotool - ydotoold daemon is now running")
                return True
            except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
                logger.debug("ydotool available but daemon not running")

        # Check for wtype with compositor support
        if shutil.which("wtype"):
            try:
                result = subprocess.run(
                    ["wtype", "test"],
                    stderr=subprocess.PIPE,
                    text=True,
                    check=False,
                )
                error_output = result.stderr.lower()
                if "compositor does not support" not in error_output and result.returncode == 0:
                    self.wayland_tool = "wtype"
                    self.environment = DesktopEnvironment.WAYLAND
                    logger.info("Recovered to wtype - compositor now supports virtual keyboard")
                    return True
            except Exception as e:
                logger.debug(f"Error testing wtype: {e}")

        logger.debug("No better tools available, continuing with xdotool fallback")
        return False

    def _copy_to_clipboard(self, text: str) -> bool:
        """
        Copy text to clipboard.

        This is useful for:
        - Fallback when injection fails on unsupported compositors (like KDE Plasma)
        - Always-on clipboard copy so users can paste recognized text elsewhere

        Args:
            text: The text to copy to clipboard

        Returns:
            True if clipboard copy was successful, False otherwise
        """
        logger.info("Copying text to clipboard")

        for tool in self._get_clipboard_tools():
            if self._clipboard_tool_health.get(tool) is False:
                continue

            try:
                if self._run_clipboard_command(tool, text):
                    self._clipboard_tool_health[tool] = True
                    logger.info(f"Text copied to clipboard using {tool}")
                    return True
            except (
                subprocess.CalledProcessError,
                subprocess.TimeoutExpired,
                FileNotFoundError,
            ) as e:
                self._clipboard_tool_health[tool] = False
                logger.warning(f"{tool} failed: {e}")

        logger.warning(
            "Clipboard copy failed. Install wl-copy (Wayland) or xclip/xsel "
            "to enable clipboard functionality."
        )
        return False

    def _should_copy_to_clipboard(self) -> bool:
        """Check if copy-to-clipboard setting is enabled."""
        try:
            import json

            config_path = os.path.expanduser("~/.config/vocalinux/config.json")
            if os.path.exists(config_path):
                with open(config_path, "r") as f:
                    config = json.load(f)
                return config.get("text_injection", {}).get("copy_to_clipboard", False)
        except Exception as e:
            logger.debug(f"Could not read copy_to_clipboard setting: {e}")
        return False

    def _show_clipboard_fallback_notification(self):
        """Show a desktop notification when text is copied to clipboard as fallback."""
        try:
            subprocess.Popen(
                [
                    "notify-send",
                    "-i",
                    "edit-paste",
                    "-a",
                    "Vocalinux",
                    "Text copied to clipboard",
                    "Text injection failed - paste with Ctrl+V",
                ],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        except Exception as e:
            logger.debug(f"Could not show clipboard notification: {e}")

    def inject_text(self, text: str) -> bool:
        """
        Inject text into the currently focused application.

        Args:
            text: The text to inject

        Returns:
            True if injection was successful, False otherwise
        """
        if not text or not text.strip():
            logger.debug("Empty text provided, skipping injection")
            return True

        logger.info(f"Starting text injection: '{text}' (length: {len(text)})")
        logger.debug(f"Environment: {self.environment}")

        # Get information about the current window/application
        self._log_current_window_info()

        # Note: No shell escaping needed - subprocess is called with list arguments,
        # which passes text directly without shell interpretation
        logger.debug(f"Text to inject: '{text}'")

        # Re-check for available tools in Wayland fallback mode
        # This allows switching to ydotool if the daemon was started after initial detection
        if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
            self._try_recover_from_fallback()

        try:
            with self._state_lock:
                current_env = self.environment
                ibus_injector = self._ibus_injector

            if (
                current_env == DesktopEnvironment.WAYLAND_IBUS
                or current_env == DesktopEnvironment.X11_IBUS
            ):
                if ibus_injector is not None:
                    result = ibus_injector.inject_text(text)
                    if result:
                        logger.info("Text injection completed successfully")
                        if self._should_copy_to_clipboard():
                            threading.Thread(
                                target=self._copy_to_clipboard,
                                args=(text,),
                                daemon=True,
                            ).start()
                        return True

                    logger.warning(
                        "IBus runtime injection failed. Falling back to non-IBus backend."
                    )
                    if not self._switch_to_non_ibus_backend():
                        raise RuntimeError(
                            "IBus injection failed and no non-IBus fallback is available"
                        )
                else:
                    logger.error("IBus injector not initialized, trying non-IBus fallback")
                    if not self._switch_to_non_ibus_backend():
                        raise RuntimeError(
                            "IBus injector not initialized and no non-IBus fallback is available"
                        )

            with self._state_lock:
                current_env = self.environment

            if (
                current_env == DesktopEnvironment.X11
                or current_env == DesktopEnvironment.WAYLAND_XDOTOOL
            ):
                self._inject_with_xdotool(text)
            else:
                try:
                    self._inject_with_wayland_tool(text)
                except subprocess.CalledProcessError as e:
                    stderr_msg = e.stderr.strip() if e.stderr else "No stderr output"
                    unsupported_wayland = (
                        "compositor does not support" in str(e).lower()
                        or "compositor does not support" in stderr_msg.lower()
                    )
                    logger.warning(
                        f"Wayland tool failed: {e}. stderr: {stderr_msg}. Falling back to xdotool"
                    )
                    if (
                        unsupported_wayland
                        and current_env == DesktopEnvironment.WAYLAND
                        and _is_kde_plasma_session()
                    ):
                        logger.warning(
                            "KDE Plasma Wayland rejected virtual keyboard injection. "
                            f"{_kde_wayland_ibus_hint()}"
                        )
                    if unsupported_wayland and shutil.which("xdotool"):
                        logger.info(
                            "Switching to XWayland fallback - will re-check for better tools"
                        )
                        with self._state_lock:
                            self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
                        self._inject_with_xdotool(text)
                    else:
                        raise
            logger.info("Text injection completed successfully")

            if self._should_copy_to_clipboard():
                threading.Thread(
                    target=self._copy_to_clipboard,
                    args=(text,),
                    daemon=True,
                ).start()

            return True
        except Exception as e:
            logger.error(f"Failed to inject text: {e}", exc_info=True)

            try:
                if self._copy_to_clipboard(text):
                    logger.info("Text copied to clipboard as fallback - user can paste manually")
                    self._show_clipboard_fallback_notification()
                    return True
            except Exception as clipboard_error:
                logger.debug(f"Clipboard fallback also failed: {clipboard_error}")

            try:
                from ..ui.audio_feedback import play_error_sound

                play_error_sound()
            except ImportError:
                logger.warning("Could not import audio feedback module")
            return False

    def _inject_with_xdotool(self, text: str):
        """
        Inject text using xdotool for X11 environments.

        Args:
            text: The text to inject
        """
        # Create environment with explicit X11 settings for Wayland compatibility
        env = os.environ.copy()

        if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
            # Force X11 backend for XWayland
            env["GDK_BACKEND"] = "x11"
            env["QT_QPA_PLATFORM"] = "xcb"
            # Ensure DISPLAY is set correctly for XWayland
            if "DISPLAY" not in env or not env["DISPLAY"]:
                env["DISPLAY"] = ":0"

            logger.debug(f"Using XWayland with DISPLAY={env['DISPLAY']}")

            # Add a small delay to ensure text is injected properly
            time.sleep(0.3)  # Increased delay for better reliability

            # Try to ensure the window has focus using more robust approach
            try:
                # Get current active window
                active_window = subprocess.run(
                    ["xdotool", "getactivewindow"],
                    env=env,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    text=True,
                    check=False,
                )

                if active_window.returncode == 0 and active_window.stdout.strip():
                    window_id = active_window.stdout.strip()
                    # Focus explicitly on that window
                    subprocess.run(
                        ["xdotool", "windowactivate", "--sync", window_id],
                        env=env,
                        stdout=subprocess.DEVNULL,
                        stderr=subprocess.DEVNULL,
                        check=False,
                    )
                    # Wait a moment for the focus to take effect
                    time.sleep(0.2)
            except Exception as e:
                logger.debug(f"Window focus command failed: {e}")

        # Inject text using xdotool
        try:
            max_retries = 2
            logger.debug(f"Starting xdotool injection with {max_retries} max retries")

            for retry in range(max_retries + 1):
                try:
                    # Inject in smaller chunks to avoid issues with very long text
                    chunk_size = 20  # Reduced chunk size for better reliability
                    total_chunks = (len(text) + chunk_size - 1) // chunk_size
                    logger.debug(
                        f"Splitting text into {total_chunks} chunks of max {chunk_size} chars"
                    )

                    for i in range(0, len(text), chunk_size):
                        chunk = text[i : i + chunk_size]
                        chunk_num = (i // chunk_size) + 1

                        # First try with clearmodifiers
                        cmd = ["xdotool", "type", "--clearmodifiers", chunk]
                        logger.debug(f"Injecting chunk {chunk_num}/{total_chunks}: '{chunk}'")

                        subprocess.run(
                            cmd,
                            env=env,
                            check=True,
                            stderr=subprocess.PIPE,
                            text=True,
                            timeout=5,
                        )

                        # Add a larger delay between chunks
                        if i + chunk_size < len(text):
                            time.sleep(0.1)

                    logger.info(
                        f"Text injected using xdotool: '{text[:20]}...' ({len(text)} chars)"
                    )
                    break  # Successfully injected
                except subprocess.CalledProcessError as chunk_error:
                    if retry < max_retries:
                        logger.warning(
                            f"Retrying text injection (attempt {retry + 1}/{max_retries}): "
                            f"{chunk_error.stderr}"
                        )
                        time.sleep(0.5)  # Wait before retry
                    else:
                        logger.error(f"Final attempt failed: {chunk_error.stderr}")
                        raise  # Re-raise on final attempt
                except subprocess.TimeoutExpired:
                    if retry < max_retries:
                        logger.warning(
                            f"Text injection timeout, retrying (attempt {retry + 1}/{max_retries})"
                        )
                        time.sleep(0.5)
                    else:
                        logger.error("Text injection timed out on final attempt")
                        raise

            # Try to reset any stuck modifiers
            try:
                subprocess.run(
                    ["xdotool", "key", "--clearmodifiers", "Escape"],
                    env=env,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    check=False,
                )
            except Exception:
                pass  # Ignore any errors from this command
        except subprocess.CalledProcessError as e:
            logger.error(f"xdotool error: {e.stderr}")
            raise

    def _has_non_ascii(self, text: str) -> bool:
        """Check if text contains any non-ASCII characters."""
        try:
            text.encode("ascii")
            return False
        except UnicodeEncodeError:
            return True

    def _inject_via_clipboard_paste(self, text: str) -> bool:
        """
        Inject text by copying to clipboard and simulating Ctrl+V with ydotool.

        This is the workaround for ydotool's inability to type non-ASCII/Unicode
        characters (accented letters, CJK, etc.) because ydotool simulates evdev
        key events which only cover US ASCII keycodes. See issue #362.

        Note: this temporarily overwrites the user's clipboard. There is no
        attempt to restore it afterward, as there is no safe race-free way to
        do so on Wayland.

        Returns:
            True if successful, False otherwise
        """
        logger.debug(
            "Using clipboard-paste injection for non-ASCII text "
            "(user clipboard will be temporarily overwritten)"
        )

        if not self._copy_to_clipboard(text):
            logger.warning("Could not copy text to clipboard for paste injection")
            return False

        # Simulate Ctrl+V via ydotool using evdev keycodes:
        # KEY_LEFTCTRL=29, KEY_V=47; value 1=press, 0=release.
        # wtype is intentionally not handled here: wtype uses the Wayland
        # virtual-keyboard protocol which supports Unicode natively, so it
        # never needs the clipboard-paste workaround.
        try:
            subprocess.run(
                ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"],
                check=True,
                stderr=subprocess.PIPE,
                text=True,
                timeout=3,
            )
            logger.info(f"Text injected via clipboard paste: '{text[:20]}...' ({len(text)} chars)")
            return True
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
            logger.warning(f"Paste simulation failed: {e}")
            return False

    # evdev keycodes for modifier keys. If any of these is still physically held
    # when a Wayland injection fires, the injected keystrokes are modified: a
    # held Alt turns the Ctrl+V paste into Ctrl+Alt+V (nothing pastes), and a
    # held modifier turns typed letters into shortcuts. Because toggle/PTT
    # shortcuts are themselves modifiers (e.g. Alt+R) and transcription can
    # finish in tens of milliseconds, the user is often still holding the key
    # when injection starts, causing intermittent "nothing pasted" failures.
    _MODIFIER_KEYCODES = frozenset({29, 97, 56, 100, 42, 54, 125, 126})

    def _held_modifier_keycodes(self) -> set:
        """Return modifier keycodes currently held on any physical keyboard."""
        try:
            import evdev
            from evdev import ecodes
        except ImportError:
            return set()

        held: set = set()
        try:
            for path in evdev.list_devices():
                try:
                    device = evdev.InputDevice(path)
                except (OSError, PermissionError):
                    continue
                try:
                    if ecodes.EV_KEY not in device.capabilities():
                        continue
                    held |= set(device.active_keys()) & self._MODIFIER_KEYCODES
                except OSError:
                    continue
                finally:
                    device.close()
        except Exception as e:
            logger.debug(f"Could not read modifier key state: {e}")
        return held

    _DEFAULT_INJECT_MODIFIER_WAIT = 1.0

    def _injection_modifier_wait_seconds(self) -> float:
        """Return the sanitized max seconds to wait for modifiers to release.

        Reads VOCALINUX_INJECT_MODIFIER_WAIT and falls back to the default for
        anything unusable: a non-numeric value, or a non-finite one. In
        particular ``inf`` parses fine and is positive, so without this guard it
        would make the wait deadline infinite and block injection forever while a
        modifier stays held.
        """
        raw = os.environ.get("VOCALINUX_INJECT_MODIFIER_WAIT")
        if raw is None:
            return self._DEFAULT_INJECT_MODIFIER_WAIT
        try:
            value = float(raw)
        except ValueError:
            return self._DEFAULT_INJECT_MODIFIER_WAIT
        if not math.isfinite(value):
            logger.warning(
                "VOCALINUX_INJECT_MODIFIER_WAIT=%r is not a finite number; " "using default %.1fs",
                raw,
                self._DEFAULT_INJECT_MODIFIER_WAIT,
            )
            return self._DEFAULT_INJECT_MODIFIER_WAIT
        return value

    def _wait_for_modifiers_released(self) -> None:
        """Wait briefly for shortcut modifier keys to be released before injecting.

        Returns immediately if no modifier is held (the common case), so this
        adds no latency unless the user is still holding their toggle/PTT
        shortcut. Bounded by VOCALINUX_INJECT_MODIFIER_WAIT seconds (default 1.0;
        set to 0 to disable). Best-effort: if evdev/permissions are unavailable
        it simply proceeds.
        """
        max_wait = self._injection_modifier_wait_seconds()
        if max_wait <= 0:
            return

        deadline = time.monotonic() + max_wait
        waited = False
        while time.monotonic() < deadline:
            if not self._held_modifier_keycodes():
                if waited:
                    logger.debug("Modifier keys released; proceeding with injection")
                return
            waited = True
            time.sleep(0.015)
        logger.debug(
            f"Modifier keys still held after {max_wait:.2f}s; injecting anyway "
            "(paste/typing may be affected)"
        )

    def _inject_with_wayland_tool(self, text: str):
        """
        Inject text using a Wayland-compatible tool (wtype or ydotool).

        For ydotool: if the text contains non-ASCII characters (accented
        letters like á, é, ú, CJK characters, etc.), uses clipboard-based
        injection instead, because ydotool simulates evdev key events which
        only cover US ASCII keycodes. See issue #362.

        Args:
            text: The text to inject

        Raises:
            subprocess.CalledProcessError: If the tool fails, with stderr captured
        """
        # Wait for the shortcut modifier(s) to be released first, so a held Alt
        # doesn't turn the Ctrl+V paste into Ctrl+Alt+V (nothing pastes) or
        # modify typed keys.
        self._wait_for_modifiers_released()

        # ydotool emits *positional* evdev keycodes, so `ydotool type` is
        # re-interpreted through the active keyboard layout, which it assumes is
        # US QWERTY. On any other layout (AZERTY, QWERTZ, Dvorak, ...) the text
        # comes out scrambled (e.g. "message" -> ",essqge" on AZERTY), and
        # non-ASCII characters are dropped entirely. Always paste via the
        # clipboard instead: Ctrl+V is layout-independent and Unicode-safe.
        if self.wayland_tool == "ydotool":
            logger.info("Using clipboard paste for ydotool (layout-independent, Unicode-safe)")
            if self._inject_via_clipboard_paste(text):
                return
            logger.warning(
                "Clipboard paste failed, falling back to ydotool type "
                "(text may be scrambled on non-US layouts)"
            )

        if self.wayland_tool == "wtype":
            cmd = ["wtype", text]
        else:  # ydotool
            # Pass explicit timing parameters to speed up injection.
            # ydotool defaults to --key-delay 12 (or 20 in older versions).
            # We use a faster default configurable via environment variable.
            # IMPORTANT: key-delay must stay > 0 to avoid Shift-leak bug where
            # capital letters corrupt following characters (e.g. "Can you" -> "CAN YOu").
            key_delay = os.environ.get("VOCALINUX_YDOTOOL_KEY_DELAY", "8")
            cmd = ["ydotool", "type", "--key-delay", key_delay, text]

        try:
            subprocess.run(cmd, check=True, stderr=subprocess.PIPE, text=True)
        except subprocess.CalledProcessError as e:
            # Re-raise with stderr preserved for better diagnostics
            raise subprocess.CalledProcessError(
                e.returncode, e.cmd, output=e.output, stderr=e.stderr
            ) from e

        logger.info(
            f"Text injected using {self.wayland_tool}: '{text[:20]}...' ({len(text)} chars)"
        )

    def _inject_keyboard_shortcut(self, shortcut: str) -> bool:
        """
        Inject a keyboard shortcut.

        Args:
            shortcut: The keyboard shortcut to inject (e.g., "ctrl+z", "ctrl+a")

        Returns:
            True if injection was successful, False otherwise
        """
        logger.debug(f"Injecting keyboard shortcut: {shortcut}")

        try:
            if (
                self.environment == DesktopEnvironment.X11
                or self.environment == DesktopEnvironment.WAYLAND_XDOTOOL
            ):
                return self._inject_shortcut_with_xdotool(shortcut)
            else:
                return self._inject_shortcut_with_wayland_tool(shortcut)
        except Exception as e:
            logger.error(f"Failed to inject keyboard shortcut '{shortcut}': {e}")
            return False

    def _inject_shortcut_with_xdotool(self, shortcut: str) -> bool:
        """
        Inject a keyboard shortcut using xdotool.

        Args:
            shortcut: The keyboard shortcut to inject

        Returns:
            True if successful, False otherwise
        """
        # Create environment with explicit X11 settings for Wayland compatibility
        env = os.environ.copy()

        if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
            env["GDK_BACKEND"] = "x11"
            env["QT_QPA_PLATFORM"] = "xcb"
            if "DISPLAY" not in env or not env["DISPLAY"]:
                env["DISPLAY"] = ":0"

        try:
            cmd = ["xdotool", "key", "--clearmodifiers", shortcut]
            subprocess.run(cmd, env=env, check=True, stderr=subprocess.PIPE, text=True)
            logger.debug(f"Keyboard shortcut '{shortcut}' injected successfully")
            return True
        except subprocess.CalledProcessError as e:
            logger.error(f"xdotool shortcut error: {e.stderr}")
            return False

    def _inject_shortcut_with_wayland_tool(self, shortcut: str) -> bool:
        """
        Inject a keyboard shortcut using a Wayland-compatible tool.

        Args:
            shortcut: The keyboard shortcut to inject

        Returns:
            True if successful, False otherwise
        """
        if self.wayland_tool == "wtype":
            # wtype doesn't support key combinations directly, so we can't implement this easily
            logger.warning("Keyboard shortcuts not supported with wtype")
            return False
        elif self.wayland_tool == "ydotool":
            try:
                cmd = ["ydotool", "key", shortcut]
                subprocess.run(cmd, check=True, stderr=subprocess.PIPE, text=True)
                logger.debug(f"Keyboard shortcut '{shortcut}' injected successfully")
                return True
            except subprocess.CalledProcessError as e:
                logger.error(f"ydotool shortcut error: {e.stderr}")
                return False
        else:
            logger.warning(f"Keyboard shortcuts not supported with {self.wayland_tool}")
            return False

    def _log_current_window_info(self):
        """Log information about the current window/application for debugging."""
        try:
            if (
                self.environment == DesktopEnvironment.X11
                or self.environment == DesktopEnvironment.WAYLAND_XDOTOOL
            ):
                self._log_x11_window_info()
            else:
                logger.debug("Window info logging not available for pure Wayland")
        except Exception as e:
            logger.debug(f"Could not get window info: {e}")

    def _log_x11_window_info(self):
        """Log X11 window information."""
        env = os.environ.copy()

        if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
            env["GDK_BACKEND"] = "x11"
            env["QT_QPA_PLATFORM"] = "xcb"
            if "DISPLAY" not in env or not env["DISPLAY"]:
                env["DISPLAY"] = ":0"

        try:
            # Get active window ID
            result = subprocess.run(
                ["xdotool", "getactivewindow"],
                env=env,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                check=True,
                timeout=2,
            )
            window_id = result.stdout.strip()
            logger.debug(f"Active window ID: {window_id}")

            # Get window name
            result = subprocess.run(
                ["xdotool", "getwindowname", window_id],
                env=env,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                check=True,
                timeout=2,
            )
            window_name = result.stdout.strip()
            logger.info(f"Target window: '{window_name}' (ID: {window_id})")

            # Get window class
            result = subprocess.run(
                ["xdotool", "getwindowclassname", window_id],
                env=env,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                check=True,
                timeout=2,
            )
            window_class = result.stdout.strip()
            logger.debug(f"Window class: {window_class}")

            # Get window PID
            result = subprocess.run(
                ["xdotool", "getwindowpid", window_id],
                env=env,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                check=True,
                timeout=2,
            )
            window_pid = result.stdout.strip()
            logger.debug(f"Window PID: {window_pid}")

            # Try to get process name
            try:
                with open(f"/proc/{window_pid}/comm", "r") as f:
                    process_name = f.read().strip()
                logger.info(f"Target process: {process_name} (PID: {window_pid})")
            except Exception:
                pass

        except subprocess.TimeoutExpired:
            logger.warning("Timeout getting window information")
        except subprocess.CalledProcessError as e:
            logger.debug(f"xdotool command failed: {e.stderr}")
        except Exception as e:
            logger.debug(f"Error getting window info: {e}")
