"""
IBus engine for Vocalinux text injection.

This module provides an IBus input method engine that acts as a transparent
proxy - it passes all keystrokes through normally while allowing Vocalinux
to inject text directly via commit_text().

The engine should be set as the user's default input method. It will:
1. Pass all keyboard input through unchanged
2. Listen for text injection requests via a Unix socket or file
3. Commit injected text directly without switching engines

This is the preferred method for Wayland environments as it works universally
without requiring compositor-specific protocols.
"""

import logging
import os
import signal
import socket
import struct
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Callable, Optional

logger = logging.getLogger(__name__)


class IBusSetupError(RuntimeError):
    """
    Exception raised when IBus engine setup fails.

    This exception is raised when the IBus text injector cannot be properly
    initialized, allowing the caller to fall back to alternative text
    injection methods.
    """

    pass


# Check if IBus is available
try:
    import gi

    gi.require_version("IBus", "1.0")
    from gi.repository import GLib, GObject, IBus

    IBUS_AVAILABLE = True
except (ImportError, ValueError) as e:
    logger.debug(f"IBus not available: {e}")
    IBUS_AVAILABLE = False
    IBus = None
    GLib = None
    GObject = None


# File paths for communication
VOCALINUX_IBUS_DIR = Path.home() / ".local" / "share" / "vocalinux-ibus"
SOCKET_PATH = VOCALINUX_IBUS_DIR / "inject.sock"
PID_FILE = VOCALINUX_IBUS_DIR / "engine.pid"

# Engine identification
ENGINE_NAME = "vocalinux"
ENGINE_LONGNAME = "Vocalinux"
ENGINE_DESCRIPTION = "Vocalinux voice dictation (use as default input method)"
COMPONENT_NAME = "org.freedesktop.IBus.Vocalinux"

# Shared metadata used by component XML, --xml output, and runtime registration.
# Kept in one place to prevent drift between the three consumers.
ENGINE_RANK = 50
_ENGINE_META = {
    "language": "other",
    "license": "GPL-3.0",
    "author": "Vocalinux",
    "icon": "audio-input-microphone",
    "layout": "default",
}
_COMPONENT_META = {
    "version": "1.0",
    "license": "GPL-3.0",
    "author": "Vocalinux",
    "homepage": "https://github.com/jatinkrmalik/vocalinux",
    "textdomain": "vocalinux",
}


def ensure_ibus_dir() -> None:
    """Ensure the IBus data directory exists with secure permissions."""
    VOCALINUX_IBUS_DIR.mkdir(parents=True, exist_ok=True)
    # Secure the directory - only owner can access (prevents socket hijacking)
    VOCALINUX_IBUS_DIR.chmod(0o700)


def verify_peer_credentials(conn: socket.socket) -> bool:
    """
    Verify that the connecting process belongs to the same user.

    Uses SO_PEERCRED to get the UID of the peer process and compares
    it to our own UID. This prevents other users from injecting text.

    Args:
        conn: The connected socket

    Returns:
        True if peer is same user, False otherwise
    """
    try:
        # SO_PEERCRED returns a struct with pid, uid, gid
        # struct ucred { pid_t pid; uid_t uid; gid_t gid; }
        cred = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("iII"))
        pid, uid, gid = struct.unpack("iII", cred)

        my_uid = os.getuid()
        if uid != my_uid:
            logger.warning(f"Rejected connection from UID {uid} (expected {my_uid})")
            return False

        logger.debug(f"Accepted connection from PID {pid}, UID {uid}")
        return True
    except (OSError, struct.error) as e:
        logger.error(f"Failed to verify peer credentials: {e}")
        return False


def is_ibus_available() -> bool:
    """Check if IBus is available on the system."""
    return IBUS_AVAILABLE


def is_ibus_daemon_running() -> bool:
    """
    Check if the IBus daemon (ibus-daemon) is currently running.

    This is important because on some desktop environments (e.g., Fedora KDE),
    the IBus daemon is not started by default. Attempting to set up IBus
    when the daemon isn't running will fail.

    Returns:
        True if ibus-daemon is running, False otherwise
    """
    try:
        result = subprocess.run(
            ["pgrep", "-x", "ibus-daemon"],
            capture_output=True,
            timeout=2,
        )
        return result.returncode == 0
    except (subprocess.SubprocessError, FileNotFoundError):
        return False


def _is_real_ibus_engine(engine: Optional[str]) -> bool:
    """Return True only for a real IBus IM engine, not a bare XKB layout.

    IBus reports a bare ``xkb:*`` engine (e.g. ``xkb:us::eng``) when it is doing
    keyboard-layout passthrough with no input-method client attached. In that
    state ``commit_text()`` succeeds at the IBus layer but never reaches the
    focused application, so treating it as active causes a silent no-op on
    Wayland (see issue #478). Real IM engines (anthy, libpinyin, hangul, ...) do
    not use the ``xkb:`` prefix.
    """
    if not engine:
        return False
    engine = engine.strip()
    if not engine or engine.lower() == "no global engine":
        return False
    return not engine.startswith("xkb:")


