#!/usr/bin/env python3
"""
Linux driver for octavus-computer-use MCP.

Implements the same CLI + JSON protocol as the macOS Swift driver,
using AT-SPI2 for accessibility tree, xdotool for input simulation,
scrot/xdpyinfo for screenshots, and Pillow for badge annotation.

Dependencies (apt):
    python3-gi gir1.2-atspi-2.0 at-spi2-core xdotool scrot python3-pil
    fonts-dejavu-core (a scalable bold TTF for grid/label overlays; see
    _BOLD_FONT_CANDIDATES)

Usage mirrors the macOS driver exactly:
    linux-driver.py observe --screenshot /tmp/shot.png [--pid PID]
    linux-driver.py click <x> <y> [--hold-seconds N]
    linux-driver.py double-click <x> <y>
    linux-driver.py right-click <x> <y>
    linux-driver.py type <text>
    linux-driver.py key <key-spec>
    linux-driver.py scroll <direction> [steps] [--at x y]
    linux-driver.py drag <from_x> <from_y> <to_x> <to_y> [--steps N] [--path x1,y1 ...] [--hold-seconds N]
    linux-driver.py screenshot <path>
    linux-driver.py screenshot-grid <path> --region x y w h --grid <2|3>
    linux-driver.py activate <app-name>
    linux-driver.py read-clipboard
"""

import ctypes
import ctypes.util
import functools
import glob
import json
import os
import re
import shlex
import signal
import subprocess
import sys
import time
import shutil

# ---------------------------------------------------------------------------
# Output helpers (match Swift driver JSON contract)
# ---------------------------------------------------------------------------

def print_success(path=None, **fields):
    resp = {"success": True, **fields}
    if path is not None:
        resp["path"] = path
    print(json.dumps(resp))

def exit_with_error(message):
    print(json.dumps({"error": message}))
    sys.exit(1)

def _run(cmd, check=True, capture=True):
    try:
        r = subprocess.run(
            cmd,
            capture_output=capture,
            text=True,
            timeout=15,
            check=check,
        )
        return r
    except FileNotFoundError:
        exit_with_error(f"Required command not found: {cmd[0]}")
    except subprocess.CalledProcessError as e:
        exit_with_error(f"Command failed: {' '.join(cmd)}\n{e.stderr or e.stdout or ''}")
    except subprocess.TimeoutExpired:
        exit_with_error(f"Command timed out: {' '.join(cmd)}")

# ---------------------------------------------------------------------------
# Screen geometry
# ---------------------------------------------------------------------------

def get_screen_size():
    """Return (width, height) of the root window via xdotool."""
    try:
        out = subprocess.check_output(
            ["xdotool", "getdisplaygeometry"], text=True, timeout=5
        ).strip()
        w, h = out.split()
        return int(w), int(h)
    except Exception:
        return 1920, 1080

# ---------------------------------------------------------------------------
# AT-SPI2 accessibility tree
# ---------------------------------------------------------------------------

CLICKABLE_ROLES = {
    "push button", "toggle button", "link", "menu item", "menu",
    "check box", "check menu item", "radio button", "radio menu item",
    "combo box", "text", "password text", "entry", "spin button",
    "slider", "tab", "page tab", "tool bar button",
}

CONTAINER_ROLES = {
    "frame", "dialog", "window", "scroll pane", "panel",
    "filler", "application", "desktop frame", "root pane",
    "layered pane", "split pane", "viewport",
}

ACTIONABLE_ACTIONS = {"click", "press", "activate", "toggle", "jump"}

# Roles that are never a control, whatever action their toolkit advertises:
# LibreOffice exposes a "click" on every toolbar separator.
NON_ACTIONABLE_ROLES = {"separator"}


def _try_import_atspi():
    """Import AT-SPI2 via GObject introspection and connect to the a11y bus."""
    try:
        import gi
        gi.require_version("Atspi", "2.0")
        from gi.repository import Atspi
        Atspi.init()
        return Atspi
    except Exception:
        return None


def _has_actionable(node, Atspi):
    try:
        action = node.get_action_iface()
        if action is None:
            return False
        n = action.get_n_actions()
        for i in range(n):
            name = action.get_action_name(i).lower()
            if name in ACTIONABLE_ACTIONS:
                return True
    except Exception:
        pass
    return False


def _overlaps_screen(x, y, w, h, screen_w, screen_h):
    """Whether a screen-coordinate box intersects the visible screen."""
    return x < screen_w and y < screen_h and x + w > 0 and y + h > 0


# The most children of one node the walk descends into. A spreadsheet reports its
# whole sheet as the table's children (Calc: every cell of 1024 x 1048576), which
# no walk could visit within a driver command - and its first rows would be no
# guide to what is on screen anyway. A node with more children than this is a
# data grid, left to grid coordinates; a UI container that is not one has orders
# of magnitude fewer.
_MAX_CHILDREN_WALKED = 2048


def _walk_atspi(
    node,
    elements,
    Atspi,
    screen_w,
    screen_h,
    depth=0,
    max_depth=20,
    trust_geometry=False,
    window_origin=None,
):
    """Collect the on-screen actionable elements under ``node``.

    ``window_origin`` is set for a toolkit whose extents are window-relative
    (see ``_uses_window_relative_extents``): extents are then read in WINDOW
    coordinates and offset by the window's on-screen client-area origin.
    """
    if depth > max_depth:
        return

    visible = False
    showing = False
    try:
        state_set = node.get_state_set()
        visible = state_set.contains(Atspi.StateType.VISIBLE)
        showing = state_set.contains(Atspi.StateType.SHOWING)
    except Exception:
        pass

    try:
        role_name = node.get_role_name().lower()
    except Exception:
        role_name = ""

    rect = None
    try:
        comp = node.get_component_iface()
        if comp is not None:
            if window_origin is None:
                extents = comp.get_extents(Atspi.CoordType.SCREEN)
                rect = (extents.x, extents.y, extents.width, extents.height)
            else:
                extents = comp.get_extents(Atspi.CoordType.WINDOW)
                rect = (
                    extents.x + window_origin[0],
                    extents.y + window_origin[1],
                    extents.width,
                    extents.height,
                )
    except Exception:
        rect = None
    # GTK3 answers for an accessible without geometry of its own (a GtkNotebook
    # page) with -1s: that is "no geometry", like having no Component interface at
    # all, not a box that is off the screen.
    if rect is not None and (rect[2] < 0 or rect[3] < 0):
        rect = None

    on_screen = (
        rect is not None
        and rect[2] > 0
        and rect[3] > 0
        and _overlaps_screen(rect[0], rect[1], rect[2], rect[3], screen_w, screen_h)
    )

    # Include this node as an actionable element when it is an on-screen clickable
    # widget. Inclusion keys on real screen geometry, not the AT-SPI SHOWING flag:
    # some toolkits (notably GTK4, e.g. gnome-text-editor) leave SHOWING unset on
    # every on-screen descendant below the top frame, so gating on SHOWING would
    # drop the entire app's controls. `trust_geometry` extends this to the VISIBLE
    # flag, which GTK4 likewise leaves unset on those descendants: for a native
    # foreground app, geometry + role decide inclusion; for Chromium (whose web
    # a11y flags are reliable) VISIBLE stays required, so web labeling never
    # surfaces hidden-but-positioned elements. Off-screen and zero-area nodes fail
    # the on_screen test regardless.
    if rect is not None and on_screen and (trust_geometry or visible):
        if role_name and (
            role_name in CLICKABLE_ROLES
            or (role_name not in NON_ACTIONABLE_ROLES and _has_actionable(node, Atspi))
        ):
            x, y, w, h = rect
            area = w * h
            screen_area = screen_w * screen_h
            too_big = (
                role_name in CONTAINER_ROLES and area > screen_area * 0.25
            ) or area > screen_area * 0.5
            if not too_big:
                title = ""
                try:
                    title = node.get_name() or ""
                except Exception:
                    pass
                if not title:
                    try:
                        title = node.get_description() or ""
                    except Exception:
                        pass
                if not title:
                    try:
                        text_iface = node.get_text_iface()
                        if text_iface:
                            val = text_iface.get_text(0, min(80, text_iface.get_character_count()))
                            if val:
                                title = val
                    except Exception:
                        pass
                if len(title) > 60:
                    title = title[:60] + "..."

                elements.append({
                    "role": role_name,
                    "title": title,
                    "state": _get_element_state(node, role_name, Atspi),
                    "x": x, "y": y,
                    "width": w, "height": h,
                })

    # Descend into children unless this node is provably off-screen. Keep walking
    # through nodes with no geometry (structural containers such as the
    # `application` node, which reports VISIBLE=False and no extents) and through
    # on-screen containers that merely lack SHOWING; prune a node whose real box
    # is off-screen or zero-area. For Chromium that prune also requires the node
    # not to be SHOWING: web layouts routinely hang visible, absolutely positioned
    # content under a zero-size wrapper, and off-viewport content is what bounds
    # the walk over a large page. A native toolkit clips children to their
    # parent's allocation, so a zero-area or off-screen native container shows
    # nothing whatever its SHOWING flag says - LibreOffice keeps a SHOWING,
    # zero-height copy of its menu bar whose items would otherwise be labeled on
    # top of the toolbar drawn below it.
    if rect is not None and not on_screen and (trust_geometry or not showing):
        return

    try:
        n_children = node.get_child_count()
        if n_children > _MAX_CHILDREN_WALKED:
            return
        for i in range(n_children):
            child = node.get_child_at_index(i)
            if child is not None:
                _walk_atspi(
                    child,
                    elements,
                    Atspi,
                    screen_w,
                    screen_h,
                    depth + 1,
                    max_depth,
                    trust_geometry,
                    window_origin,
                )
    except Exception:
        pass


def _get_element_state(node, role_name, Atspi):
    parts = []
    try:
        state_set = node.get_state_set()
        if role_name in ("check box", "check menu item", "radio button", "radio menu item", "toggle button"):
            if state_set.contains(Atspi.StateType.CHECKED):
                parts.append("selected")
            else:
                parts.append("unselected")
        if state_set.contains(Atspi.StateType.FOCUSED):
            parts.append("focused")
        if role_name in ("text", "password text", "entry", "spin button", "combo box"):
            try:
                text_iface = node.get_text_iface()
                if text_iface:
                    val = text_iface.get_text(0, min(40, text_iface.get_character_count()))
                    if val:
                        if len(val) > 40:
                            val = val[:40] + "..."
                        parts.append(f'value="{val}"')
            except Exception:
                pass
    except Exception:
        pass
    return ", ".join(parts)


def _dedup_elements(elements, screen_w, screen_h):
    elements.sort(key=lambda e: e["width"] * e["height"])

    deduped = []
    for el in elements:
        area = el["width"] * el["height"]
        if area > screen_w * screen_h * 0.5:
            continue

        cx = el["x"] + el["width"] / 2
        cy = el["y"] + el["height"] / 2
        is_dup = False
        for ex in deduped:
            ex_cx = ex["x"] + ex["width"] / 2
            ex_cy = ex["y"] + ex["height"] / 2
            dist = ((cx - ex_cx) ** 2 + (cy - ex_cy) ** 2) ** 0.5
            if dist < 12.0:
                is_dup = True
                break
            if (ex["x"] >= el["x"] and ex["y"] >= el["y"]
                    and ex["x"] + ex["width"] <= el["x"] + el["width"]
                    and ex["y"] + ex["height"] <= el["y"] + el["height"]
                    and dist < 30.0):
                is_dup = True
                break
        if not is_dup:
            deduped.append(el)

    deduped.sort(key=lambda e: (round(e["y"] / 15) * 15, e["x"]))
    return deduped


def _xdotool_output(args, timeout=5):
    """stdout of an xdotool query, or None when it fails (no active window, a
    window gone between two lookups, no WM support)."""
    try:
        return subprocess.check_output(
            ["xdotool", *args], text=True, timeout=timeout, stderr=subprocess.DEVNULL
        ).strip()
    except Exception:
        return None


def _get_active_window_id():
    out = _xdotool_output(["getactivewindow"])
    try:
        return int(out) if out else None
    except ValueError:
        return None


def _window_pid(window_id):
    out = _xdotool_output(["getwindowpid", str(window_id)])
    try:
        return int(out) if out else None
    except ValueError:
        return None


def _window_title(window_id):
    return _xdotool_output(["getwindowname", str(window_id)]) or ""


def _window_geometry(window_id):
    """Screen rect of a window as {id, x, y, width, height}, or None when it is gone."""
    out = _xdotool_output(["getwindowgeometry", "--shell", str(window_id)])
    if not out:
        return None

    vals = {}
    for line in out.splitlines():
        key, sep, value = line.partition("=")
        if sep:
            vals[key.strip()] = value.strip()
    try:
        return {
            "id": int(vals["WINDOW"]),
            "x": int(vals["X"]),
            "y": int(vals["Y"]),
            "width": int(vals["WIDTH"]),
            "height": int(vals["HEIGHT"]),
        }
    except (KeyError, ValueError):
        return None


# Window types that sit in the WM's client list without ever being "in front":
# the desktop (GNOME's desktop-icons window spans the screen at the bottom of the
# stack) and panels/docks.
_BACKDROP_WINDOW_TYPES = ("_NET_WM_WINDOW_TYPE_DESKTOP", "_NET_WM_WINDOW_TYPE_DOCK")


def _is_backdrop_window(window_id):
    """Whether the window is the desktop, a dock, or minimized - never "in front"."""
    with _X11() as x11:
        types = x11.window_property(window_id, "_NET_WM_WINDOW_TYPE", max_items=8) or []
        state = x11.window_property(window_id, "_NET_WM_STATE", max_items=32) or []
        backdrop = {x11.atom(name) for name in _BACKDROP_WINDOW_TYPES}
        return bool(backdrop & set(types)) or x11.atom("_NET_WM_STATE_HIDDEN") in state


def _foreground_window_id():
    """The window in front of the user: the top of the WM's stacking order.

    Usually that is also the focused window. It is not when the window manager
    stacks a window on top but withholds focus from it: mutter's focus-stealing
    prevention does exactly that to a modal dialog another process opens on
    behalf of the focused app (the portal file chooser Chrome's "Open" / "Save as"
    bring up) after synthetic keystrokes, so the dialog covers the browser while
    keystrokes keep landing in the browser. The topmost window is what the agent
    sees and will click, so it is the one labeled - and it is given focus first so
    typing reaches it too. Falls back to the focused window where the WM
    publishes no stacking order, and is None when nothing but the desktop (or a
    dock) is up - the desktop is never "the app in front".
    """
    active = _get_active_window_id()
    for window_id in reversed(_managed_windows_stacked()):
        if _is_backdrop_window(window_id):
            continue
        if window_id != active:
            _focus_window(window_id)
        return window_id
    if active is not None and not _is_backdrop_window(active):
        return active
    return None


def _process_name(pid):
    """The process's command name from /proc, or "" when unreadable."""
    try:
        with open(f"/proc/{pid}/comm", "rb") as f:
            return f.read().decode("utf-8", "ignore").strip()
    except Exception:
        return ""


def _pid_is_chromium(pid):
    """Whether the PID is a Chrome/Chromium process.

    Read from /proc rather than the window class: the cloud xdotool build has no
    getwindowclassname and xprop is not installed, but the browser owns the
    focused window's _NET_WM_PID, so its process name is the reliable signal.
    """
    if pid is None:
        return False
    for proc_file in (f"/proc/{pid}/comm", f"/proc/{pid}/cmdline"):
        try:
            with open(proc_file, "rb") as f:
                data = f.read().replace(b"\x00", b" ").decode("utf-8", "ignore").lower()
        except Exception:
            continue
        if "chrome" in data or "chromium" in data:
            return True
    return False


def _pid_is_libreoffice(pid):
    return pid is not None and _process_name(pid).startswith("soffice")


# How long LibreOffice gets to re-read its frame geometry after the pointer nudge
# below, before its extents are trusted.
_LIBREOFFICE_GEOMETRY_SETTLE = 0.3


def _refresh_libreoffice_geometry(window):
    """Make LibreOffice recompute where its widgets are before they are read.

    LibreOffice (gtk3 VCL) caches its frame geometry and only re-reads it on a
    pointer event: after the window opens and the window manager places and
    decorates it, every toolbar, the document and the status bar keep reporting
    AT-SPI extents one title-bar height above where they are drawn, until the
    pointer first moves inside the window. A labeled toolbar button would then be
    clicked on the menu bar above it. One motion event over the window's center
    is enough; the pointer has no meaning to the agent between actions.
    """
    center = (window["x"] + window["width"] // 2, window["y"] + window["height"] // 2)
    if _pointer_location() == center:
        center = (center[0] + 1, center[1])
    _move_pointer(*center)
    time.sleep(_LIBREOFFICE_GEOMETRY_SETTLE)


def _foreground_window():
    """What is in front: {id, title, pid, x, y, width, height, chromium}, or None.

    Everything `observe` labels is scoped to this window - one desktop, what you
    see is what you get - so it is also what the result reports back, letting the
    agent (and anyone reading the trace) see which window the numbers belong to.
    """
    window_id = _foreground_window_id()
    window = _window_geometry(window_id) if window_id is not None else None
    if window is None:
        return None
    pid = _window_pid(window["id"])
    return {
        **window,
        "title": _window_title(window["id"]),
        "pid": pid,
        "chromium": _pid_is_chromium(pid),
    }


def _apps_for_pid(desktop, target_pid):
    """The AT-SPI application node(s) owned by ``target_pid`` - strictly by PID.

    A None target_pid (foreground PID unavailable) matches no app, so labeling never
    falls back to walking the whole desktop, which would overlay the always-open
    browser's labels onto whatever native app is in front.
    """
    apps = []
    if target_pid is None:
        return apps
    try:
        n_apps = desktop.get_child_count()
    except Exception:
        return apps
    for i in range(n_apps):
        try:
            app = desktop.get_child_at_index(i)
            if app is not None and app.get_process_id() == target_pid:
                apps.append(app)
        except Exception:
            continue
    return apps


def _collect_elements(apps, window, Atspi, screen_w, screen_h, trust_geometry=False):
    """Collect the actionable elements of the foreground window from its app's tree.

    Only the toplevel accessible that corresponds to the focused X11 window is
    walked: an app's other windows (a second Files window behind the first, a
    browser popup, a document behind a modal dialog) are not what the agent sees
    and would otherwise be labeled on top of what is - the same control twice at
    two positions. An app whose toplevel cannot be matched to the focused window
    (nameless frames, a toolkit that never marks one ACTIVE) is walked whole, so a
    scoping miss degrades to extra labels rather than none. `trust_geometry` is
    forwarded to the walk so a native foreground app's on-screen controls are
    included from geometry even when its toolkit leaves the AT-SPI VISIBLE/SHOWING
    flags unset (GTK4).
    """
    elements = []
    for app in apps:
        frame = _focused_frame(app, window, Atspi) if window is not None else None
        if _uses_window_relative_extents(app):
            # Window-relative extents can only be placed for the focused window,
            # whose on-screen origin is known; without a matched frame there is
            # nothing to place them against.
            if frame is not None:
                left, top = _client_side_decoration_offset(window["id"])
                _walk_atspi(
                    frame,
                    elements,
                    Atspi,
                    screen_w,
                    screen_h,
                    trust_geometry=trust_geometry,
                    window_origin=(window["x"] + left, window["y"] + top),
                )
        else:
            _walk_atspi(
                frame if frame is not None else app,
                elements,
                Atspi,
                screen_w,
                screen_h,
                trust_geometry=trust_geometry,
            )
    return elements


def _uses_window_relative_extents(app):
    """Whether the app's AT-SPI extents are relative to its window, not the screen.

    GTK 4 has no global coordinates: 4.6 answers a SCREEN extents query with
    window-relative values ("Screen coordinates not supported") and later releases
    report every SCREEN origin as 0,0. Taken as screen coordinates, every label
    lands near the screen's top-left corner (GNOME's Activities button and dock)
    instead of on its control. GTK 3 (through the ATK bridge), Chromium, and Qt
    report true screen coordinates.
    """
    try:
        toolkit = (app.get_toolkit_name() or "").lower()
        major = int((app.get_toolkit_version() or "0").split(".")[0])
    except Exception:
        return False
    return toolkit == "gtk" and major >= 4


def _focused_frame(app, window, Atspi):
    """The app's toplevel accessible for the focused X11 window, or None.

    Matched by the ACTIVE state when the toolkit sets it, else by title - a GTK
    dialog's accessible carries its window title even when it is never marked
    ACTIVE. An app with a single toplevel needs no matching: there is nothing
    else it could be.
    """
    frames = []
    try:
        for i in range(app.get_child_count()):
            frame = app.get_child_at_index(i)
            if frame is not None:
                frames.append(frame)
    except Exception:
        return None
    if len(frames) == 1:
        return frames[0]

    for frame in frames:
        try:
            if frame.get_state_set().contains(Atspi.StateType.ACTIVE):
                return frame
        except Exception:
            continue

    title = window.get("title") or ""
    for frame in frames:
        try:
            if title and frame.get_name() == title:
                return frame
        except Exception:
            continue
    return None


# Xlib's default error handler exits the process. A window can be destroyed
# between the xdotool lookup and a property read (BadWindow), which must degrade
# to "no property" rather than take the driver down mid-observe. Installed once
# when libX11 is loaded; the callback has to stay referenced for as long as the
# library is in use, so it lives at module level.
_IGNORE_X_ERRORS = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)(
    lambda _display, _error: 0
)