def is_ibus_active_input_method() -> bool:
    """
    Check if IBus is the currently active input method.

    Checks environment variables and, where they are not set (common on
    Wayland), whether the IBus daemon is running with an engine.

    On Wayland there is an extra requirement: IBus can only deliver
    ``commit_text()`` to applications through a *real* IM engine. A bare
    ``xkb:*`` engine is a layout passthrough, so on compositors that don't fully
    bridge IBus to native clients (niri, sway, and even GNOME/Mutter with only
    an ``xkb`` engine) injection silently reaches nothing. We therefore require a
    real engine on Wayland regardless of how IBus was detected (issue #478). On
    X11/XWayland IBus reaches apps through XIM, so that check is not applied
    there.

    Returns:
        True if IBus appears to be the active input method, False otherwise
    """
    gtk_im = os.environ.get("GTK_IM_MODULE", "").lower()
    qt_im = os.environ.get("QT_IM_MODULE", "").lower()
    xmodifiers = os.environ.get("XMODIFIERS", "").lower()

    ibus_via_env = ("ibus" in gtk_im) or ("ibus" in qt_im) or ("@im=ibus" in xmodifiers)

    # If another (non-IBus) input method is explicitly configured, respect it.
    if not ibus_via_env and (gtk_im or qt_im or (xmodifiers and "@im=" in xmodifiers)):
        logger.debug(
            "Another input method is explicitly configured "
            f"(GTK_IM_MODULE={gtk_im or 'not set'}, "
            f"QT_IM_MODULE={qt_im or 'not set'}, "
            f"XMODIFIERS={xmodifiers or 'not set'})"
        )
        return False

    daemon_running = is_ibus_daemon_running()

    # Neither an IBus env-var signal nor a running daemon -> IBus is not active.
    if not ibus_via_env and not daemon_running:
        logger.debug(
            "IBus does not appear to be the active input method "
            f"(GTK_IM_MODULE={gtk_im or 'not set'}, "
            f"QT_IM_MODULE={qt_im or 'not set'}, "
            f"XMODIFIERS={xmodifiers or 'not set'}, daemon running: False)"
        )
        return False

    if _is_wayland_session():
        # On Wayland a bare xkb:* engine can't deliver commits to apps, so
        # require a real IM engine regardless of how IBus was detected (#478).
        engine = get_current_engine() if daemon_running else None
        if not _is_real_ibus_engine(engine):
            logger.debug(
                "IBus is present on Wayland but the current engine "
                f"({engine or 'none'}) is a bare XKB layout or unset; "
                "commit_text() would not reach applications, so treating IBus "
                "as inactive (see #478)."
            )
            return False
        logger.debug(f"IBus detected as active input method on Wayland (engine: {engine})")
        return True

    # X11 / XWayland: IBus reaches apps via XIM. An env-var signal, or a running
    # daemon with any engine (KDE Plasma with legacy env vars unset), counts.
    if ibus_via_env:
        logger.debug("IBus detected as active input method via environment variables")
        return True

    engine = get_current_engine()
    if engine:
        logger.debug(
            "IBus detected as active input method via running daemon "
            f"(no env vars set, current engine: {engine})"
        )
        return True

    logger.debug("IBus does not appear to be the active input method (daemon has no engine)")
    return False