@functools.lru_cache(maxsize=None)
def _load_xlib():
    """libX11 with the prototypes the driver uses, loaded once; None when unavailable.

    Window properties and the clipboard are read through libX11 directly because
    xprop and xclip are not installed on the cloud image (nor guaranteed on an
    Agent CLI host).
    """
    try:
        xlib = ctypes.cdll.LoadLibrary(ctypes.util.find_library("X11") or "libX11.so.6")
    except Exception:
        return None
    xlib.XOpenDisplay.argtypes = [ctypes.c_char_p]
    xlib.XOpenDisplay.restype = ctypes.c_void_p
    xlib.XCloseDisplay.argtypes = [ctypes.c_void_p]
    xlib.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
    xlib.XDefaultRootWindow.restype = ctypes.c_ulong
    xlib.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
    xlib.XInternAtom.restype = ctypes.c_ulong
    xlib.XGetWindowProperty.argtypes = [
        ctypes.c_void_p,
        ctypes.c_ulong,
        ctypes.c_ulong,
        ctypes.c_long,
        ctypes.c_long,
        ctypes.c_int,
        ctypes.c_ulong,
        ctypes.POINTER(ctypes.c_ulong),
        ctypes.POINTER(ctypes.c_int),
        ctypes.POINTER(ctypes.c_ulong),
        ctypes.POINTER(ctypes.c_ulong),
        ctypes.POINTER(ctypes.c_void_p),
    ]
    xlib.XFree.argtypes = [ctypes.c_void_p]
    xlib.XCreateSimpleWindow.argtypes = [
        ctypes.c_void_p,
        ctypes.c_ulong,
        ctypes.c_int,
        ctypes.c_int,
        ctypes.c_uint,
        ctypes.c_uint,
        ctypes.c_uint,
        ctypes.c_ulong,
        ctypes.c_ulong,
    ]
    xlib.XCreateSimpleWindow.restype = ctypes.c_ulong
    xlib.XDestroyWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
    xlib.XSelectInput.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_long]
    xlib.XGetSelectionOwner.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
    xlib.XGetSelectionOwner.restype = ctypes.c_ulong
    xlib.XConvertSelection.argtypes = [
        ctypes.c_void_p,
        ctypes.c_ulong,
        ctypes.c_ulong,
        ctypes.c_ulong,
        ctypes.c_ulong,
        ctypes.c_ulong,
    ]
    xlib.XCheckTypedWindowEvent.argtypes = [
        ctypes.c_void_p,
        ctypes.c_ulong,
        ctypes.c_int,
        ctypes.c_void_p,
    ]
    xlib.XCheckTypedWindowEvent.restype = ctypes.c_int
    xlib.XFlush.argtypes = [ctypes.c_void_p]
    xlib.XGetAtomName.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
    xlib.XGetAtomName.restype = ctypes.c_void_p
    xlib.XSetErrorHandler.restype = ctypes.c_void_p
    xlib.XSetErrorHandler(_IGNORE_X_ERRORS)
    return xlib


class _X11:
    """One connection to the X display for a batch of reads.

    Opened on entry and closed on exit; ``display`` is None on a box without
    libX11 or a display at all. Every read answers with a neutral value (0 for
    an atom, None for a property) instead of raising, so a window destroyed
    between the xdotool lookup and the read - or that missing display - degrades
    to "no property".
    """

    def __enter__(self):
        self.xlib = _load_xlib()
        self.display = self.xlib.XOpenDisplay(None) if self.xlib else None
        return self

    def __exit__(self, *_exc):
        if self.display:
            self.xlib.XCloseDisplay(self.display)

    def atom(self, name):
        """The atom for ``name``, or 0 when it was never interned on this server."""
        if not self.display:
            return 0
        return int(self.xlib.XInternAtom(self.display, name.encode(), True))

    def window_property(self, window_id, name, max_items=1024):
        """A format-32 property of ``window_id`` (the root window when None) as a
        list of ints, or None when the window or the property does not exist."""
        atom = self.atom(name)
        if not atom:
            return None
        if window_id is None:
            window_id = self.xlib.XDefaultRootWindow(self.display)
        value = self.property_value(window_id, atom, max_length=max_items)
        if value is None:
            return None
        _type, data_format, data = value
        if data_format != 32 or not data:
            return None
        return _format32_items(data)

    def property_value(self, window_id, atom, delete=False, max_length=1024):
        """The raw value of ``atom`` on ``window_id``: ``(type atom, format, bytes)``,
        or None when the window or the property does not exist.

        ``max_length`` counts 32-bit units, as Xlib does. Format-16 and format-32
        items come back as C shorts and longs, as Xlib returns them. Deleting the
        property as it is read is how a selection requestor takes a value.
        """
        actual_type = ctypes.c_ulong()
        actual_format = ctypes.c_int()
        n_items = ctypes.c_ulong()
        bytes_after = ctypes.c_ulong()
        data = ctypes.c_void_p()
        status = self.xlib.XGetWindowProperty(
            self.display,
            window_id,
            atom,
            0,
            max_length,
            delete,
            0,  # AnyPropertyType
            ctypes.byref(actual_type),
            ctypes.byref(actual_format),
            ctypes.byref(n_items),
            ctypes.byref(bytes_after),
            ctypes.byref(data),
        )
        if status != 0 or not data.value:
            return None
        try:
            item_size = {
                8: 1,
                16: ctypes.sizeof(ctypes.c_short),
                32: ctypes.sizeof(ctypes.c_long),
            }.get(actual_format.value, 0)
            raw = ctypes.string_at(data, n_items.value * item_size)
            return actual_type.value, actual_format.value, raw
        finally:
            self.xlib.XFree(data)


def _format32_items(data):
    """The items of a format-32 property value, which Xlib hands back as C longs."""
    item_size = ctypes.sizeof(ctypes.c_ulong)
    count = len(data) // item_size
    return list((ctypes.c_ulong * count).from_buffer_copy(data[: count * item_size]))


def _x11_window_property(window_id, name, max_items=1024):
    """One format-32 property read on its own connection (see ``_X11``)."""
    with _X11() as x11:
        return x11.window_property(window_id, name, max_items)


def _managed_windows_stacked():
    """The window manager's client windows, bottom to top (`_NET_CLIENT_LIST_STACKING`).

    These are the real, mapped toplevels a user sees - not the unmapped client-leader
    and helper windows a toolkit also creates (which `xdotool search` returns too, and
    which cannot be activated). Empty when the WM does not publish the list.
    """
    return _x11_window_property(None, "_NET_CLIENT_LIST_STACKING") or []


def _client_side_decoration_offset(window_id):
    """(left, top) inset of a window's visible frame within its X11 window.

    GTK draws client-side decorations - its drop shadow - inside the X11 window and
    advertises their size in `_GTK_FRAME_EXTENTS` (left, right, top, bottom); GTK
    measures window-relative extents from the visible frame, not the X11 window's
    corner. (0, 0) for a window without client-side decorations.
    """
    extents = _x11_window_property(window_id, "_GTK_FRAME_EXTENTS", max_items=4)
    if not extents or len(extents) != 4:
        return 0, 0
    left, _right, top, _bottom = extents
    return left, top


def _chromium_apps(desktop):
    """The Chrome/Chromium AT-SPI app node(s), matched by name.

    Recovery for when the foreground window is Chromium but its active-window PID
    disagrees with the AT-SPI app PID (a wrapper or zygote owning the window).
    Scoping by name keeps the recovery on the browser alone, so a native app in
    front is never overlaid with the always-open browser's labels.
    """
    apps = []
    try:
        n_apps = desktop.get_child_count()
    except Exception:
        return apps
    for i in range(n_apps):
        try:
            app = desktop.get_child_at_index(i)
            name = (app.get_name() or "").lower() if app is not None else ""
        except Exception:
            continue
        if "chrome" in name or "chromium" in name:
            apps.append(app)
    return apps


def _within_rect(elements, rect):
    """Keep only elements whose center lies inside the given screen rect."""
    if not rect:
        return elements
    rx, ry, rw, rh = rect["x"], rect["y"], rect["width"], rect["height"]
    kept = []
    for el in elements:
        cx = el["x"] + el["width"] / 2
        cy = el["y"] + el["height"] / 2
        if rx <= cx <= rx + rw and ry <= cy <= ry + rh:
            kept.append(el)
    return kept


# Retries for a Chromium tree that is still being built: Chromium exports its
# web content lazily, populating the tree on the first assistive-technology
# query, so the very first walk after a navigation can come back empty.
_CHROMIUM_EXPORT_RETRIES = 2
_CHROMIUM_EXPORT_RETRY_DELAY = 0.6


def _empty_result_diagnostic(window, apps_found, foreground_is_chromium):
    """Why `observe` found nothing, for the agent and for anyone reading the trace.

    "Found 0 actionable elements" has several unrelated causes with different
    remedies, and the trace cannot tell them apart without this: no application
    window in front (only the desktop, or a window manager reporting nothing
    active), a window without an owning process, an app that is not on the
    accessibility bus at all (a toolkit without AT-SPI support, or a browser that
    started with accessibility off and stays that way until restarted), or an app
    on the bus whose focused window exports no actionable controls.
    """
    if window is None:
        return (
            "No application window is in front: only the desktop is showing, or the "
            "window manager reports nothing active."
        )
    title = window.get("title") or "(untitled)"
    if window.get("pid") is None:
        return (
            f'The focused window "{title}" reports no owning process, '
            "so its accessibility tree cannot be located."
        )
    if not apps_found:
        if foreground_is_chromium:
            return (
                "The browser in front is not exporting an accessibility tree (it is not "
                "registered on the accessibility bus), so none of its controls or page "
                "content can be labeled until it is restarted."
            )
        process = _process_name(window["pid"]) or f"pid {window['pid']}"
        return (
            f'The app in front ("{title}", {process}) is not registered on the '
            "accessibility bus: it exports no accessibility tree."
        )
    return (
        f'The app in front ("{title}") is on the accessibility bus but exports no '
        "actionable controls for its focused window."
    )


def enumerate_elements(pid=None):
    """Label the foreground window's actionable elements.

    Returns (elements, window, diagnostic): the numbered elements, the foreground
    window descriptor (None when no application window is in front), and - only
    when no element was found - a diagnostic naming the cause.
    """
    Atspi = _try_import_atspi()
    if Atspi is None:
        exit_with_error(
            "AT-SPI2 not available. Install: sudo apt install python3-gi gir1.2-atspi-2.0 at-spi2-core"
        )

    screen_w, screen_h = get_screen_size()
    window = _foreground_window()
    if pid is not None and window is not None:
        window = {**window, "pid": pid, "chromium": _pid_is_chromium(pid)}
    target_pid = pid or (window["pid"] if window is not None else None)
    # A native foreground app (GTK4 etc.) leaves the AT-SPI VISIBLE/SHOWING flags
    # unset on its on-screen descendants, so trust geometry for it; Chromium's web
    # a11y flags are reliable, so keep requiring VISIBLE there.
    foreground_is_chromium = bool(window and window["chromium"])
    if window is not None and _pid_is_libreoffice(target_pid):
        _refresh_libreoffice_geometry(window)

    desktop = Atspi.get_desktop(0)
    apps = _apps_for_pid(desktop, target_pid)
    elements = _collect_elements(
        apps, window, Atspi, screen_w, screen_h, trust_geometry=not foreground_is_chromium
    )

    if not elements and foreground_is_chromium:
        # The browser's PID-scoped walk came back empty: either its tree is still
        # being exported, or its window PID is not its AT-SPI PID. Retry briefly on
        # the browser's own app node(s), clipped to the focused window. Never the
        # whole desktop, which would overlay the always-open browser's labels on
        # whatever native app is in front.
        for _ in range(_CHROMIUM_EXPORT_RETRIES):
            time.sleep(_CHROMIUM_EXPORT_RETRY_DELAY)
            desktop = Atspi.get_desktop(0)
            apps = apps or _chromium_apps(desktop)
            elements = _within_rect(
                _collect_elements(apps, window, Atspi, screen_w, screen_h), window
            )
            if elements:
                break

    # A native (non-Chromium) foreground app that exports nothing yields an empty
    # result on purpose: the agent falls back to screenshot_grid coordinates
    # rather than being handed the background browser's elements.

    deduped = _dedup_elements(elements, screen_w, screen_h)

    for idx, el in enumerate(deduped):
        el["number"] = idx + 1

    diagnostic = None
    if not deduped:
        diagnostic = _empty_result_diagnostic(window, bool(apps), foreground_is_chromium)
    return deduped, window, diagnostic


# ---------------------------------------------------------------------------
# Screenshot capture
# ---------------------------------------------------------------------------

def _screenshot_tool_cmds(output_path):
    """Full-display capture commands tried in order. scrot is the primary (present
    on our images); the rest are fallbacks so a missing or broken scrot degrades
    gracefully instead of blinding the loop. Each writes ``output_path`` directly."""
    return (
        ["scrot", "-o", output_path],
        ["maim", output_path],
        ["import", "-window", "root", output_path],
        ["gnome-screenshot", "-f", output_path],
    )


def _pillow_x11_grab(output_path):
    """Last-resort capture via Pillow's native X11 grab (no external tool). Returns
    True on a non-empty PNG, else False."""
    try:
        from PIL import ImageGrab

        image = ImageGrab.grab(xdisplay=os.environ.get("DISPLAY") or ":0")
        image.save(output_path, "PNG")
        return os.path.exists(output_path) and os.path.getsize(output_path) > 0
    except Exception:
        return False


def capture_screenshot(output_path):
    """Take a full-display screenshot (X11).

    Prefers scrot; falls back to other common capture tools, then Pillow's native
    X11 grab, so a missing or failing scrot (e.g. it was never installed, or crashed
    with a libpng write error on a full disk) degrades gracefully instead of aborting
    the run with "Required command not found: scrot".
    """
    for cmd in _screenshot_tool_cmds(output_path):
        if shutil.which(cmd[0]) is None:
            continue
        try:
            subprocess.run(cmd, capture_output=True, text=True, timeout=20, check=True)
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
            continue
        if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
            return
    if _pillow_x11_grab(output_path):
        return
    exit_with_error(
        "Screenshot failed: no working capture tool. Install one of scrot, maim, "
        "imagemagick (import), or gnome-screenshot."
    )


def crop_screenshot(screenshot_path, region):
    """Crop a screenshot to the given region dict {x, y, width, height}."""
    try:
        from PIL import Image
    except ImportError:
        exit_with_error(
            "Pillow not installed. Install: pip3 install Pillow (or sudo apt install python3-pil)"
        )
    img = Image.open(screenshot_path)
    img_w, img_h = img.size
    screen_w, screen_h = get_screen_size()
    scale_x = img_w / screen_w
    scale_y = img_h / screen_h

    left = int(region["x"] * scale_x)
    top = int(region["y"] * scale_y)
    right = int((region["x"] + region["width"]) * scale_x)
    bottom = int((region["y"] + region["height"]) * scale_y)

    left = max(0, min(left, img_w))
    top = max(0, min(top, img_h))
    right = max(left, min(right, img_w))
    bottom = max(top, min(bottom, img_h))

    cropped = img.crop((left, top, right, bottom))
    cropped.info = img.info
    cropped.save(screenshot_path, "PNG")


# Bold sans-serif TrueType candidates, in preference order. DejaVu is the
# baseline on both the E2B image and the cloud AMI; Liberation and FreeSans are
# fallbacks so a substrate missing DejaVu still renders readable, scalable
# overlays instead of PIL's fixed-size bitmap default (which ignores the
# requested size and makes grid/label text unreadable).
_BOLD_FONT_CANDIDATES = (
    "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
    "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
    "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
)


def load_bold_font(size):
    """Load a scalable bold font at the requested size from a known path,
    falling back to PIL's default only when no TrueType font is available."""
    from PIL import ImageFont

    for path in _BOLD_FONT_CANDIDATES:
        try:
            return ImageFont.truetype(path, size)
        except Exception:
            continue
    return ImageFont.load_default()