def start_ibus_daemon():
    """
    Start the IBus daemon if it's not already running.

    This is useful for desktop environments where IBus doesn't start automatically,
    such as some KDE Plasma installations or minimal window managers.

    Returns:
        True if daemon was started or already running, False on failure
    """
    if is_ibus_daemon_running():
        return True

    if not is_ibus_available():
        return False

    try:
        # Start ibus-daemon in the background with XIM support
        # -x: Enable XIM (X Input Method)
        # -d: Run as daemon
        # -r: Replace existing daemon
        subprocess.Popen(
            ["ibus-daemon", "-x", "-d", "-r"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
        )
        # Give daemon time to start
        time.sleep(0.5)
        return is_ibus_daemon_running()
    except FileNotFoundError:
        # ibus-daemon not found
        return False
    except Exception:
        return False


def _get_exec_command() -> str:
    """Return the engine exec command for component XML and runtime registration."""
    engine_script = Path(__file__).resolve()
    return f"{sys.executable} {engine_script} --ibus"


def is_engine_active() -> bool:
    """Check if the Vocalinux IBus engine is currently active."""
    try:
        import subprocess

        result = subprocess.run(
            ["ibus", "engine"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        return result.returncode == 0 and ENGINE_NAME in result.stdout.strip()
    except (subprocess.SubprocessError, FileNotFoundError):
        return False


def _is_gnome_session() -> bool:
    """Check if the current session is running under GNOME."""
    desktop = os.environ.get("XDG_CURRENT_DESKTOP", "")
    return "GNOME" in desktop.upper()


def get_current_engine_gnome_fallback() -> Optional[str]:
    """
    Get the current keyboard layout as an IBus engine name via GNOME settings.

    On GNOME/Wayland, IBus may have no global engine (input sources are managed
    by Mutter, not ibus-daemon). In that case ``ibus engine`` returns nothing
    or a misleading ``xkb:us::eng`` default. This function reads the actual
    keyboard layout from ``org.gnome.desktop.input-sources`` via gsettings.

    Returns:
        IBus-format engine name (e.g. ``xkb:br::``) or None if not GNOME
        or if gsettings fails.
    """
    if not _is_gnome_session():
        return None

    try:
        sources_result = subprocess.run(
            ["gsettings", "get", "org.gnome.desktop.input-sources", "sources"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        if sources_result.returncode != 0:
            logger.debug("gsettings get sources failed; not using GNOME fallback")
            return None

        import ast

        sources = ast.literal_eval(sources_result.stdout.strip())
        if not sources:
            logger.debug("GNOME input-sources list is empty")
            return None

        # Get the current index
        current_result = subprocess.run(
            ["gsettings", "get", "org.gnome.desktop.input-sources", "current"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        if current_result.returncode != 0:
            current_idx = 0
        else:
            # ponytail: gsettings prints "uint32 N" or just "N" — split()[-1] handles both
            current_idx = int(current_result.stdout.strip().split()[-1])

        if current_idx < 0 or current_idx >= len(sources):
            current_idx = 0

        source_type, source_id = sources[current_idx]

        # GNOME uses ('xkb', 'br+dyn') or ('ibus', 'anthy') tuples.
        # For xkb sources, build an IBus engine name like xkb:<layout>::<variant>
        if source_type == "xkb":
            layout, _, variant = source_id.partition("+")
            if variant:
                engine_name = f"xkb:{layout}::{variant}"
            else:
                engine_name = f"xkb:{layout}::"
            logger.debug(
                f"GNOME fallback: current source index {current_idx}, "
                f"engine name: {engine_name}"
            )
            return engine_name
        elif source_type == "ibus":
            # IBus IM engine — return the engine ID directly
            logger.debug(f"GNOME fallback: current source is IBus engine '{source_id}'")
            return source_id

        logger.debug(f"GNOME fallback: unknown source type '{source_type}'")
        return None

    except (
        subprocess.SubprocessError,
        FileNotFoundError,
        ValueError,
        SyntaxError,
    ) as e:
        logger.debug(f"GNOME fallback via gsettings failed: {e}")
        return None


def get_current_engine() -> Optional[str]:
    """
    Get the currently active IBus engine name.

    On GNOME/Wayland, IBus may have no global engine set (Mutter manages input
    sources). In that case ``ibus engine`` can return a misleading
    ``xkb:us::eng`` default that doesn't match the user's actual keyboard
    layout. We detect this and fall back to reading the layout from GNOME's
    gsettings so the real layout is preserved during engine restore.

    Returns:
        The IBus engine name, or None if it cannot be determined.
    """
    try:
        result = subprocess.run(
            ["ibus", "engine"],
            capture_output=True,
            text=True,
            timeout=5,
        )

        # Check for "No global engine" in stderr (ibus-daemon has no
        # global engine set — common on GNOME/Wayland where Mutter manages
        # input sources).
        stderr_lower = result.stderr.lower() if result.stderr else ""
        if "no global engine" in stderr_lower:
            logger.debug(
                "ibus engine reports 'No global engine'; " "attempting GNOME gsettings fallback"
            )
            return get_current_engine_gnome_fallback()

        if result.returncode == 0:
            engine = result.stdout.strip()
            if not engine:
                return None

            # On GNOME/Wayland, ``ibus engine`` can return ``xkb:us::eng``
            # as a default fallback even when the user's actual layout is
            # different (e.g. Brazilian ABNT2). Detect this suspicious
            # default and try the GNOME gsettings fallback for the real
            # layout before trusting the IBus output (see issue #497).
            if engine == "xkb:us::eng" and _is_gnome_session() and _is_wayland_session():
                logger.debug(
                    "ibus engine returned 'xkb:us::eng' on GNOME/Wayland; "
                    "attempting GNOME gsettings fallback (see #497)"
                )
                gnome_engine = get_current_engine_gnome_fallback()
                if gnome_engine and gnome_engine != engine:
                    logger.debug(
                        f"GNOME gsettings reports different engine: "
                        f"{gnome_engine} (was {engine})"
                    )
                    return gnome_engine

            return engine
    except (subprocess.SubprocessError, FileNotFoundError):
        pass
    return None


def _is_wayland_session() -> bool:
    """Check if the current session is running under Wayland."""
    return os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"


def get_current_xkb_layout() -> tuple:
    """
    Get the current XKB keyboard layout, variant, and options.

    This queries the system's current keyboard configuration using setxkbmap.
    This is important because IBus may not reflect the actual XKB layout,
    especially when the user configured their keyboard via desktop environment
    settings or setxkbmap directly rather than through IBus.

    On Wayland, setxkbmap does not reflect the compositor's keyboard state
    and can return incorrect results. Returns empty values in that case to
    signal that XKB layout management should be skipped.

    Returns:
        A tuple of (layout, variant, option). Returns ("", "", "") on Wayland
        or on error, ("us", "", "") as X11 default.
    """
    if _is_wayland_session():
        logger.debug(
            "Wayland session detected — skipping setxkbmap query. "
            "Keyboard layout is managed by the Wayland compositor."
        )
        return "", "", ""

    try:
        result = subprocess.run(
            ["setxkbmap", "-query"],
            capture_output=True,
            text=True,
            timeout=2,
        )
        if result.returncode == 0:
            layout, variant, option = "us", "", ""
            for line in result.stdout.split("\n"):
                line = line.strip()
                if line.startswith("layout:"):
                    layout = line.split(":", 1)[1].strip()
                elif line.startswith("variant:"):
                    variant = line.split(":", 1)[1].strip()
                elif line.startswith("options:"):
                    option = line.split(":", 1)[1].strip()
            logger.debug(f"Current XKB layout: {layout}, variant: {variant}, option: {option}")
            return layout, variant, option
    except (subprocess.SubprocessError, FileNotFoundError) as e:
        logger.debug(f"Could not query XKB layout: {e}")
    return "us", "", ""


def restore_xkb_layout(layout: str, variant: str = "", option: str = "") -> bool:
    """
    Restore the XKB keyboard layout.

    This is used to ensure the user's keyboard layout is preserved
    after IBus engine operations that might change it.

    Args:
        layout: The XKB layout to set (e.g., "us", "es", "de")
        variant: Optional layout variant
        option: Optional layout options

    Returns:
        True if successful, False otherwise
    """
    if not layout:
        return False

    # On Wayland the compositor owns the keyboard layout. setxkbmap only reaches
    # the XWayland X11 server, so re-applying a layout here (or the historical
    # "us" default when nothing was captured) silently switches XWayland — and
    # therefore XWayland/Electron apps — to the wrong layout, while native
    # Wayland apps and localectl stay correct. Skip it on Wayland and let the
    # compositor manage the layout. See issue #474.
    if _is_wayland_session():
        logger.debug(
            "Wayland session detected — skipping setxkbmap; the compositor "
            "manages the keyboard layout (see #474)."
        )
        return False

    try:
        cmd = ["setxkbmap", "-layout", layout]
        if variant:
            cmd.extend(["-variant", variant])
        if option:
            # Clear existing options first, then set new ones
            cmd = ["setxkbmap", "-option", ""] + cmd[1:]
            cmd.extend(["-option", option])

        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=2,
        )
        if result.returncode == 0:
            logger.info(f"Restored XKB layout: {layout} (variant: {variant}, option: {option})")
            return True
        else:
            logger.warning(f"Failed to restore XKB layout: {result.stderr}")
    except (subprocess.SubprocessError, FileNotFoundError) as e:
        logger.error(f"Could not restore XKB layout: {e}")
    return False


def _handle_engine_destroy(
    active_instance: Optional["VocalinuxEngine"],
    current_instance: object,
    ibus_available: bool,
    super_destroy: Optional[Callable[[], None]] = None,
) -> Optional["VocalinuxEngine"]:
    """Compute next active engine state and invoke optional parent destroy."""
    next_active_instance = active_instance
    if active_instance is current_instance:
        next_active_instance = None

    if ibus_available and super_destroy is not None:
        super_destroy()

    return next_active_instance


def switch_engine(engine_name: str) -> bool:
    """Switch to the specified IBus engine."""
    import time

    try:
        import subprocess

        subprocess.run(
            ["ibus", "engine", engine_name],
            capture_output=True,
            text=True,
            timeout=5,
        )
        # ibus engine command may return non-zero even on success
        # So we verify by checking the current engine
        time.sleep(0.2)
        current = get_current_engine()
        if current == engine_name:
            return True
        else:
            logger.warning(f"Engine switch failed: expected {engine_name}, got {current}")
            return False
    except (subprocess.SubprocessError, FileNotFoundError) as e:
        logger.error(f"Failed to switch IBus engine: {e}")
        return False


def is_engine_process_running() -> bool:
    """Check if the Vocalinux IBus engine process is running."""
    try:
        if not PID_FILE.exists():
            return False

        pid = int(PID_FILE.read_text().strip())
        # Check if process exists and is our engine
        os.kill(pid, 0)  # Raises OSError if process doesn't exist
        # Verify it's actually our process by checking cmdline
        cmdline_path = Path(f"/proc/{pid}/cmdline")
        if cmdline_path.exists():
            cmdline = cmdline_path.read_text()
            return "ibus_engine.py" in cmdline and "vocalinux" in cmdline
        return True  # Process exists but can't verify cmdline
    except (OSError, ValueError, FileNotFoundError):
        # Process doesn't exist or PID file is invalid
        if PID_FILE.exists():
            PID_FILE.unlink()  # Clean up stale PID file
        return False


def start_engine_process() -> bool:
    """
    Start the IBus engine process in the background.

    This should be called by Vocalinux on startup to ensure the engine
    is running before attempting to switch to it.

    Returns:
        True if the engine was started or is already running, False otherwise
    """
    import time

    if is_engine_process_running():
        logger.debug("IBus engine process already running")
        return True

    engine_script = Path(__file__).resolve()
    logger.info(f"Starting IBus engine process: {engine_script}")

    try:
        # Start the engine process using the same Python interpreter
        # and inherit the current environment (for venv compatibility)
        env = os.environ.copy()
        # Ensure PYTHONPATH includes current site-packages if in a venv
        if hasattr(sys, "prefix") and sys.prefix != sys.base_prefix:
            # We're in a virtual environment - ensure it's preserved
            env["VIRTUAL_ENV"] = sys.prefix
            # Keep PATH so the venv's bin is first

        process = subprocess.Popen(
            [sys.executable, str(engine_script)],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
            env=env,
        )

        # Write PID file for tracking
        ensure_ibus_dir()
        PID_FILE.write_text(str(process.pid))

        # Wait a bit for the engine to start
        for _ in range(10):
            time.sleep(0.2)
            if is_engine_process_running():
                logger.info("IBus engine process started successfully")
                return True

        logger.error("IBus engine process failed to start")
        return False

    except Exception as e:
        logger.error(f"Failed to start IBus engine process: {e}")
        return False


def stop_engine_process() -> None:
    """Stop the IBus engine process if running."""
    try:
        if not PID_FILE.exists():
            logger.debug("No PID file found, engine not running")
            return

        pid = int(PID_FILE.read_text().strip())
        # Verify it's our process before killing
        cmdline_path = Path(f"/proc/{pid}/cmdline")
        if cmdline_path.exists():
            cmdline = cmdline_path.read_text()
            if "ibus_engine.py" not in cmdline or "vocalinux" not in cmdline:
                logger.warning(f"PID {pid} is not our engine process, skipping kill")
                PID_FILE.unlink()
                return

        os.kill(pid, signal.SIGTERM)
        logger.info(f"IBus engine process (PID {pid}) stopped")
        PID_FILE.unlink()
    except (OSError, ValueError, FileNotFoundError) as e:
        logger.debug(f"Failed to stop IBus engine process: {e}")
        if PID_FILE.exists():
            PID_FILE.unlink()


class VocalinuxEngine(IBus.Engine if IBUS_AVAILABLE else object):
    """
    IBus proxy engine for Vocalinux text injection.

    This engine acts as a transparent proxy:
    - All keyboard input passes through unchanged
    - Text injection requests are received via Unix socket
    - Injected text is committed directly to the focused application

    Users should set this as their default input method for seamless
    voice dictation support.
    """

    if IBUS_AVAILABLE:
        __gtype_name__ = "VocalinuxEngine"

    # Class-level reference to active engine instance
    _active_instance: Optional["VocalinuxEngine"] = None
    _socket_server: Optional[threading.Thread] = None
    _server_socket: Optional[socket.socket] = None
    _server_running: bool = False

    def __init__(self):
        """Initialize the Vocalinux IBus engine."""
        if IBUS_AVAILABLE:
            super().__init__()
        logger.debug("VocalinuxEngine instance created")

    def do_enable(self) -> None:
        """Called when the engine is enabled/selected."""
        logger.info("VocalinuxEngine enabled - ready for text injection")
        VocalinuxEngine._active_instance = self

        # Start socket server if not already running
        if VocalinuxEngine._socket_server is None:
            self._start_socket_server()

    def do_disable(self) -> None:
        """Called when the engine is disabled/deselected."""
        logger.debug("VocalinuxEngine disabled (keeping instance for injection)")
        # NOTE: We intentionally do NOT clear _active_instance here.
        # IBus calls do_disable when focus changes between windows, but we still
        # want to be able to inject text. The engine process keeps running and
        # the instance remains valid for text injection.

    def do_destroy(self) -> None:
        """Called when the engine instance is destroyed by IBus (e.g. layout switch)."""
        logger.debug("VocalinuxEngine instance destroyed")
        super_destroy: Optional[Callable[[], None]] = None
        if IBUS_AVAILABLE:
            super_destroy = super().do_destroy
        VocalinuxEngine._active_instance = _handle_engine_destroy(
            VocalinuxEngine._active_instance,
            self,
            IBUS_AVAILABLE,
            super_destroy,
        )

    def do_focus_in(self) -> None:
        """Called when the engine gains focus."""
        logger.debug("VocalinuxEngine focus in")
        VocalinuxEngine._active_instance = self

    def do_focus_out(self) -> None:
        """Called when the engine loses focus."""
        logger.debug("VocalinuxEngine focus out")

    def do_process_key_event(self, keyval: int, keycode: int, state: int) -> bool:
        """
        Process key events.

        We pass through ALL keys unchanged since this is a proxy engine.
        The actual keyboard layout handling is done by the system.
        """
        return False  # Don't consume any keys - pass through

    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:
            return True

        try:
            logger.debug(f"Injecting text: {text[:50]}...")
            self.commit_text(IBus.Text.new_from_string(text))
            logger.info(f"Text injected via IBus: '{text[:20]}...' ({len(text)} chars)")
            return True
        except Exception as e:
            logger.error(f"Failed to inject text: {e}")
            return False

    @classmethod
    def _start_socket_server(cls) -> None:
        """Start the Unix socket server for receiving injection requests."""
        ensure_ibus_dir()

        # Remove existing socket
        if SOCKET_PATH.exists():
            SOCKET_PATH.unlink()

        cls._server_running = True

        def server_thread():  # pragma: no cover
            try:
                cls._server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
                cls._server_socket.bind(str(SOCKET_PATH))
                cls._server_socket.listen(1)
                SOCKET_PATH.chmod(0o600)  # Only user can access

                logger.info(f"Socket server listening on {SOCKET_PATH}")

                while cls._server_running:
                    try:
                        conn, _ = cls._server_socket.accept()
                        with conn:
                            # Verify peer is same user (defense in depth)
                            if not verify_peer_credentials(conn):
                                conn.sendall(b"UNAUTHORIZED")
                                continue

                            data = conn.recv(65536)  # Max text size
                            if data:
                                if data == b"\x00PING":
                                    if cls._active_instance:
                                        conn.sendall(b"OK")
                                    else:
                                        conn.sendall(b"NO_ENGINE")
                                    continue

                                text = data.decode("utf-8")
                                # Snapshot active instance to avoid TOCTOU race with do_destroy
                                instance = cls._active_instance
                                if instance:
                                    done = threading.Event()
                                    injection_result = {"ok": False}

                                    def do_inject(t, inst=instance):
                                        try:
                                            injection_result["ok"] = inst.inject_text(t)
                                        finally:
                                            done.set()
                                        return False  # Run only once

                                    GLib.idle_add(do_inject, text)
                                    if done.wait(timeout=3.0):
                                        conn.sendall(b"OK" if injection_result["ok"] else b"ERROR")
                                    else:
                                        logger.error("Timed out waiting for IBus text commit")
                                        conn.sendall(b"TIMEOUT")
                                else:
                                    logger.warning("No active engine instance")
                                    conn.sendall(b"NO_ENGINE")
                    except OSError as e:
                        # Check if we're shutting down
                        if not cls._server_running:
                            logger.debug("Socket server shutting down")
                            break
                        logger.error(f"Socket connection error: {e}")
                    except Exception as e:
                        logger.error(f"Socket connection error: {e}")

            except Exception as e:
                if cls._server_running:
                    logger.error(f"Socket server error: {e}")

        cls._socket_server = threading.Thread(target=server_thread, daemon=True)
        cls._socket_server.start()

    @classmethod
    def stop_socket_server(cls) -> None:
        """Stop the socket server."""
        cls._server_running = False
        if cls._server_socket:
            try:
                cls._server_socket.close()
            except Exception:
                pass
            cls._server_socket = None

        if SOCKET_PATH.exists():
            SOCKET_PATH.unlink()


class VocalinuxEngineApplication:
    """
    Application wrapper for running the Vocalinux IBus engine.

    This is used when the engine is launched by IBus as a separate process.
    """

    def __init__(self, exec_by_ibus: bool = False):
        """Initialize the engine application.

        Args:
            exec_by_ibus: True when IBus launched this process (via --ibus flag).
                          False when Vocalinux launched it directly.
        """
        if not IBUS_AVAILABLE:
            raise RuntimeError("IBus is not available")

        self.mainloop = GLib.MainLoop()
        self.bus = IBus.Bus()

        if not self.bus.is_connected():
            logger.error("IBus.Bus() is NOT connected — engine cannot register")
            raise RuntimeError("IBus bus not connected")

        self.bus.connect("disconnected", self._on_disconnected)

        conn = self.bus.get_connection()
        if conn is None:
            logger.error("bus.get_connection() returned None")
            raise RuntimeError("IBus bus connection is None")
        self.factory = IBus.Factory.new(conn)
        self.factory.add_engine(
            ENGINE_NAME,
            GObject.type_from_name("VocalinuxEngine"),
        )

        if exec_by_ibus:
            # IBus launched us — it already knows about our component,
            # just claim the well-known D-Bus name.
            if not self.bus.request_name(COMPONENT_NAME, 0):
                logger.error("bus.request_name() failed")
                raise RuntimeError("Failed to acquire IBus D-Bus name")
        else:
            # Launched by Vocalinux directly — register the full component
            # so IBus discovers our engine without having launched us.
            component = IBus.Component(
                name=COMPONENT_NAME,
                description=ENGINE_DESCRIPTION,
                command_line=_get_exec_command(),
                **_COMPONENT_META,
            )
            engine_desc = IBus.EngineDesc(
                name=ENGINE_NAME,
                longname=ENGINE_LONGNAME,
                description=ENGINE_DESCRIPTION,
                rank=ENGINE_RANK,
                **_ENGINE_META,
            )
            component.add_engine(engine_desc)
            if not self.bus.register_component(component):
                logger.error("bus.register_component() failed")
                raise RuntimeError("Failed to register IBus component")
            logger.info("Registered component with IBus (standalone mode)")

        logger.info("Vocalinux IBus engine started")

        # Start the socket server immediately so it's ready for connections
        # even before the engine is activated via `ibus engine vocalinux`
        VocalinuxEngine._start_socket_server()

    def _on_disconnected(self, bus: "IBus.Bus") -> None:
        """Handle IBus disconnection."""
        logger.info("IBus disconnected, exiting")
        VocalinuxEngine.stop_socket_server()
        self.mainloop.quit()

    def run(self) -> None:
        """Run the engine main loop."""
        self.mainloop.run()


class IBusTextInjector:
    """
    Text injector that uses IBus for text injection.

    This class connects to the Vocalinux IBus engine via Unix socket
    and sends text to be injected. The normal TextInjector warmup path starts
    the engine process without selecting it as the user's active IBus engine.
    Each injection temporarily switches to the Vocalinux engine, commits the
    text, and restores the user's previous engine.

    The auto_activate=True path still performs the older eager activation flow
    for compatibility with direct callers and setup tests.
    """

    def __init__(self, auto_activate: bool = True):
        """
        Initialize the IBus text injector.

        Args:
            auto_activate: If True, automatically install and activate the engine
        """
        if not IBUS_AVAILABLE:
            raise RuntimeError("IBus is not available")

        ensure_ibus_dir()
        self._previous_engine: Optional[str] = None
        # Empty until a real X11 layout is captured. A non-empty default (e.g.
        # "us") would make stop()/restore re-apply a layout that was never
        # captured, flipping non-US users to us. See issue #474.
        self._previous_xkb_layout: tuple = ("", "", "")

        if auto_activate:
            self._setup_engine()

    def prepare_engine(self) -> None:
        """
        Start the IBus engine process without selecting it as the active engine.

        The Vocalinux engine is only needed while committing dictated text. Keeping
        the user's normal IBus/XKB engine active preserves dead-key composition and
        layout-specific behavior during ordinary typing.
        """
        # Capture the current engine before starting the Vocalinux process
        # so inject_text() can restore it after committing text
        if not is_engine_active():
            self._previous_engine = get_current_engine()
            if self._previous_engine:
                logger.debug(f"Captured current engine for later restore: {self._previous_engine}")

        if not start_engine_process():
            raise IBusSetupError("Failed to start IBus engine process. Check logs for details.")

        self._wait_for_engine_ready(require_active=False)

    def _setup_engine(self) -> None:
        """Install and activate the IBus engine."""
        # Start the engine process — it calls register_component() via
        # D-Bus which makes the engine available even when the IBus daemon
        # doesn't scan ~/.local/share/ibus/component/.
        # Follow-up to PR #304: register_component() is the reliable path,
        # so we no longer gate on is_engine_registered() / ibus list-engine.
        if not start_engine_process():
            raise IBusSetupError("Failed to start IBus engine process. Check logs for details.")

        # Capture current XKB layout before switching engines
        # This is critical for preserving the user's keyboard layout
        # when IBus engine switching might override it
        self._previous_xkb_layout = get_current_xkb_layout()
        logger.debug(
            f"Captured XKB layout: {self._previous_xkb_layout[0]}, "
            f"variant: {self._previous_xkb_layout[1]}, option: {self._previous_xkb_layout[2]}"
        )

        # Save current engine and switch to Vocalinux
        if not is_engine_active():
            self._previous_engine = get_current_engine()
            if self._previous_engine:
                logger.info(f"Saving current engine: {self._previous_engine}")

            logger.info("Activating Vocalinux IBus engine...")
            if switch_engine(ENGINE_NAME):
                logger.info("Vocalinux IBus engine activated")
            else:
                raise IBusSetupError(
                    "Failed to activate Vocalinux IBus engine. "
                    "Try manually: ibus engine vocalinux"
                )

        self._wait_for_engine_ready()

        # Restore the user's XKB layout immediately after engine activation.
        # Switching to the Vocalinux IBus engine can override the system
        # keyboard layout (e.g. Spanish, French AZERTY) with the engine's
        # default layout. Re-applying the captured XKB layout ensures the
        # user's keyboard keeps working correctly while Vocalinux is active.
        # See issue #292.
        if self._previous_xkb_layout:
            layout, variant, option = self._previous_xkb_layout
            restore_xkb_layout(layout, variant, option)

    def _wait_for_engine_ready(self, max_attempts: int = 8, require_active: bool = True) -> None:
        delay = 0.25

        for attempt in range(max_attempts):
            try:
                if not SOCKET_PATH.exists():
                    raise FileNotFoundError("IBus engine socket not found")

                with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
                    sock.settimeout(2.0)
                    sock.connect(str(SOCKET_PATH))
                    sock.sendall(b"\x00PING")
                    response = sock.recv(64).decode("utf-8")

                if response == "OK":
                    logger.debug(f"IBus engine readiness probe succeeded on attempt {attempt + 1}")
                    return

                if response == "NO_ENGINE" and not require_active:
                    logger.debug("IBus engine socket is ready; active engine instance not required")
                    return

                if response != "NO_ENGINE":
                    logger.warning(f"Unexpected readiness probe response: {response}")
            except (socket.timeout, FileNotFoundError, ConnectionRefusedError, OSError) as e:
                logger.debug(f"IBus readiness probe attempt {attempt + 1} failed: {e}")

            if attempt < max_attempts - 1:
                time.sleep(delay)
                delay = min(delay * 2, 2.0)

        raise IBusSetupError(
            "Vocalinux IBus engine did not become ready after startup. "
            "Try manually: ibus engine vocalinux"
        )

    def stop(self) -> None:
        """
        Stop the IBus text injector and restore previous engine and XKB layout.

        Call this when Vocalinux is shutting down. If no valid engine was
        captured before injection (e.g. on GNOME/Wayland with no global IBus
        engine), restoration is skipped to avoid switching the user to a
        wrong layout (see issue #497).
        """
        if self._previous_engine:
            logger.info(f"Restoring previous engine: {self._previous_engine}")
            switch_engine(self._previous_engine)
            self._previous_engine = None
        else:
            logger.info(
                "Skipping engine restoration — " "no valid engine was captured before injection"
            )

        # Restore the XKB layout that was captured during setup
        # This ensures the user's original keyboard layout is preserved
        if self._previous_xkb_layout:
            layout, variant, option = self._previous_xkb_layout
            if layout:
                logger.info(f"Restoring XKB layout: {layout}")
                restore_xkb_layout(layout, variant, option)
            self._previous_xkb_layout = None

        # Stop the engine process
        stop_engine_process()

    def inject_text(self, text: str) -> bool:
        """
        Inject text using the IBus engine.

        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 IBus text injection: '{text[:20]}...' (length: {len(text)})")

        restore_engine: Optional[str] = None
        if not is_engine_active():
            current_engine = get_current_engine() or self._previous_engine
            if current_engine:
                restore_engine = current_engine
                logger.debug(
                    "Temporarily activating Vocalinux IBus engine "
                    f"(will restore {current_engine})"
                )
            else:
                logger.debug(
                    "Could not determine current IBus engine; "
                    "activating Vocalinux engine without restore"
                )
            if not switch_engine(ENGINE_NAME):
                logger.error("Failed to activate Vocalinux IBus engine for injection")
                return False

        # Try injection with bounded retries for transient socket/engine races.
        # This can happen if IBus re-created the engine instance or if the
        # engine process/socket is still coming up when dictation ends.
        max_attempts = 3

        def restart_engine_process() -> bool:
            logger.warning("IBus engine process is not running, restarting...")
            if not start_engine_process():
                logger.error("Failed to restart IBus engine process")
                return False
            time.sleep(0.3)
            return True

        try:
            for attempt in range(max_attempts):
                try:
                    if not SOCKET_PATH.exists():
                        if not is_engine_process_running() and not restart_engine_process():
                            return False

                        if attempt < max_attempts - 1:
                            logger.warning(
                                "IBus engine socket not found on attempt "
                                f"{attempt + 1}/{max_attempts}; retrying..."
                            )
                            time.sleep(0.2 * (attempt + 1))
                            continue

                        logger.error(
                            "IBus engine socket not found. "
                            "Make sure Vocalinux IBus engine is running."
                        )
                        return False

                    # Connect to engine socket and send text
                    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
                        sock.settimeout(5.0)
                        sock.connect(str(SOCKET_PATH))
                        sock.sendall(text.encode("utf-8"))

                        # Wait for response
                        response = sock.recv(64).decode("utf-8")
                        if response == "OK":
                            logger.debug("Text injection successful")
                            return True
                        elif response == "NO_ENGINE" and attempt < max_attempts - 1:
                            # Engine instance was destroyed (layout switch).
                            # Re-activate to create a new instance and retry.
                            logger.info("Engine instance not active, re-activating and retrying...")
                            switch_engine(ENGINE_NAME)
                            time.sleep(0.3)
                            continue
                        else:
                            logger.error(f"Text injection failed: {response}")
                            return False

                except socket.timeout:
                    if attempt < max_attempts - 1:
                        logger.warning(
                            "Timeout connecting to IBus engine on attempt "
                            f"{attempt + 1}/{max_attempts}; retrying..."
                        )
                        time.sleep(0.2 * (attempt + 1))
                        continue
                    logger.error("Timeout connecting to IBus engine")
                    return False
                except ConnectionRefusedError as e:
                    if attempt < max_attempts - 1:
                        logger.warning(
                            "IBus engine refused connection on attempt "
                            f"{attempt + 1}/{max_attempts}: {e}. Retrying..."
                        )
                        if not is_engine_process_running() and not restart_engine_process():
                            return False
                        time.sleep(0.2 * (attempt + 1))
                        continue
                    logger.error(f"Failed to inject text via IBus: {e}")
                    return False
                except FileNotFoundError:
                    if attempt < max_attempts - 1:
                        logger.warning(
                            "IBus engine socket disappeared on attempt "
                            f"{attempt + 1}/{max_attempts}; retrying..."
                        )
                        if not is_engine_process_running() and not restart_engine_process():
                            return False
                        time.sleep(0.2 * (attempt + 1))
                        continue
                    logger.error("IBus engine socket not found")
                    return False
                except Exception as e:
                    logger.error(f"Failed to inject text via IBus: {e}")
                    return False
        finally:
            if restore_engine:
                logger.debug(f"Restoring IBus engine after injection: {restore_engine}")
                switch_engine(restore_engine)
            else:
                logger.debug(
                    "Skipping engine restoration after injection — "
                    "no valid engine was captured (see #497)"
                )

        return False


def _get_engines_xml() -> str:
    """Return engine XML for IBus --xml discovery.

    IBus invokes ``<exec> --xml`` during ``ibus write-cache`` and
    ``ibus list-engine`` to discover available engines.  The expected
    output is a bare ``<engines>`` block printed to stdout.
    """
    e = _ENGINE_META

    return f"""<engines>
    <engine>
        <name>{ENGINE_NAME}</name>
        <longname>{ENGINE_LONGNAME}</longname>
        <language>{e['language']}</language>
        <license>{e['license']}</license>
        <author>{e['author']}</author>
        <icon>{e['icon']}</icon>
        <layout>{e['layout']}</layout>
        <layout_variant />
        <layout_option />
        <description>{ENGINE_DESCRIPTION}</description>
        <rank>{ENGINE_RANK}</rank>
    </engine>
</engines>"""


def main():
    """Entry point when run as IBus engine process."""
    # IBus calls the exec with --xml to discover engines during
    # ibus write-cache and ibus list-engine.  Respond and exit
    # immediately — do not enter the GLib main loop.
    if "--xml" in sys.argv:
        print(_get_engines_xml())
        return 0

    logging.basicConfig(
        level=logging.DEBUG,
        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    )

    if not IBUS_AVAILABLE:
        logger.error("IBus is not available")
        return 1

    IBus.init()
    exec_by_ibus = "--ibus" in sys.argv
    app = VocalinuxEngineApplication(exec_by_ibus=exec_by_ibus)
    app.run()
    return 0


if __name__ == "__main__":
    sys.exit(main())