def draw_grid_overlay(screenshot_path, grid_size):
    """Draw a numbered grid overlay on an existing screenshot.
    grid_size is 2 or 3 (for 2x2 or 3x3)."""
    try:
        from PIL import Image, ImageDraw
    except ImportError:
        exit_with_error(
            "Pillow not installed. Install: pip3 install Pillow (or sudo apt install python3-pil)"
        )

    img = Image.open(screenshot_path).convert("RGBA")
    overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(overlay, "RGBA")
    img_w, img_h = img.size
    cell_w = img_w / grid_size
    cell_h = img_h / grid_size
    cell_min = min(cell_w, cell_h)

    line_width = max(1, min(int(cell_min / 40), 4))
    for i in range(1, grid_size):
        x = int(i * cell_w)
        draw.line([(x, 0), (x, img_h)], fill=(230, 25, 25, 216), width=line_width)
    for i in range(1, grid_size):
        y = int(i * cell_h)
        draw.line([(0, y), (img_w, y)], fill=(230, 25, 25, 216), width=line_width)

    font_size = max(8, min(int(cell_min / 6), 60))
    font = load_bold_font(font_size)

    for row in range(grid_size):
        for col in range(grid_size):
            cx = int(col * cell_w + cell_w / 2)
            cy = int(row * cell_h + cell_h / 2)
            label = f"R{row + 1}C{col + 1}"
            bbox = draw.textbbox((0, 0), label, font=font)
            tw = bbox[2] - bbox[0]
            th = bbox[3] - bbox[1]
            draw.text(
                (cx - tw // 2, cy - th // 2),
                label,
                fill=(230, 25, 25, 255),
                font=font,
            )

    result = Image.alpha_composite(img, overlay).convert("RGB")
    result.info = img.info
    result.save(screenshot_path, "PNG")


# ---------------------------------------------------------------------------
# Screenshot annotation (red numbered badges, matching macOS style)
# ---------------------------------------------------------------------------

def annotate_screenshot(screenshot_path, elements):
    if not elements:
        return

    try:
        from PIL import Image, ImageDraw
    except ImportError:
        exit_with_error(
            "Pillow not installed. Install: pip3 install Pillow (or sudo apt install python3-pil)"
        )

    img = Image.open(screenshot_path).convert("RGBA")
    draw = ImageDraw.Draw(img, "RGBA")
    img_w, img_h = img.size
    screen_w, screen_h = get_screen_size()
    scale_x = img_w / screen_w
    scale_y = img_h / screen_h

    font = load_bold_font(12)
    font_small = load_bold_font(10)

    for el in elements:
        num_str = str(el["number"])
        badge_size = 30 if len(num_str) > 2 else 24
        active_font = font_small if len(num_str) > 2 else font

        lx = el["x"] * scale_x - 2
        ly = el["y"] * scale_y - 2

        if lx < 0:
            lx = 0
        if ly < 0:
            ly = 0
        if lx + badge_size > img_w:
            lx = img_w - badge_size
        if ly + badge_size > img_h:
            ly = img_h - badge_size

        cx = lx + badge_size / 2
        cy = ly + badge_size / 2
        r = badge_size / 2

        draw.ellipse(
            [cx - r, cy - r, cx + r, cy + r],
            fill=(230, 25, 25, 242),
            outline=(255, 255, 255, 255),
            width=2,
        )

        bbox = draw.textbbox((0, 0), num_str, font=active_font)
        tw = bbox[2] - bbox[0]
        th = bbox[3] - bbox[1]
        tx = cx - tw / 2
        ty = cy - th / 2
        draw.text((tx, ty), num_str, fill=(255, 255, 255, 255), font=active_font)

    img = img.convert("RGB")
    img.save(screenshot_path, "PNG")


# ---------------------------------------------------------------------------
# Input simulation via xdotool
# ---------------------------------------------------------------------------

# xdotool mousemove --sync blocks until the X server confirms the pointer
# reached the target. Under display load - rapid screenshots + grid overlays and
# heavy page rendering during a CAPTCHA-solving loop - that confirmation can
# stall for many seconds and blow the 15s command timeout, failing the click
# outright. Bound the sync wait; if it stalls (or errors), fall back to a
# non-sync move so the cursor still lands and the click proceeds - a
# slightly-less-synchronous move instead of a hard failure.
_MOUSEMOVE_SYNC_TIMEOUT = 3

# The longest a press-and-hold may keep the button down. A command's timeout
# grows by the requested hold, so this bound is what actually limits it.
_MAX_HOLD_SECONDS = 20

# Every keysym a modifier can be held under, left and right variants.
_MODIFIER_KEYSYMS = [
    "Control_L", "Control_R", "Alt_L", "Alt_R", "Shift_L", "Shift_R", "Super_L", "Super_R",
]
_MODIFIER_KEYUP_TIMEOUT = 3

def release_modifiers(keysyms=_MODIFIER_KEYSYMS):
    """Release modifier keys the X server still counts as held.

    The driver runs one process per action and has no hold-key primitive, so a
    modifier that is logically down when an action starts is always stale - most
    often a combo whose release never landed because the ``xdotool key`` that
    pressed it was killed by the ``_run`` timeout under display lag. Left in
    place, a stale Ctrl turns the next wheel scroll into browser zoom and the
    next click into a modified click. ``--clearmodifiers`` does not cure it: it
    restores whatever it found held once its own keystroke is done. Best-effort
    and never fatal: a failed release must not fail the action itself.
    """
    try:
        subprocess.run(
            ["xdotool", "keyup", *keysyms],
            capture_output=True,
            text=True,
            timeout=_MODIFIER_KEYUP_TIMEOUT,
            check=False,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass

def _move_pointer(ix, iy):
    """Move the pointer to integer (ix, iy) with a bounded ``--sync`` wait.

    Returns True when the X server confirmed the arrival. When the sync stalls
    (or errors) the move is re-issued without ``--sync``, which returns
    immediately but carries no arrival confirmation, and False is returned so
    the caller can give the async motion a longer settle.
    """
    try:
        subprocess.run(
            ["xdotool", "mousemove", "--sync", str(ix), str(iy)],
            capture_output=True,
            text=True,
            timeout=_MOUSEMOVE_SYNC_TIMEOUT,
            check=True,
        )
        return True
    except FileNotFoundError:
        exit_with_error("Required command not found: xdotool")
    except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
        pass
    _run(["xdotool", "mousemove", str(ix), str(iy)])
    return False

def _pointer_location():
    """The pointer's current integer (x, y), or None when it cannot be read."""
    try:
        out = subprocess.run(
            ["xdotool", "getmouselocation", "--shell"],
            capture_output=True,
            text=True,
            timeout=_MOUSEMOVE_SYNC_TIMEOUT,
            check=True,
        ).stdout
    except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.CalledProcessError):
        return None
    values = dict(line.split("=", 1) for line in out.split() if "=" in line)
    try:
        return int(values["X"]), int(values["Y"])
    except (KeyError, ValueError):
        return None

def mouse_move(x, y):
    target = (int(x), int(y))
    # `mousemove --sync` waits for the pointer to leave its origin, so a move to
    # the pixel it is already on never confirms and would burn the whole sync
    # timeout (clicking the same element twice, a drag that starts where the last
    # click landed, a repeated scroll at one spot). Nothing to move: return.
    if _pointer_location() == target:
        return
    confirmed = _move_pointer(*target)
    time.sleep(0.025 if confirmed else 0.1)

def _hold_button(button, hold_seconds):
    """Press ``button``, keep it down for ``hold_seconds``, then release it.

    The release runs even when the hold is interrupted, so a press-and-hold
    can never leave the button logically down.
    """
    _run(["xdotool", "mousedown", button])
    try:
        time.sleep(hold_seconds)
    finally:
        _run(["xdotool", "mouseup", button], check=False)

def mouse_click(x, y, hold_seconds=0.0):
    mouse_move(x, y)
    if hold_seconds > 0:
        _hold_button("1", hold_seconds)
        return
    _run(["xdotool", "click", "1"])

def mouse_double_click(x, y):
    mouse_move(x, y)
    _run(["xdotool", "click", "--repeat", "2", "--delay", "50", "1"])

def mouse_right_click(x, y):
    mouse_move(x, y)
    _run(["xdotool", "click", "3"])

def type_text(text):
    _run(["xdotool", "type", "--clearmodifiers", "--delay", "5", text])

# Mapping from our key-spec names to xdotool key names
_KEY_MAP = {
    "return": "Return", "enter": "Return", "tab": "Tab", "space": "space",
    "delete": "BackSpace", "backspace": "BackSpace", "forwarddelete": "Delete",
    "escape": "Escape", "esc": "Escape",
    "up": "Up", "down": "Down", "left": "Left", "right": "Right",
    "home": "Home", "end": "End", "pageup": "Prior", "pagedown": "Next",
    "f1": "F1", "f2": "F2", "f3": "F3", "f4": "F4", "f5": "F5", "f6": "F6",
    "f7": "F7", "f8": "F8", "f9": "F9", "f10": "F10", "f11": "F11", "f12": "F12",
}

_MOD_MAP = {
    "cmd": "super", "command": "super", "super": "super",
    "shift": "shift",
    "alt": "alt", "option": "alt", "opt": "alt",
    "ctrl": "ctrl", "control": "ctrl",
}

# The keysyms a combo's modifier is pressed under, for the release guarantee.
_MOD_KEYSYMS = {
    "super": ["Super_L", "Super_R"],
    "shift": ["Shift_L", "Shift_R"],
    "alt": ["Alt_L", "Alt_R"],
    "ctrl": ["Control_L", "Control_R"],
}

def press_key(key_spec):
    parts = key_spec.lower().split("+")
    mods = []
    key_name = ""

    for part in parts:
        if part in _MOD_MAP:
            mods.append(_MOD_MAP[part])
        else:
            key_name = part

    xdot_key = _KEY_MAP.get(key_name, key_name)

    combo = "+".join(mods + [xdot_key]) if mods else xdot_key
    if not mods:
        _run(["xdotool", "key", "--clearmodifiers", combo])
        return
    # ``xdotool key`` presses the combo's modifiers, taps the key, then releases
    # them in order. Killed part-way (the ``_run`` timeout under display lag),
    # it leaves the modifiers logically held in the X server, and every later
    # wheel scroll and click arrives modified. The release runs on every exit,
    # including the SystemExit the timeout raises, so no combo leaves one behind.
    try:
        _run(["xdotool", "key", "--clearmodifiers", combo])
    finally:
        release_modifiers([keysym for mod in mods for keysym in _MOD_KEYSYMS[mod]])

def _interpolate_points(from_x, from_y, to_x, to_y, steps):
    """Generate linearly interpolated points between two coordinates."""
    points = []
    for i in range(1, steps + 1):
        t = i / steps
        x = from_x + (to_x - from_x) * t
        y = from_y + (to_y - from_y) * t
        points.append((x, y))
    return points


def mouse_drag(from_x, from_y, to_x, to_y, steps=10, waypoints=None, hold_seconds=0.0):
    """Perform a smooth drag from one position to another.

    Presses the button at the start - keeping it down for ``hold_seconds``
    first when given, the press-and-hold-then-slide gesture - then moves
    through the interpolated points with bounded ``--sync`` waits so Chrome
    recognizes the drag, and releases at the end. The 16ms sleep between
    moves matches 60fps frame timing.

    A point that lands on the pixel the pointer is already on is not sent to
    xdotool: its ``mousemove --sync`` waits for the pointer to leave its
    origin, so a move to the current position blocks until the command
    timeout (a zero-length drag, a freehand loop whose points round to one
    pixel). The frame sleep still runs so the gesture keeps its timing.

    When waypoints are provided the last waypoint is the destination, so
    (to_x, to_y) is not appended to the segment list. The button is
    released in a finally clause so a failed mousemove can't leave the
    cursor stuck in the down state.
    """
    mouse_move(from_x, from_y)
    last = (int(from_x), int(from_y))
    _run(["xdotool", "mousedown", "1"])
    try:
        if hold_seconds > 0:
            time.sleep(hold_seconds)
        if waypoints:
            segments = [(from_x, from_y)] + list(waypoints)
        else:
            segments = [(from_x, from_y), (to_x, to_y)]
        for seg_idx in range(len(segments) - 1):
            sx, sy = segments[seg_idx]
            ex, ey = segments[seg_idx + 1]
            for px, py in _interpolate_points(sx, sy, ex, ey, steps):
                target = (int(px), int(py))
                if target != last:
                    _move_pointer(*target)
                    last = target
                time.sleep(0.016)
    finally:
        _run(["xdotool", "mouseup", "1"], check=False)


def do_scroll(direction, steps=3, at_x=None, at_y=None):
    if at_x is not None and at_y is not None:
        mouse_move(at_x, at_y)
        time.sleep(0.05)
    else:
        sw, sh = get_screen_size()
        mouse_move(sw // 2, sh // 2)
        time.sleep(0.05)

    button = "5" if direction == "down" else "4"
    # Chrome turns a wheel event with Ctrl held into zoom, and nine notches
    # reach its 25% floor; a scroll must never carry a modifier, held or stale.
    for _ in range(steps):
        _run(["xdotool", "click", "--clearmodifiers", "--repeat", "3", button])
        time.sleep(0.016)


# ---------------------------------------------------------------------------
# Application activation
# ---------------------------------------------------------------------------

_DESKTOP_DIRS = [
    "/usr/share/applications",
    "/usr/local/share/applications",
    os.path.expanduser("~/.local/share/applications"),
]


# How long a freshly launched app gets to show a window before `activate` reports
# it as launched but not visible yet. LibreOffice and Evince routinely need well
# over the few seconds a snappy app takes on these computers. A driver command
# has 30 s in total, so this leaves room for the lookups before the launch and
# the settle after it.
_LAUNCH_WINDOW_TIMEOUT = 20.0

# The %f/%U/... placeholders in a .desktop Exec= line (files/URLs to open, icon,
# caption), none of which apply to a bare launch.
_DESKTOP_FIELD_CODES = re.compile(r"\s+%[fFuUdDnNickvm]")


def _desktop_entry_lines(content):
    """Yield the lines of a .desktop file's ``[Desktop Entry]`` group only.

    ``[Desktop Action ...]`` groups carry their own Name= and Exec= (the
    LibreOffice Start Center's actions are named "Writer", "Calc", ...), so
    reading them would match a component's short name to the launcher's entry
    and open the Start Center for "Calc".
    """
    in_entry = False
    for line in content.splitlines():
        if line.startswith("["):
            in_entry = line.strip() == "[Desktop Entry]"
        elif in_entry:
            yield line


def _desktop_entry_bool(fields, key):
    return fields.get(key, "").strip().lower() == "true"


def _resolve_from_desktop_files(name):
    """Search .desktop files for an app matching *name*, best match first.

    Matches the Name= and GenericName= fields of the main group
    (case-insensitive) and the desktop file basename (e.g. "chromium" matches
    "chromium-browser.desktop"). A Name= match ranks before a GenericName= match,
    which ranks before a basename match - "Text Editor" is the Name of GNOME's
    editor but only the GenericName of vim's entry, and "libreoffice" is a
    substring of every libreoffice-*.desktop basename but names the Start Center.
    Ties break on the desktop id, so the choice never depends on directory order.

    Entries that cannot show a window of their own are left out: `Terminal=true`
    programs (vim, htop) run inside a terminal emulator the agent has to open
    first, and `Hidden`/`NoDisplay` entries are not launchers a user sees.

    Returns a list of (argv, desktop_id, window_classes) launch candidates. argv is
    the Exec= command with its arguments kept and the field codes removed, so
    "LibreOffice Calc" launches `libreoffice --calc` - not the bare `libreoffice`,
    which opens the Start Center instead of the component the agent asked for.
    window_classes are the WM_CLASS patterns that identify the entry's windows.
    """
    name_lower = name.lower()
    ranked = []

    for desktop_dir in _DESKTOP_DIRS:
        for filepath in glob.glob(os.path.join(desktop_dir, "*.desktop")):
            try:
                with open(filepath) as f:
                    content = f.read()
            except Exception:
                continue

            fields = {}
            for line in _desktop_entry_lines(content):
                key, sep, value = line.partition("=")
                if sep and key.strip() not in fields:
                    fields[key.strip()] = value.strip()
            if fields.get("Type", "Application") != "Application":
                continue
            if any(_desktop_entry_bool(fields, key) for key in ("Terminal", "Hidden", "NoDisplay")):
                continue

            desktop_id = os.path.basename(filepath)
            basename = os.path.splitext(desktop_id)[0].lower()
            if fields.get("Name", "").lower() == name_lower:
                rank = 0
            elif fields.get("GenericName", "").lower() == name_lower:
                rank = 1
            elif name_lower in basename:
                rank = 2
            else:
                continue

            exec_cmd = _DESKTOP_FIELD_CODES.sub("", fields.get("Exec", ""))
            try:
                argv = shlex.split(exec_cmd) if exec_cmd else []
            except ValueError:
                argv = []
            classes = _window_classes_for_entry(argv, desktop_id, fields.get("StartupWMClass"))
            ranked.append((rank, desktop_id, argv, classes))

    ranked.sort(key=lambda item: (item[0], item[1]))
    return [(argv, desktop_id, classes) for _rank, desktop_id, argv, classes in ranked]


# Exec= commands that are wrappers around the real program, so their name says
# nothing about the window's class.
_LAUNCHER_WRAPPERS = {"env", "sh", "bash", "flatpak", "snap", "gtk-launch", "xdg-open"}


def _window_classes_for_entry(argv, desktop_id, startup_wm_class=None):
    """WM_CLASS patterns (case-insensitive regexes) that identify an entry's windows.

    `StartupWMClass=` is the entry's own statement of its class and is used alone
    when present. LibreOffice's component entries carry it, and the guesses below
    would otherwise name the whole family too: `libreoffice`, the Exec= basename,
    is a substring of every component's class, so asking for Calc would focus an
    open Writer window instead of launching Calc. Without it, a .desktop id is the
    most specific signal; a reverse-DNS id (`org.gnome.Evince`) also contributes
    its last segment, since GTK3 apps set WM_CLASS from the program name; and the
    executable's basename covers apps whose class is their binary
    (`gnome-calculator`) - without a desktop entry it is all there is.
    """
    if startup_wm_class:
        return [re.escape(startup_wm_class)]
    classes = []
    if desktop_id:
        base = os.path.splitext(desktop_id)[0]
        classes.append(base)
        if "." in base:
            classes.append(base.rsplit(".", 1)[1])
    if argv and os.path.basename(argv[0]) not in _LAUNCHER_WRAPPERS:
        classes.append(os.path.basename(argv[0]))
    seen = set()
    return [re.escape(c) for c in classes if c and not (c.lower() in seen or seen.add(c.lower()))]


# WM_CLASS regex fragments for apps whose window title does not match the app name
# the agent asks to activate - notably Chrome `--app=<url>` windows, whose title is
# the page's, not "chrome". "chrome" matches the "google-chrome" class; "chromium"
# is listed explicitly since it does not contain "chrome".
_WINDOW_CLASS_ALIASES = {
    "chrome": ["chrome", "chromium"],
    "google chrome": ["chrome", "chromium"],
    "google-chrome": ["chrome", "chromium"],
    "chromium": ["chromium", "chrome"],
    "browser": ["chrome", "chromium", "firefox"],
    "firefox": ["firefox"],
}

# How long the window manager gets to report a requested window as active before
# the next activation method is tried. Mutter answers a pager-sourced
# _NET_ACTIVE_WINDOW request within a frame or two; the bound only matters when a
# request is refused outright.
_ACTIVATION_CONFIRM_TIMEOUT = 1.0


def _search_windows(flag, pattern):
    """Visible X11 windows matching an xdotool search (`--class` / `--name`).

    `--onlyvisible` leaves out the unmapped client-leader and helper windows a
    toolkit creates alongside its real toplevel: they share its class and title,
    sort first, and cannot be activated (the WM has never managed them).
    """
    try:
        r = subprocess.run(
            ["xdotool", "search", "--onlyvisible", flag, pattern],
            capture_output=True, text=True, timeout=5,
        )
        return [int(wid) for wid in r.stdout.split() if wid.isdigit()]
    except Exception:
        return []


def _find_app_window(name_patterns, class_patterns):
    """The topmost window of the app, matched by WM_CLASS first, then by title.

    Class is the reliable identity of an app's windows: a title only sometimes
    carries the app's name (Files shows "Home", a document viewer shows the
    document's title), and a title search can hit a browser tab that happens to
    mention the app. Among several windows the one highest in the WM's stacking
    order is the one the user last worked in. Falls back to xdotool's own order
    when the WM publishes no stacking list.
    """
    stacked = _managed_windows_stacked()
    attempts = [("--class", c) for c in class_patterns] + [("--name", n) for n in name_patterns]
    for flag, pattern in attempts:
        wids = _search_windows(flag, pattern)
        if not wids:
            continue
        if stacked:
            matched = set(wids)
            managed = [wid for wid in stacked if wid in matched]
            if managed:
                return managed[-1]
            continue
        return wids[-1]
    return None


def _focus_window(window_id):
    """Make ``window_id`` the active window and confirm the WM agreed.

    A plain `xdotool windowactivate` (a pager-sourced _NET_ACTIVE_WINDOW request)
    is what mutter and the other EWMH managers honor. Its `--sync` is not used: it
    waits without a bound of its own for the WM to report the window active, so a
    refused request would hang until the subprocess timeout. Confirmation is read
    back from the WM under a bound instead, and a refused request falls through to
    a raise + focus and then to wmctrl. Returns whether the window ended up active.
    """
    methods = [
        [["xdotool", "windowactivate", str(window_id)]],
        [["xdotool", "windowraise", str(window_id)], ["xdotool", "windowfocus", str(window_id)]],
    ]
    if shutil.which("wmctrl"):
        methods.append([["wmctrl", "-ia", str(window_id)]])
    for commands in methods:
        for command in commands:
            try:
                subprocess.run(command, capture_output=True, text=True, timeout=5, check=False)
            except (FileNotFoundError, subprocess.TimeoutExpired):
                continue
        deadline = time.time() + _ACTIVATION_CONFIRM_TIMEOUT
        while time.time() < deadline:
            active = _get_active_window_id()
            # A WM that publishes no active window cannot confirm or deny; the
            # request was delivered, which is all there is to go on.
            if active is None or active == window_id:
                return True
            time.sleep(0.1)
    return False


def activate_app(name):
    """Focus the app's window, launching the app first when it has none.

    Returns True when a window of the app is in front, False when the app was
    launched but has not shown a window within the launch timeout - it is still
    starting, so the caller should wait rather than launch it again. Raises
    RuntimeError when nothing launchable matches the name, when the launched
    process exits with a failure before showing a window, or when the app has a
    window the window manager refuses to bring to the front.
    """
    candidates = _resolve_from_desktop_files(name)
    name_patterns = [re.escape(name)]
    class_patterns = list(_WINDOW_CLASS_ALIASES.get(name.lower(), []))
    for _argv, _desktop_id, classes in candidates:
        class_patterns += [c for c in classes if c not in class_patterns]

    def focus_or_raise(window_id):
        if _focus_window(window_id):
            return True
        title = _window_title(window_id) or "(untitled)"
        raise RuntimeError(
            f"'{name}' has a window (\"{title}\") but the window manager would not "
            "bring it to the front."
        )

    # An app that is already running is focused, never launched again: a second
    # launch of a single-instance app opens a second window (or just re-presents
    # the first), and the agent then labels two of the same window.
    existing = _find_app_window(name_patterns, class_patterns)
    if existing is not None:
        return focus_or_raise(existing)

    known = {argv[0] for argv, _, _ in candidates if argv}
    for direct in (name.lower(), name):
        if direct not in known:
            candidates.append(([direct], None, _window_classes_for_entry([direct], None)))
            known.add(direct)

    # Launch once: the first candidate whose executable exists. Each candidate
    # names the same app, so launching more than one would only stack windows
    # and make the agent wait for every one in turn.
    launch = None
    for argv, desktop_id, classes in candidates:
        if not argv:
            continue
        executable = shutil.which(argv[0])
        if executable is not None:
            launch = ([executable, *argv[1:]], desktop_id, classes)
            break

    # A desktop entry whose Exec= cannot be resolved directly (D-Bus activated
    # apps, launcher wrappers off PATH) still launches through gtk-launch.
    if launch is None:
        gtk_launch = shutil.which("gtk-launch")
        with_entry = [(desktop_id, classes) for _, desktop_id, classes in candidates if desktop_id]
        if gtk_launch and with_entry:
            desktop_id, classes = with_entry[0]
            launch = ([gtk_launch, desktop_id], desktop_id, classes)

    if launch is None:
        raise RuntimeError(
            f"Could not find an application named '{name}': no open window, "
            f".desktop entry, or executable matches it."
        )

    argv, _desktop_id, launch_classes = launch
    windows_before = set(_managed_windows_stacked())
    try:
        # Its own session with no inherited stdio: the app must outlive this
        # driver process (one process per command) and never block on, or be
        # killed with, the driver's pipes and process group.
        proc = subprocess.Popen(
            argv,
            stdin=subprocess.DEVNULL,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
        )
    except OSError as e:
        raise RuntimeError(f"Could not launch '{name}' ({argv[0]}): {e}") from e

    def launched_window():
        # The app's window by class (its launcher wrapper may exec a differently
        # named binary - LibreOffice's `soffice` - and a single-instance app may
        # hand the launch to its running process), else whatever managed window
        # appeared since the launch: that is the app's, whatever class it chose.
        window_id = _find_app_window(name_patterns, launch_classes)
        if window_id is not None:
            return window_id
        appeared = [wid for wid in _managed_windows_stacked() if wid not in windows_before]
        return appeared[-1] if appeared else None

    deadline = time.time() + _LAUNCH_WINDOW_TIMEOUT
    while time.time() < deadline:
        window_id = launched_window()
        # A window the WM will not activate yet is still settling (a splash, a
        # frame the app is about to replace with its real one): keep waiting for
        # one it will, rather than report the app as unfocusable.
        if window_id is not None and _focus_window(window_id):
            return True
        # A launcher that hands off to a running instance exits 0 with no
        # window of its own, so only a failure exit ends the wait early.
        code = proc.poll()
        if code is not None and code != 0:
            status = f"signal {-code}" if code < 0 else f"exit status {code}"
            raise RuntimeError(
                f"'{name}' crashed on launch ({status}) before showing a window."
            )
        time.sleep(0.2)
    window_id = launched_window()
    if window_id is not None:
        return focus_or_raise(window_id)
    return False


# ---------------------------------------------------------------------------
# Clipboard (the X11 CLIPBOARD selection, read through libX11)
# ---------------------------------------------------------------------------

_SELECTION_NOTIFY = 31
_PROPERTY_NOTIFY = 28
_PROPERTY_NEW_VALUE = 0
_PROPERTY_CHANGE_MASK = 1 << 22

# How long the app holding the clipboard gets to answer one request: the target
# list, the text, or one chunk of an INCR transfer.
_CLIPBOARD_REPLY_TIMEOUT = 5.0

# The most bytes one read collects, so a huge copy cannot balloon the driver.
_CLIPBOARD_MAX_BYTES = 8 * 1024 * 1024

# Text targets in preference order, each with the encoding its data arrives in.
_CLIPBOARD_TEXT_TARGETS = [
    ("UTF8_STRING", "utf-8"),
    ("text/plain;charset=utf-8", "utf-8"),
    ("STRING", "latin-1"),
    ("TEXT", "utf-8"),
    ("text/plain", "utf-8"),
]

_CLIPBOARD_FILE_TARGETS = {"text/uri-list", "x-special/gnome-copied-files"}

# Targets every owner advertises that carry no clipboard content of their own.
_CLIPBOARD_META_TARGETS = {"TARGETS", "TIMESTAMP", "MULTIPLE", "SAVE_TARGETS"}


class _XSelectionEvent(ctypes.Structure):
    _fields_ = [
        ("type", ctypes.c_int),
        ("serial", ctypes.c_ulong),
        ("send_event", ctypes.c_int),
        ("display", ctypes.c_void_p),
        ("requestor", ctypes.c_ulong),
        ("selection", ctypes.c_ulong),
        ("target", ctypes.c_ulong),
        ("property", ctypes.c_ulong),
        ("time", ctypes.c_ulong),
    ]


class _XPropertyEvent(ctypes.Structure):
    _fields_ = [
        ("type", ctypes.c_int),
        ("serial", ctypes.c_ulong),
        ("send_event", ctypes.c_int),
        ("display", ctypes.c_void_p),
        ("window", ctypes.c_ulong),
        ("atom", ctypes.c_ulong),
        ("time", ctypes.c_ulong),
        ("state", ctypes.c_int),
    ]


class _XEvent(ctypes.Union):
    # Xlib's XEvent is a union padded to 24 longs; only these members are read.
    _fields_ = [
        ("type", ctypes.c_int),
        ("xselection", _XSelectionEvent),
        ("xproperty", _XPropertyEvent),
        ("pad", ctypes.c_long * 24),
    ]


def read_clipboard():
    """The CLIPBOARD selection as ``{"content": kind, "text": str | None}``.

    ``kind`` is text, image, files, empty, or other; text wins whenever the owner
    offers it alongside other kinds. Reading never changes the clipboard.
    """
    with _X11() as x11:
        if not x11.display:
            exit_with_error("Cannot read the clipboard: no X display is available ($DISPLAY).")
        return _ClipboardReader(x11).read()


def _non_text_clipboard_kind(targets):
    """Classify an owner that offers no text by the targets it does offer.

    ``targets`` is None for an owner that would not even list them; it holds
    something, so that is "other", never "empty".
    """
    if targets is None:
        return "other"
    if any(target.startswith("image/") for target in targets):
        return "image"
    if targets & _CLIPBOARD_FILE_TARGETS:
        return "files"
    return "empty" if targets <= _CLIPBOARD_META_TARGETS else "other"


class _ClipboardReader:
    """One CLIPBOARD read on an open display, acting as the selection requestor.

    Each request asks the owner to convert the selection into a property on a
    private unmapped window and waits for its SelectionNotify. Data larger than
    the owner's request size arrives through the ICCCM INCR protocol, as
    successive values of that property.
    """

    def __init__(self, x11):
        self._x11 = x11
        self._xlib = x11.xlib
        self._display = x11.display
        self._clipboard = self._intern("CLIPBOARD")
        self._property = self._intern("OCTAVUS_CLIPBOARD")
        self._incr = self._intern("INCR")
        self._window = 0

    def read(self):
        if not self._xlib.XGetSelectionOwner(self._display, self._clipboard):
            return {"content": "empty", "text": None}
        root = self._xlib.XDefaultRootWindow(self._display)
        self._window = self._xlib.XCreateSimpleWindow(self._display, root, 0, 0, 1, 1, 0, 0, 0)
        self._xlib.XSelectInput(self._display, self._window, _PROPERTY_CHANGE_MASK)
        try:
            targets = self._targets()
            for name, encoding in _CLIPBOARD_TEXT_TARGETS:
                if targets is not None and name not in targets:
                    continue
                reply = self._convert(self._intern(name))
                if reply is not None:
                    _format, data = reply
                    return {"content": "text", "text": data.decode(encoding, errors="replace")}
            return {"content": _non_text_clipboard_kind(targets), "text": None}
        finally:
            self._xlib.XDestroyWindow(self._display, self._window)

    def _targets(self):
        """Names of the targets the owner offers, or None when it will not list them."""
        reply = self._convert(self._intern("TARGETS"))
        if reply is None:
            return None
        data_format, data = reply
        if data_format != 32:
            return None
        atoms = _format32_items(data)
        return {name for name in (self._atom_name(atom) for atom in atoms) if name}

    def _convert(self, target):
        """The owner's ``(format, data)`` for ``target``, or None when it refuses the target."""
        self._xlib.XConvertSelection(
            self._display, self._clipboard, target, self._property, self._window, 0
        )
        self._xlib.XFlush(self._display)
        event = self._wait_for(
            _SELECTION_NOTIFY, lambda e: e.xselection.selection == self._clipboard
        )
        if not event.xselection.property:
            return None
        # The owner set the property before it notified, so every PropertyNotify
        # queued so far is stale; dropping them keeps an INCR transfer from
        # mistaking the initial INCR marker for its first chunk.
        self._discard(_PROPERTY_NOTIFY)
        value_type, data_format, data = self._take_property()
        if value_type == self._incr:
            return 8, self._read_incr()
        return data_format, data

    def _read_incr(self):
        """The data an INCR owner sends as successive property values, ended by an empty one.

        Deleting the INCR marker (``_take_property``) is what starts the transfer;
        each chunk is taken the same way, which asks the owner for the next.
        """
        chunks = []
        total = 0
        while total < _CLIPBOARD_MAX_BYTES:
            self._wait_for(
                _PROPERTY_NOTIFY,
                lambda e: e.xproperty.atom == self._property
                and e.xproperty.state == _PROPERTY_NEW_VALUE,
            )
            _type, _format, chunk = self._take_property()
            if not chunk:
                break
            chunks.append(chunk)
            total += len(chunk)
        return b"".join(chunks)

    def _take_property(self):
        """Read and delete our property: ``(type atom, format, raw bytes)``, empty
        when the owner set none."""
        value = self._x11.property_value(
            self._window, self._property, delete=True, max_length=_CLIPBOARD_MAX_BYTES // 4
        )
        return value if value is not None else (0, 0, b"")

    def _wait_for(self, event_type, matches):
        """The next ``event_type`` event on our window that ``matches``; exits when the owner never answers."""
        event = _XEvent()
        deadline = time.monotonic() + _CLIPBOARD_REPLY_TIMEOUT
        while time.monotonic() < deadline:
            while self._xlib.XCheckTypedWindowEvent(
                self._display, self._window, event_type, ctypes.byref(event)
            ):
                if matches(event):
                    return event
            time.sleep(0.01)
        exit_with_error(
            "The app holding the clipboard did not respond. Copy the value again and retry."
        )

    def _discard(self, event_type):
        event = _XEvent()
        while self._xlib.XCheckTypedWindowEvent(
            self._display, self._window, event_type, ctypes.byref(event)
        ):
            pass

    def _intern(self, name):
        return int(self._xlib.XInternAtom(self._display, name.encode(), False))

    def _atom_name(self, atom):
        pointer = self._xlib.XGetAtomName(self._display, atom)
        if not pointer:
            return None
        try:
            return ctypes.string_at(pointer).decode("latin-1")
        finally:
            self._xlib.XFree(pointer)


# ---------------------------------------------------------------------------
# Main CLI
# ---------------------------------------------------------------------------

# Commands that synthesize keyboard or mouse input. Each starts from a neutral
# modifier state: the driver is one process per action with no hold-key
# primitive, so any modifier still held when an action begins is stale by
# construction (see ``release_modifiers``), and this is a headless machine with
# no person at the keyboard whose keys could be released by mistake.
_INPUT_COMMANDS = {"click", "double-click", "right-click", "type", "key", "scroll", "drag"}


def _parse_hold_seconds(args):
    """Read an optional ``--hold-seconds N`` out of a command's trailing args."""
    for i, arg in enumerate(args):
        if arg != "--hold-seconds":
            continue
        try:
            hold = float(args[i + 1])
        except (IndexError, ValueError):
            exit_with_error("--hold-seconds requires a number")
        if hold < 0 or hold > _MAX_HOLD_SECONDS:
            exit_with_error(f"--hold-seconds must be between 0 and {_MAX_HOLD_SECONDS}")
        return hold
    return 0.0


def _exit_on_sigterm(signum, frame):
    """Unwind on SIGTERM instead of dying in place.

    A driver that outlives its command timeout is ended with SIGTERM. Python's
    default disposition terminates without unwinding, which would skip the
    ``finally`` blocks that release a mouse button held by a drag or a
    press-and-hold; raising SystemExit runs them first.
    """
    sys.exit(1)


def main():
    args = sys.argv[1:]
    if not args:
        exit_with_error("Usage: linux-driver.py <command> [args...]")

    command = args[0]
    if command in _INPUT_COMMANDS:
        signal.signal(signal.SIGTERM, _exit_on_sigterm)
        release_modifiers()

    if command == "observe":
        pid = None
        screenshot_path = "/tmp/octavus-screenshot.png"
        i = 1
        while i < len(args):
            if args[i] == "--pid" and i + 1 < len(args):
                pid = int(args[i + 1])
                i += 2
            elif args[i] == "--screenshot" and i + 1 < len(args):
                screenshot_path = args[i + 1]
                i += 2
            else:
                i += 1

        elements, window, diagnostic = enumerate_elements(pid=pid)
        capture_screenshot(screenshot_path)
        annotate_screenshot(screenshot_path, elements)

        result = {
            "elements": elements,
            "screenshotPath": screenshot_path,
        }
        if window is not None:
            result["window"] = {"title": window["title"], "pid": window["pid"]}
        if diagnostic:
            result["diagnostic"] = diagnostic
        print(json.dumps(result, indent=2))

    elif command == "click":
        if len(args) < 3:
            exit_with_error("Usage: linux-driver.py click <x> <y> [--hold-seconds N]")
        mouse_click(float(args[1]), float(args[2]), _parse_hold_seconds(args[3:]))
        print_success()

    elif command == "double-click":
        if len(args) < 3:
            exit_with_error("Usage: linux-driver.py double-click <x> <y>")
        mouse_double_click(float(args[1]), float(args[2]))
        print_success()

    elif command == "right-click":
        if len(args) < 3:
            exit_with_error("Usage: linux-driver.py right-click <x> <y>")
        mouse_right_click(float(args[1]), float(args[2]))
        print_success()

    elif command == "type":
        if len(args) < 2:
            exit_with_error("Usage: linux-driver.py type <text>")
        text = " ".join(args[1:])
        type_text(text)
        print_success()

    elif command == "key":
        if len(args) < 2:
            exit_with_error("Usage: linux-driver.py key <key-spec>")
        press_key(args[1])
        print_success()

    elif command == "scroll":
        if len(args) < 2:
            exit_with_error("Usage: linux-driver.py scroll <direction> [steps] [--at x y]")
        direction = args[1]
        steps = 3
        at_x = None
        at_y = None
        si = 2
        while si < len(args):
            if args[si] == "--at" and si + 2 < len(args):
                at_x = float(args[si + 1])
                at_y = float(args[si + 2])
                si += 3
            else:
                try:
                    steps = int(args[si])
                except ValueError:
                    pass
                si += 1
        do_scroll(direction, steps, at_x, at_y)
        print_success()

    elif command == "drag":
        if len(args) < 5:
            exit_with_error(
                "Usage: linux-driver.py drag <from_x> <from_y> <to_x> <to_y> "
                "[--steps N] [--path x1,y1 ...] [--hold-seconds N]"
            )
        from_x, from_y = float(args[1]), float(args[2])
        to_x, to_y = float(args[3]), float(args[4])
        steps = 10
        waypoints = None
        hold_seconds = _parse_hold_seconds(args[5:])
        di = 5
        while di < len(args):
            if args[di] == "--steps" and di + 1 < len(args):
                steps = int(args[di + 1])
                di += 2
            elif args[di] == "--path":
                waypoints = []
                di += 1
                while di < len(args) and not args[di].startswith("--"):
                    parts = args[di].split(",")
                    waypoints.append((float(parts[0]), float(parts[1])))
                    di += 1
            else:
                di += 1
        mouse_drag(from_x, from_y, to_x, to_y, steps, waypoints, hold_seconds)
        print_success()

    elif command == "screenshot":
        if len(args) < 2:
            exit_with_error("Usage: linux-driver.py screenshot <path>")
        path = args[1]
        capture_screenshot(path)
        print_success(path=path)

    elif command == "screenshot-grid":
        if len(args) < 2:
            exit_with_error(
                "Usage: linux-driver.py screenshot-grid <path> --region x y w h --grid <2|3>"
            )
        path = args[1]
        region = None
        grid_size = 3
        si = 2
        while si < len(args):
            if args[si] == "--region" and si + 4 < len(args):
                region = {
                    "x": float(args[si + 1]), "y": float(args[si + 2]),
                    "width": float(args[si + 3]), "height": float(args[si + 4]),
                }
                si += 5
            elif args[si] == "--grid" and si + 1 < len(args):
                grid_size = int(args[si + 1])
                si += 2
            else:
                si += 1
        if not region:
            exit_with_error("screenshot-grid requires --region x y w h")
        capture_screenshot(path)
        crop_screenshot(path, region)
        draw_grid_overlay(path, grid_size)
        print_success(path=path)

    elif command == "activate":
        if len(args) < 2:
            exit_with_error("Usage: linux-driver.py activate <app-name>")
        app_name = " ".join(args[1:])
        try:
            window_visible = activate_app(app_name)
        except RuntimeError as e:
            exit_with_error(str(e))
        time.sleep(0.5)
        # `windowVisible: false` reports an app that was launched but has not shown
        # a window yet, so the caller waits instead of launching it again.
        print_success(windowVisible=window_visible)

    elif command == "read-clipboard":
        print(json.dumps(read_clipboard()))

    else:
        exit_with_error(f"Unknown command: '{command}'")


if __name__ == "__main__":
    main()
