#!/usr/bin/env python3
"""Dokki's input helper for Linux hosts (the twin of DokkiHelper on macOS / DokkiHelper.exe input on Windows).

    dokki-input touchpad                 JSON lines on stdin (src/input.js validateInput)
    dokki-input signature                stdin {"color","strokes"} -> a PNG in Pictures/Dokki/Signatures
    dokki-input signature-insert         the same, then copy it and paste it (Ctrl+V)
    dokki-input type-text '<json>'       {"text": "...", "enter": bool}
    dokki-input hotkey '<json>'          ["ctrl", "shift", "m"] (src/platform/contract.js validateHotkey)
    dokki-input probe                    {"backend": "uinput"|"xtest"|null, "reason": ...}
    dokki-input focus                    {"window": "x11"|"sway"|"hypr"|null, "atspi": bool, "editable", "password"}:
                                         what the focus reader sees now (diagnostics and the Wayland CI)
    dokki-input key-down '<json>'        push-to-talk: the hotkey's keys held down (X11 one-shot; uinput: daemon only)
    dokki-input key-up '<json>'          the key, then the modifiers, released
    dokki-input mouse-click '<json>'     {"button": "left"|"right"|"middle", "count": 1|2} at the pointer
    dokki-input input-daemon             hotkey / key-down / key-up / type-text / mouse-click / release-all and
                                         pad-probe / pad-state / pad-close requests as JSON lines
                                         (src/platform/input-daemon.js; one device, one X connection)

hotkey, key-down and key-up take an optional last argument "game" (game macros: bare
keys such as W or Space). This helper never refused a bare key (the host's
contract.js does), so it only accepts and checks the word.

Virtual gamepads (input-daemon only): one uinput device per slot 0..3, shaped like
the kernel's xpad driver (an Xbox 360 pad: SDL, Steam and Proton map it), separate
from the keyboard/pointer device. Neutral and unplugged on pad-close and at EOF.

Backends, in this order:
- uinput: a virtual keyboard + pointer ("Dokki virtual input") made through
  /dev/uinput. The kernel delivers it like a USB device, so it works on X11 and
  on every Wayland compositor (libinput reads it). It needs write access to
  /dev/uinput: the udev rule in native/linux/70-dokki-uinput.rules gives it to
  the user at the computer's own seat (systemd's uaccess), nobody else.
- xtest: an X11 session without that access, through libXtst (XTest), which
  any X client may use. Never under Wayland: XWayland's XTest would only reach
  XWayland windows, so there the reason is reported instead ("uinput").

Typing text: on X11 always through XTest, even next to a uinput pointer: each
character's key from the X server's own keymap (level 1 or Shift), anything
else on a borrowed free keycode, given back afterwards (as xdotool does), so
any Unicode character types with no input method. Under Wayland (uinput) each
character is looked up in the active XKB layout (libxkbcommon, the layout read
from DOKKI_XKB_LAYOUT, GNOME, KDE, localectl or /etc/default/keyboard; US when
none) and pressed as its keycode plus Shift / AltGr. A character the layout
cannot produce (emoji, another script) is typed with GTK/IBus's
Ctrl+Shift+U <hex> Space; apps without that input method ignore it. Caps Lock
on the computer inverts letters typed through uinput (its state is not read),
and with several layouts configured the first one is used.

Exit status: 0 done, 2 invalid input, 3 no input backend (reason on stderr),
4 a character this layout cannot type, 5 no virtual gamepad (pad verbs only,
"uinput: ..." or "failed: ..."), 6 the focused field is a password field (type-text:
nothing typed; known through AT-SPI, native/linux/dokki_focus.py), 1 anything else. Stdout carries only
JSON, never a typed character or a signature stroke. python3 stdlib only
(ctypes for libxkbcommon / libX11 / libXtst when present); unit tests in
native/linux/tests/test_dokki_input.py.
"""

import ctypes
import ctypes.util
import datetime
import fcntl
import json
import os
import re
import secrets
import selectors
import signal
import shutil
import struct
import subprocess
import sys
import time
import zlib

EXIT_INVALID, EXIT_NO_BACKEND, EXIT_UNTYPEABLE, EXIT_PAD, EXIT_PASSWORD, EXIT_UNKNOWN_FOCUS = 2, 3, 4, 5, 6, 7
UINPUT_PATH = "/dev/uinput"
DEVICE_NAME = b"Dokki virtual input"

# ---- evdev (linux/input-event-codes.h) ----
EV_SYN, EV_KEY, EV_REL, EV_ABS = 0x00, 0x01, 0x02, 0x03
SYN_REPORT = 0
REL_X, REL_Y, REL_HWHEEL, REL_WHEEL, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES = 0x00, 0x01, 0x06, 0x08, 0x0B, 0x0C
RELS = (REL_X, REL_Y, REL_HWHEEL, REL_WHEEL, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES)
BTN_LEFT, BTN_RIGHT, BTN_MIDDLE = 0x110, 0x111, 0x112
BUS_VIRTUAL, BUS_USB = 0x06, 0x03
# Gamepad (xpad's Xbox 360 shape)
BTN_A, BTN_B, BTN_X, BTN_Y, BTN_TL, BTN_TR = 0x130, 0x131, 0x133, 0x134, 0x136, 0x137
BTN_SELECT, BTN_START, BTN_MODE, BTN_THUMBL, BTN_THUMBR = 0x13A, 0x13B, 0x13C, 0x13D, 0x13E
ABS_X, ABS_Y, ABS_Z, ABS_RX, ABS_RY, ABS_RZ, ABS_HAT0X, ABS_HAT0Y = 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x10, 0x11
WHEEL_NOTCH = 120  # the phone's scroll units, like Windows' WHEEL_DELTA (src/input.js "scroll")

# ioctls (asm-generic _IOC: x86, arm, arm64, riscv)
def _ioc(direction, nr, size):
    return (direction << 30) | (size << 16) | (ord("U") << 8) | nr

UI_DEV_CREATE = _ioc(0, 1, 0)
UI_DEV_DESTROY = _ioc(0, 2, 0)
UI_SET_EVBIT = _ioc(1, 100, 4)
UI_SET_KEYBIT = _ioc(1, 101, 4)
UI_SET_RELBIT = _ioc(1, 102, 4)
UI_SET_ABSBIT = _ioc(1, 103, 4)

LEFTCTRL, LEFTSHIFT, LEFTALT, RIGHTALT, LEFTMETA = 29, 42, 56, 100, 125
ALTGR = RIGHTALT

# Layout-independent keys by name (Dokki's hotkey names, contract.js HOTKEY_KEYS).
NAMED = {
    "escape": 1, "backspace": 14, "tab": 15, "enter": 28, "space": 57,
    "home": 102, "up": 103, "pageup": 104, "left": 105, "right": 106, "end": 107, "down": 108, "pagedown": 109, "delete": 111,
    **{"f%d" % i: 58 + i for i in range(1, 11)}, "f11": 87, "f12": 88,
    **{"f%d" % i: 170 + i for i in range(13, 21)},  # F1..F20, as contract.js
}
MODIFIERS = {"ctrl": LEFTCTRL, "shift": LEFTSHIFT, "alt": LEFTALT, "option": LEFTALT, "cmd": LEFTMETA, "win": LEFTMETA}
# The phone keyboard's keys (src/input.js "key").
PRESS = {"Backspace": "backspace", "Delete": "delete", "Enter": "enter", "Tab": "tab", "Escape": "escape",
         "ArrowLeft": "left", "ArrowRight": "right", "ArrowUp": "up", "ArrowDown": "down", "Home": "home", "End": "end"}

# A US layout by position: the fallback when no XKB keymap can be compiled.
_US_ROWS = [
    ("1234567890-=", "!@#$%^&*()_+", 2),
    ("qwertyuiop[]", "QWERTYUIOP{}", 16),
    ("asdfghjkl;'`", "ASDFGHJKL:\"~", 30),
]
US_KEYMAP = {}
for plain, shifted, first in _US_ROWS:
    for i, (a, b) in enumerate(zip(plain, shifted)):
        US_KEYMAP[a] = (first + i, ())
        US_KEYMAP[b] = (first + i, (LEFTSHIFT,))
US_KEYMAP["\\"], US_KEYMAP["|"] = (43, ()), (43, (LEFTSHIFT,))
for i, (a, b) in enumerate(zip("zxcvbnm,./", "ZXCVBNM<>?")):
    US_KEYMAP[a], US_KEYMAP[b] = (44 + i, ()), (44 + i, (LEFTSHIFT,))
US_KEYMAP[" "] = (57, ())

KEYBITS = sorted(set(range(1, 249)) | {BTN_LEFT, BTN_RIGHT, BTN_MIDDLE})


class InputError(Exception):
    """Input this helper refuses (exit 2)."""


class Untypeable(Exception):
    """A character the layout cannot produce (exit 4)."""


class PasswordField(Exception):
    """type-text into a field AT-SPI reports as a password field: refused, nothing typed."""


class UnknownFocus(Exception):
    """type-text while AT-SPI cannot say which field has focus (no AT-SPI here, or an app without
    accessibility: Qt or Electron while no assistive technology asked, plain X11 toolkits): refused,
    nothing typed (exit 7), unless the owner allowed typing into that app (`anyField`)."""


TEXT_KEYS = {"text", "enter", "anyField"}


def text_request(value):
    """type-text's JSON: {text, enter?, anyField?}."""
    if not isinstance(value, dict) or set(value) - TEXT_KEYS or not isinstance(value.get("enter", False), bool) \
            or not isinstance(value.get("anyField", False), bool):
        raise InputError("expected {text, enter, anyField}")
    return value


def field_state(reader):
    """"password", "field" (AT-SPI knows the focused element and it is not a password field) or
    "unknown" (no reader, an accessibility failure, nothing focused that AT-SPI can see)."""
    if not reader:
        return "unknown"
    try:
        if hasattr(reader, "field_state"):
            state = reader.field_state()
            return state if state in ("password", "field") else "unknown"
        return "password" if reader.password() else "field"
    except Exception:  # noqa: BLE001 - an accessibility failure is "not known"
        return "unknown"


def refuse_password(reader, any_field=False):
    """Voz and type-text tiles never type into a password field, and fail closed when the focused
    field cannot be read (as DokkiHelper on macOS and Windows, exit 5 there): without AT-SPI, or in
    an app without accessibility, nothing is typed unless the owner allowed that app in Studio
    (src/type-guard.js sends anyField). A known password field is refused even then."""
    state = field_state(reader)
    if state == "password":
        raise PasswordField("password field")
    if state == "unknown" and not any_field:
        raise UnknownFocus("focused field unknown")


class NoBackend(Exception):
    """No way to inject input here (exit 3); str() is the reason."""


# ---- XKB layouts ----

def parse_setxkbmap(out):
    fields = dict(re.findall(r"^(\w+):[ \t]*(\S*)[ \t]*$", out or "", re.M))
    return _first_layout(fields.get("layout", ""), fields.get("variant", ""))


def parse_gnome_sources(out):
    """`gsettings get org.gnome.desktop.input-sources mru-sources|sources` -> the first xkb source."""
    for kind, value in re.findall(r"\(\s*'([^']*)'\s*,\s*'([^']*)'\s*\)", out or ""):
        if kind == "xkb" and value:
            layout, _, variant = value.partition("+")
            return _clean(layout, variant)
    return None


def parse_kxkbrc(text):
    section, fields = "", {}
    for line in (text or "").splitlines():
        line = line.strip()
        if line.startswith("["):
            section = line
        elif section == "[Layout]" and "=" in line:
            key, _, value = line.partition("=")
            fields[key.strip()] = value.strip()
    if fields.get("Use", "true").lower() == "false":
        return None
    return _first_layout(fields.get("LayoutList", ""), fields.get("VariantList", ""))


def parse_localectl(out):
    layout = re.search(r"X11 Layout:[ \t]*(\S+)", out or "")
    variant = re.search(r"X11 Variant:[ \t]*(\S+)", out or "")
    return _first_layout(layout.group(1) if layout else "", variant.group(1) if variant else "")


def parse_default_keyboard(text):
    fields = dict(re.findall(r'^\s*(XKB\w+)\s*=\s*"?([^"\n]*)"?\s*$', text or "", re.M))
    return _first_layout(fields.get("XKBLAYOUT", ""), fields.get("XKBVARIANT", ""))


def _first_layout(layouts, variants):
    layout = (layouts or "").split(",")[0].strip()
    variant = (variants or "").split(",")[0].strip()
    return _clean(layout, variant)


def _clean(layout, variant=""):
    ok = re.compile(r"^[A-Za-z0-9_()-]{1,64}$")
    if not layout or not ok.match(layout):
        return None
    return (layout, variant if variant and ok.match(variant) else "")


def is_wayland(env):
    return (env.get("XDG_SESSION_TYPE", "").lower() == "wayland") or bool(env.get("WAYLAND_DISPLAY"))


def is_x11(env):
    return bool(env.get("DISPLAY")) and not is_wayland(env)


def has_session(env):
    """A graphical session this process belongs to (X11 or Wayland)."""
    return is_wayland(env) or bool(env.get("DISPLAY")) or env.get("XDG_SESSION_TYPE", "").lower() == "x11"


def _run(argv, timeout=3):
    try:
        done = subprocess.run(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=timeout, check=False)
        return done.stdout.decode("utf-8", "replace") if done.returncode == 0 else ""
    except (OSError, subprocess.SubprocessError):
        return ""


def _read(path):
    try:
        with open(path, encoding="utf-8", errors="replace") as handle:
            return handle.read(65536)
    except OSError:
        return ""


def detect_layout(env, run=_run, read=_read):
    """The active XKB (layout, variant); ("us", "") when nothing says otherwise."""
    if env.get("DOKKI_XKB_LAYOUT"):
        found = _clean(env["DOKKI_XKB_LAYOUT"], env.get("DOKKI_XKB_VARIANT", ""))
        if found:
            return found
    if is_x11(env):
        found = parse_setxkbmap(run(["setxkbmap", "-query"]))
        if found:
            return found
    desktop = env.get("XDG_CURRENT_DESKTOP", "").lower()
    if "kde" in desktop:
        home = env.get("HOME", "")
        found = parse_kxkbrc(read(os.path.join(env.get("XDG_CONFIG_HOME") or os.path.join(home, ".config"), "kxkbrc"))) if home else None
        if found:
            return found
    if any(name in desktop for name in ("gnome", "unity", "budgie", "pantheon", "cinnamon")):
        for key in ("mru-sources", "sources"):
            found = parse_gnome_sources(run(["gsettings", "get", "org.gnome.desktop.input-sources", key]))
            if found:
                return found
    for found in (parse_localectl(run(["localectl", "status"])), parse_default_keyboard(read("/etc/default/keyboard")),
                  _clean(env.get("XKB_DEFAULT_LAYOUT", ""), env.get("XKB_DEFAULT_VARIANT", ""))):
        if found:
            return found
    return ("us", "")


class _RuleNames(ctypes.Structure):
    _fields_ = [(name, ctypes.c_char_p) for name in ("rules", "model", "layout", "variant", "options")]


def xkb_entries(layout, variant=""):
    """[(char, evdev code, level, mod names)] from libxkbcommon, or None without it."""
    try:
        lib = ctypes.CDLL(ctypes.util.find_library("xkbcommon") or "libxkbcommon.so.0")
    except OSError:
        return None
    vp, u32 = ctypes.c_void_p, ctypes.c_uint32
    lib.xkb_context_new.restype, lib.xkb_context_new.argtypes = vp, [ctypes.c_int]
    lib.xkb_context_unref.argtypes = [vp]
    lib.xkb_keymap_new_from_names.restype, lib.xkb_keymap_new_from_names.argtypes = vp, [vp, ctypes.POINTER(_RuleNames), ctypes.c_int]
    lib.xkb_keymap_unref.argtypes = [vp]
    for name in ("xkb_keymap_min_keycode", "xkb_keymap_max_keycode"):
        getattr(lib, name).restype, getattr(lib, name).argtypes = u32, [vp]
    lib.xkb_keymap_num_levels_for_key.restype, lib.xkb_keymap_num_levels_for_key.argtypes = u32, [vp, u32, u32]
    lib.xkb_keymap_key_get_syms_by_level.restype = ctypes.c_int
    lib.xkb_keymap_key_get_syms_by_level.argtypes = [vp, u32, u32, u32, ctypes.POINTER(ctypes.POINTER(u32))]
    lib.xkb_keysym_to_utf32.restype, lib.xkb_keysym_to_utf32.argtypes = u32, [u32]
    lib.xkb_keymap_mod_get_index.restype, lib.xkb_keymap_mod_get_index.argtypes = u32, [vp, ctypes.c_char_p]
    mods_for_level = getattr(lib, "xkb_keymap_key_get_mods_for_level", None)  # libxkbcommon >= 1.0
    if mods_for_level is not None:
        mods_for_level.restype, mods_for_level.argtypes = ctypes.c_size_t, [vp, u32, u32, u32, ctypes.POINTER(u32), ctypes.c_size_t]
    ctx = lib.xkb_context_new(0)
    if not ctx:
        return None
    names = _RuleNames(None, None, layout.encode(), (variant or "").encode() or None, None)
    keymap = lib.xkb_keymap_new_from_names(ctx, ctypes.byref(names), 0)
    if not keymap:
        lib.xkb_context_unref(ctx)
        return None
    try:
        index = {name: lib.xkb_keymap_mod_get_index(keymap, name.encode()) for name in ("Shift", "Mod5", "LevelThree", "Lock", "Control", "Mod1", "Mod2")}
        entries = []
        for keycode in range(lib.xkb_keymap_min_keycode(keymap), lib.xkb_keymap_max_keycode(keymap) + 1):
            if keycode < 8 or keycode - 8 > 248:
                continue
            for level in range(lib.xkb_keymap_num_levels_for_key(keymap, keycode, 0)):
                syms = ctypes.POINTER(u32)()
                if lib.xkb_keymap_key_get_syms_by_level(keymap, keycode, 0, level, ctypes.byref(syms)) != 1:
                    continue
                cp = lib.xkb_keysym_to_utf32(syms[0])
                if not cp:
                    continue
                if mods_for_level is None:
                    masks = [((), ("Shift",), ("Mod5",), ("Shift", "Mod5"))[level]] if level < 4 else []
                else:
                    buffer = (u32 * 16)()
                    count = mods_for_level(keymap, keycode, 0, level, buffer, 16)
                    masks = [tuple(name for name, bit in index.items() if bit < 32 and buffer[i] & (1 << bit)) for i in range(min(count, 16))]
                for mods in masks:
                    entries.append((chr(cp), keycode - 8, level, mods))
        return entries
    finally:
        lib.xkb_keymap_unref(keymap)
        lib.xkb_context_unref(ctx)


def build_keymap(entries):
    """XKB entries -> {char: (code, (modifier codes))}: Shift and AltGr only, the fewest
    modifiers first, then the lowest key (the main row before the keypad)."""
    if not entries:
        return dict(US_KEYMAP)
    out, rank = {}, {}
    for char, code, _level, mods in entries:
        names = set(mods)
        if names - {"Shift", "Mod5", "LevelThree"}:
            continue  # Caps/Num Lock, Control or Alt levels: not what a plain press types
        held = tuple(([LEFTSHIFT] if "Shift" in names else []) + ([ALTGR] if names & {"Mod5", "LevelThree"} else []))
        key = (len(held), code)
        if char not in rank or key < rank[char]:
            out[char], rank[char] = (code, held), key
    for char in (" ",):
        out.setdefault(char, US_KEYMAP[char])
    return out


def load_keymap(env):
    layout, variant = detect_layout(env)
    # A variant libxkbcommon cannot compile (a stale or misread one): the plain layout, then US.
    return build_keymap(xkb_entries(layout, variant) or (xkb_entries(layout) if variant else None) or None)


# ---- what to press ----

def plan_text(text, keymap, unicode_fallback=True):
    """Text -> [(code, (modifier codes))]. Newlines are Enter, tabs Tab; anything the
    layout lacks becomes Ctrl+Shift+U <hex> Space (GTK / IBus), or Untypeable."""
    if not isinstance(text, str) or not 1 <= len(text) <= 1024:
        raise InputError("text must be 1..1024 characters")
    steps, previous = [], ""
    for char in text:
        if char == "\n" and previous == "\r":
            previous = char
            continue
        previous = char
        if char in "\r\n":
            steps.append((NAMED["enter"], ()))
        elif char == "\t":
            steps.append((NAMED["tab"], ()))
        elif ord(char) < 0x20 or ord(char) == 0x7F or 0xD800 <= ord(char) <= 0xDFFF:
            raise InputError("text contains control characters")
        elif char in keymap:
            steps.append(keymap[char])
        elif unicode_fallback:
            steps.extend(unicode_steps(ord(char), keymap))
        else:
            raise Untypeable("not in this layout")
    return steps


def unicode_steps(codepoint, keymap):
    digits = []
    for digit in "%x" % codepoint:
        if digit not in keymap:
            raise Untypeable("this layout cannot type the hex digits Ctrl+Shift+U needs")
        digits.append(keymap[digit])  # AZERTY's digits need Shift: the input method reads the keysym
    u = keymap.get("u")
    if not u or u[1]:
        raise Untypeable("no U key")
    return [(u[0], (LEFTCTRL, LEFTSHIFT))] + digits + [(NAMED["space"], ())]


def hold_id(keys):
    """The same keys whatever the modifier order or alias (option = alt, win = cmd): how a
    key-up finds the codes its key-down pressed. Call after hotkey_codes validated `keys`."""
    keys = [k.lower() for k in keys]
    if keys[-1] in MODIFIERS:  # a game's modifier-only hold (["shift"]): every name is a modifier
        return tuple(sorted(MODIFIERS[k] for k in keys)) + ("",)
    return tuple(sorted(MODIFIERS[k] for k in keys[:-1])) + (keys[-1],)


def hotkey_codes(keys, keymap, game=False):
    """["ctrl", "shift", "m"] -> (modifier codes, key code). Letters, digits and
    punctuation follow the layout (Ctrl+Z is the key that types z), names do not.
    game (a game macro): 1..4 distinct modifiers alone too (["shift"] to sprint), the
    left-hand keys; the last one stands as the key, so a tap is down in order, up in reverse.
    Names in any case. Without game, a typing key (letter, digit, punctuation, space) needs a
    modifier other than Shift, as contract.js validateHotkey (function and navigation keys
    stand alone); a bare game key is the key itself, without the layout's implied modifiers."""
    if (not isinstance(keys, list) or not 1 <= len(keys) <= 5
            or not all(isinstance(k, str) and len(k) <= 12 for k in keys)):
        raise InputError("expected a key list")
    keys = [k.lower() for k in keys]
    if game and all(k in MODIFIERS for k in keys):
        codes = [MODIFIERS[k] for k in keys]
        if len(codes) > 4 or len(set(codes)) != len(codes):
            raise InputError("unknown or repeated modifier")
        return codes[:-1], codes[-1]
    mods = []
    for name in keys[:-1]:
        if name not in MODIFIERS or MODIFIERS[name] in mods:
            raise InputError("unknown or repeated modifier")
        mods.append(MODIFIERS[name])
    key = keys[-1]
    if key in NAMED and key != "space":
        return mods, NAMED[key]
    if not game and not any(m != LEFTSHIFT for m in mods):
        raise InputError("this key needs a modifier other than shift")
    if key == "space":
        return mods, NAMED[key]
    if len(key) == 1 and key.isascii() and (key.isalnum() or key in "-=[]\\;',./`"):
        found = keymap.get(key)
        if found and not found[1]:
            return mods, found[0]
        if found and not key.isalnum() and game and not mods:
            return mods, found[0]  # a game's bare key: the key itself (German "/" is the 7 key)
        if found and not key.isalnum():
            # Punctuation behind Shift / AltGr on this layout (German "/" is Shift+7): press what
            # types it, so Ctrl+/ stays Ctrl+slash rather than the US position's Ctrl+-.
            return mods + [m for m in found[1] if m not in mods], found[0]
        if key in US_KEYMAP and not US_KEYMAP[key][1]:
            return mods, US_KEYMAP[key][0]
    raise InputError("unknown key")


class Wheel:
    """Phone scroll units (120 a notch) -> hi-res wheel values plus whole notches."""

    def __init__(self):
        self.rest = 0

    def add(self, value):
        self.rest += value
        notches = int(self.rest / WHEEL_NOTCH)
        self.rest -= notches * WHEEL_NOTCH
        return notches


# ---- uinput ----

def pack_event(kind, code, value):
    return struct.pack("@llHHi", 0, 0, kind, code, value)


def user_dev(name=DEVICE_NAME):
    """struct uinput_user_dev (the legacy setup every kernel since 2.6 takes)."""
    return struct.pack("80sHHHHI", name[:79], BUS_VIRTUAL, 0x1209, 0xD0CC, 1, 0) + b"\0" * (4 * 64 * 4)


class UInputDevice:
    """One virtual keyboard + pointer. `ops` (open / ioctl / write / close) is for tests."""

    def __init__(self, path=UINPUT_PATH, ops=None):
        self.ops = ops or {"open": os.open, "ioctl": fcntl.ioctl, "write": os.write, "close": os.close}
        try:
            self.fd = self.ops["open"](path, os.O_WRONLY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0))
        except OSError as error:
            raise NoBackend("uinput") from error
        try:
            for kind in (EV_SYN, EV_KEY, EV_REL):
                self.ops["ioctl"](self.fd, UI_SET_EVBIT, kind)
            for code in KEYBITS:
                self.ops["ioctl"](self.fd, UI_SET_KEYBIT, code)
            for rel in RELS:
                self.ops["ioctl"](self.fd, UI_SET_RELBIT, rel)
            self.ops["write"](self.fd, user_dev())
            self.ops["ioctl"](self.fd, UI_DEV_CREATE)
        except OSError as error:
            self.ops["close"](self.fd)
            raise NoBackend("uinput") from error

    def emit(self, events):
        data = b"".join(pack_event(*event) for event in events) + pack_event(EV_SYN, SYN_REPORT, 0)
        self.ops["write"](self.fd, data)

    def close(self):
        try:
            self.ops["ioctl"](self.fd, UI_DEV_DESTROY)
        finally:
            self.ops["close"](self.fd)


class UInputBackend:
    name = "uinput"

    def __init__(self, device, keymap, key_delay=0.002):
        self.device, self.keymap, self.key_delay = device, keymap, key_delay
        self.held = []  # keys this session pressed and has not released, in order
        self.holds = {}  # key-down's keys (hold_id) -> the codes it pressed, for key-up
        self.emitted = False
        self.wheel, self.hwheel = Wheel(), Wheel()

    def _key(self, code, down):
        self.emitted = True
        self.device.emit([(EV_KEY, code, 1 if down else 0)])
        if down:
            if code not in self.held:
                self.held.append(code)
        elif code in self.held:
            self.held.remove(code)

    def _kept(self):
        """Codes a key-down still holds (two holds may share Ctrl): not let go by another press."""
        return {code for codes in self.holds.values() for code in codes}

    def move(self, dx, dy):
        events = ([(EV_REL, REL_X, dx)] if dx else []) + ([(EV_REL, REL_Y, dy)] if dy else [])
        if events:
            self.device.emit(events)

    def button(self, name, down):
        self._key({"left": BTN_LEFT, "middle": BTN_MIDDLE}.get(name, BTN_RIGHT), down)

    def scroll(self, dx, dy):
        events = []
        if dy:
            events.append((EV_REL, REL_WHEEL_HI_RES, dy))
            notches = self.wheel.add(dy)
            if notches:
                events.append((EV_REL, REL_WHEEL, notches))
        if dx:
            events.append((EV_REL, REL_HWHEEL_HI_RES, dx))
            notches = self.hwheel.add(dx)
            if notches:
                events.append((EV_REL, REL_HWHEEL, notches))
        if events:
            self.device.emit(events)

    def zoom(self, steps):
        self._key(LEFTCTRL, True)
        try:
            self.device.emit([(EV_REL, REL_WHEEL_HI_RES, steps * WHEEL_NOTCH), (EV_REL, REL_WHEEL, steps)])
        finally:
            self._key(LEFTCTRL, False)

    def tap(self, code, mods=()):
        """Modifiers down in order, the key down and up, modifiers up in reverse."""
        try:
            for mod in mods:
                self._key(mod, True)
            self._key(code, True)
            self._key(code, False)
        finally:
            kept = self._kept()
            for mod in reversed(mods):
                if mod in self.held and mod not in kept:
                    self._key(mod, False)
        if self.key_delay:
            time.sleep(self.key_delay)

    def press(self, name):
        self.tap(NAMED[name])

    def chord(self, keys, game=False):
        mods, code = hotkey_codes(keys, self.keymap, game)
        self.tap(code, tuple(mods))

    def text(self, text):
        for code, mods in plan_text(text, self.keymap):
            self.tap(code, mods)

    def hold(self, keys, game=False):
        """key-down (push-to-talk): modifiers in order, then the key, nothing released.
        A press that fails half-way lets go of what it pressed."""
        mods, code = hotkey_codes(keys, self.keymap, game)
        pressed, before = [], set(self.held)
        try:
            for key in list(mods) + [code]:
                self._key(key, True)
                pressed.append(key)
            self.holds[hold_id(keys)] = pressed
        except BaseException:
            for key in reversed(pressed):
                if key in self.held and key not in before:
                    try:
                        self._key(key, False)
                    except OSError:
                        self.held.remove(key)
            raise

    def lift(self, keys, game=False):
        """key-up: the key, then the modifiers in reverse. A release of a key that is up
        is dropped by the kernel, so this is safe when nothing is down."""
        mods, code = hotkey_codes(keys, self.keymap, game)
        # The codes key-down pressed: the layout (self.keymap) may have been reloaded since.
        recorded = self.holds.pop(hold_id(keys), None)
        kept = self._kept()
        for key in reversed(recorded) if recorded else [code] + list(reversed(mods)):
            if key not in kept:
                self._key(key, False)

    def release(self):
        self.holds.clear()
        for code in reversed(list(self.held)):
            self._key(code, False)

    def close(self, linger=0.05):
        try:
            self.release()
            # Readers lose what is still queued once the device is gone (evdev answers ENODEV):
            # give the compositor a moment to read the last events (a paste's Ctrl+V, the
            # releases). The kernel also lifts any key still down when a device unregisters.
            if self.emitted and linger:
                time.sleep(linger)
        finally:
            try:
                self.device.close()
            finally:
                typist = getattr(self, "x_text", None)
                if typist:
                    typist.close()


# ---- virtual gamepads (uinput, one device per slot) ----

PAD_NAME = b"Dokki virtual gamepad"
# Dokki's button bits (native-spec.md) -> evdev; bits 10..13 are the d-pad (a hat), 14/15 ignored.
PAD_BUTTONS = ((0, BTN_A), (1, BTN_B), (2, BTN_X), (3, BTN_Y), (4, BTN_TL), (5, BTN_TR), (6, BTN_SELECT),
               (7, BTN_START), (8, BTN_THUMBL), (9, BTN_THUMBR))
PAD_KEYS = tuple(code for _bit, code in PAD_BUTTONS) + (BTN_MODE,)  # Guide: declared like xpad, never pressed
STICK = (-32768, 32767, 16, 128)
PAD_ABS = {ABS_X: STICK, ABS_Y: STICK, ABS_RX: STICK, ABS_RY: STICK,
           ABS_Z: (0, 255, 0, 0), ABS_RZ: (0, 255, 0, 0), ABS_HAT0X: (-1, 1, 0, 0), ABS_HAT0Y: (-1, 1, 0, 0)}
PAD_UNWRITABLE = "uinput: /dev/uinput is not writable"


class PadUnavailable(Exception):
    """No virtual gamepad can be made (exit 5); str() is "<reason token>: <English>"."""


def parse_pad_slot(value):
    if not isinstance(value, str) or value not in ("0", "1", "2", "3"):
        raise InputError("slot")
    return int(value)


def _clamp(value, low, high):
    return max(low, min(high, value))


def parse_pad_state(text):
    """'[buttons, lx, ly, rx, ry, lt, rt]' -> the clamped tuple. Integers only (never floats or
    booleans), exactly seven; out-of-range values are clamped, bits above 0x3FFF dropped."""
    try:
        value = json.loads(text)
    except (TypeError, ValueError) as error:
        raise InputError("state") from error
    if not isinstance(value, list) or len(value) != 7 or not all(isinstance(n, int) and not isinstance(n, bool) for n in value):
        raise InputError("state")
    buttons, lx, ly, rx, ry, lt, rt = value
    sticks = tuple(_clamp(n, -32767, 32767) for n in (lx, ly, rx, ry))
    return (_clamp(buttons, 0, 0xFFFF) & 0x3FFF,) + sticks + (_clamp(lt, 0, 255), _clamp(rt, 0, 255))


def pad_events(state):
    """A parsed state -> every (EV_KEY | EV_ABS, code, value) the pad reports. Evdev's Y grows
    downwards (XInput's up is positive); opposite d-pad directions cancel."""
    buttons, lx, ly, rx, ry, lt, rt = state
    events = [(EV_KEY, code, 1 if buttons & (1 << bit) else 0) for bit, code in PAD_BUTTONS] + [(EV_KEY, BTN_MODE, 0)]
    pressed = [1 if buttons & (1 << bit) else 0 for bit in (10, 11, 12, 13)]  # up, down, left, right
    events += [(EV_ABS, ABS_X, lx), (EV_ABS, ABS_Y, -ly), (EV_ABS, ABS_RX, rx), (EV_ABS, ABS_RY, -ry),
               (EV_ABS, ABS_Z, lt), (EV_ABS, ABS_RZ, rt),
               (EV_ABS, ABS_HAT0X, pressed[3] - pressed[2]), (EV_ABS, ABS_HAT0Y, pressed[1] - pressed[0])]
    return events


NEUTRAL_PAD = (0, 0, 0, 0, 0, 0, 0)


def pad_user_dev(name=PAD_NAME):
    """struct uinput_user_dev with the axes' ranges: absmax, absmin, absfuzz, absflat[64] each."""
    columns = [[0] * 64 for _ in range(4)]
    for axis, (low, high, fuzz, flat) in PAD_ABS.items():
        columns[0][axis], columns[1][axis], columns[2][axis], columns[3][axis] = high, low, fuzz, flat
    head = struct.pack("80sHHHHI", name[:79], BUS_USB, 0x045E, 0x028E, 0x0110, 0)
    return head + b"".join(struct.pack("64i", *column) for column in columns)


def pad_writable(path=UINPUT_PATH):
    return os.access(path, os.W_OK)


class UInputGamepad:
    """One virtual Xbox 360-shaped pad. `ops` (open / ioctl / write / close) is for tests."""

    def __init__(self, path=UINPUT_PATH, ops=None, linger=0.05):
        self.ops = ops or {"open": os.open, "ioctl": fcntl.ioctl, "write": os.write, "close": os.close}
        self.linger = linger
        try:
            self.fd = self.ops["open"](path, os.O_WRONLY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0))
        except OSError as error:
            raise PadUnavailable(PAD_UNWRITABLE) from error
        try:
            for kind in (EV_SYN, EV_KEY, EV_ABS):
                self.ops["ioctl"](self.fd, UI_SET_EVBIT, kind)
            for code in PAD_KEYS:
                self.ops["ioctl"](self.fd, UI_SET_KEYBIT, code)
            for axis in PAD_ABS:
                self.ops["ioctl"](self.fd, UI_SET_ABSBIT, axis)
            self.ops["write"](self.fd, pad_user_dev())
            self.ops["ioctl"](self.fd, UI_DEV_CREATE)
        except OSError as error:
            self.ops["close"](self.fd)
            raise PadUnavailable("failed: %s" % (error.strerror or error)) from error
        # A new device starts with every key up and every axis at 0 (the neutral state).
        self.last = {(kind, code): value for kind, code, value in pad_events(NEUTRAL_PAD)}
        self.emitted = False

    def submit(self, state):
        """One report: the values that changed since the last one, then SYN_REPORT."""
        changed = [event for event in pad_events(state) if self.last.get(event[:2]) != event[2]]
        if not changed:
            return
        self.ops["write"](self.fd, b"".join(pack_event(*event) for event in changed) + pack_event(EV_SYN, SYN_REPORT, 0))
        self.emitted = True
        for kind, code, value in changed:
            self.last[(kind, code)] = value

    def close(self):
        """Neutral first (a game must not keep a stick pushed), then unplugged."""
        try:
            self.submit(NEUTRAL_PAD)
            if self.emitted and self.linger:
                time.sleep(self.linger)  # readers get the neutral report before the device goes (ENODEV)
        except OSError:
            pass
        finally:
            try:
                self.ops["ioctl"](self.fd, UI_DEV_DESTROY)
            finally:
                self.ops["close"](self.fd)


# ---- clicks (mouse-click) ----

def parse_click(text):
    """'{"button": "left"|"right"|"middle", "count": 1|2}' (count optional) -> (button, count)."""
    try:
        value = json.loads(text)
    except (TypeError, ValueError) as error:
        raise InputError("click") from error
    if not isinstance(value, dict) or set(value) - {"button", "count"} or value.get("button") not in ("left", "right", "middle"):
        raise InputError("click")
    count = value.get("count", 1)
    if not isinstance(count, int) or isinstance(count, bool) or count not in (1, 2):
        raise InputError("click")
    return value["button"], count


def click(backend, button, count, sleep=time.sleep, gap=0.03):
    """Down and up at the pointer, twice ~30 ms apart for a double click."""
    for i in range(count):
        if i:
            sleep(gap)
        backend.button(button, True)
        backend.button(button, False)


# ---- X11 (XTest) ----

X_KEYSYMS = {"escape": "Escape", "backspace": "BackSpace", "tab": "Tab", "enter": "Return", "space": "space", "home": "Home",
             "up": "Up", "pageup": "Prior", "left": "Left", "right": "Right", "end": "End", "down": "Down", "pagedown": "Next",
             "delete": "Delete", "-": "minus", "=": "equal", "[": "bracketleft", "]": "bracketright", "\\": "backslash",
             ";": "semicolon", "'": "apostrophe", ",": "comma", ".": "period", "/": "slash", "`": "grave",
             **{"f%d" % i: "F%d" % i for i in range(1, 21)}}
X_MODIFIERS = {"ctrl": "Control_L", "shift": "Shift_L", "alt": "Alt_L", "option": "Alt_L", "cmd": "Super_L", "win": "Super_L"}


def x_keysym_name(key):
    """A Dokki key name (any case) -> the X keysym name xdotool / XStringToKeysym take."""
    key = key.lower()
    if key in X_MODIFIERS:
        return X_MODIFIERS[key]
    if key in X_KEYSYMS:
        return X_KEYSYMS[key]
    if len(key) == 1 and key.isalnum() and key.isascii():
        return key.lower()
    raise InputError("unknown key")


class XTestBackend:
    """X11 through libXtst (XTest), which any X client may use."""
    name = "xtest"

    def __init__(self, env):
        try:
            self.x11 = ctypes.CDLL(ctypes.util.find_library("X11") or "libX11.so.6")
            self.xtst = ctypes.CDLL(ctypes.util.find_library("Xtst") or "libXtst.so.6")
        except OSError as error:
            raise NoBackend("xtest") from error
        self.x11.XOpenDisplay.restype, self.x11.XOpenDisplay.argtypes = ctypes.c_void_p, [ctypes.c_char_p]
        self.x11.XFlush.argtypes = [ctypes.c_void_p]
        self.x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
        self.x11.XStringToKeysym.restype, self.x11.XStringToKeysym.argtypes = ctypes.c_ulong, [ctypes.c_char_p]
        self.x11.XKeysymToKeycode.restype, self.x11.XKeysymToKeycode.argtypes = ctypes.c_ubyte, [ctypes.c_void_p, ctypes.c_ulong]
        self.x11.XKeycodeToKeysym.restype, self.x11.XKeycodeToKeysym.argtypes = ctypes.c_ulong, [ctypes.c_void_p, ctypes.c_ubyte, ctypes.c_int]
        self.xtst.XTestFakeRelativeMotionEvent.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_ulong]
        self.xtst.XTestFakeButtonEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_int, ctypes.c_ulong]
        self.xtst.XTestFakeKeyEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_int, ctypes.c_ulong]
        self.dpy = self.x11.XOpenDisplay((env.get("DISPLAY") or "").encode() or None)
        if not self.dpy:
            raise NoBackend("no-display")
        self.x11.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
        self.x11.XFree.argtypes = [ctypes.c_void_p]
        self.x11.XDisplayKeycodes.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)]
        self.x11.XGetKeyboardMapping.restype = ctypes.POINTER(ctypes.c_ulong)
        self.x11.XGetKeyboardMapping.argtypes = [ctypes.c_void_p, ctypes.c_ubyte, ctypes.c_int, ctypes.POINTER(ctypes.c_int)]
        self.x11.XChangeKeyboardMapping.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.c_ulong), ctypes.c_int]
        self.x11.XQueryKeymap.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
        self.env, self.free, self.borrowed = env, None, {}
        self.held_buttons, self.held_keys = [], []
        self.holds = {}  # key-down's keys (hold_id) -> the keycodes it pressed, for key-up
        self.wheel, self.hwheel = Wheel(), Wheel()

    def _flush(self):
        self.x11.XFlush(self.dpy)

    def refresh_mapping(self):
        """A long-lived connection (input-daemon) must read MappingNotify itself, or a layout
        switch never reaches XKeysymToKeycode."""
        self.x11.XPending.argtypes, self.x11.XPending.restype = [ctypes.c_void_p], ctypes.c_int
        self.x11.XNextEvent.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
        self.x11.XRefreshKeyboardMapping.argtypes = [ctypes.c_char_p]
        event = ctypes.create_string_buffer(256)  # XEvent: 24 longs
        while self.x11.XPending(self.dpy) > 0:
            self.x11.XNextEvent(self.dpy, event)
            if struct.unpack_from("i", event.raw)[0] == 34:  # MappingNotify
                self.x11.XRefreshKeyboardMapping(event)
                if not self.borrowed:
                    self.free = None  # a new layout may bind what was a free keycode

    def release_down(self, names):
        """Key-up for every modifier, and the named keys, the X server has down (after a crash)."""
        syms = ["Control_L", "Control_R", "Shift_L", "Shift_R", "Alt_L", "Alt_R", "Super_L", "Super_R", "ISO_Level3_Shift", "Meta_L", "Meta_R"]
        for name in names:
            try:
                syms.append(x_keysym_name(name))
            except InputError:
                pass
        down = self.down_keys()
        for sym in syms:
            code = self.x11.XKeysymToKeycode(self.dpy, self.x11.XStringToKeysym(sym.encode()))
            if code and code in down:
                self.xtst.XTestFakeKeyEvent(self.dpy, code, 0, 0)
        self._flush()

    def _keycode(self, name):
        code = self.x11.XKeysymToKeycode(self.dpy, self.x11.XStringToKeysym(x_keysym_name(name).encode()))
        if not code and name.lower() in US_KEYMAP and not US_KEYMAP[name.lower()][1]:
            # A dead key on this layout (German ` is dead_grave): the US position's key, X keycode = evdev + 8.
            code = US_KEYMAP[name.lower()][0] + 8
        if not code:
            raise Untypeable("no key for %s" % name)
        return code

    def _key(self, code, down):
        self.xtst.XTestFakeKeyEvent(self.dpy, code, 1 if down else 0, 0)
        if down:
            self.held_keys.append(code)
        elif code in self.held_keys:
            self.held_keys.remove(code)

    def _button(self, number, down):
        self.xtst.XTestFakeButtonEvent(self.dpy, number, 1 if down else 0, 0)
        if down:
            self.held_buttons.append(number)
        elif number in self.held_buttons:
            self.held_buttons.remove(number)

    def move(self, dx, dy):
        self.xtst.XTestFakeRelativeMotionEvent(self.dpy, dx, dy, 0)
        self._flush()

    def button(self, name, down):
        self._button({"left": 1, "middle": 2}.get(name, 3), down)
        self._flush()

    def _clicks(self, number, count):
        for _ in range(min(abs(count), 32)):
            self._button(number, True)
            self._button(number, False)

    def scroll(self, dx, dy):
        notches = self.wheel.add(dy)
        self._clicks(4 if notches > 0 else 5, notches)
        notches = self.hwheel.add(dx)
        self._clicks(7 if notches > 0 else 6, notches)
        self._flush()

    def down_keys(self):
        """Keycodes the X server has down right now (anyone's, the computer's own keyboard too)."""
        state = ctypes.create_string_buffer(32)
        self.x11.XQueryKeymap(self.dpy, state)
        return {i * 8 + bit for i, byte in enumerate(state.raw) for bit in range(8) if byte & (1 << bit)}

    def zoom(self, steps):
        ctrl = self._keycode("ctrl")
        # Like ZoomInput on Windows: a Control the user already holds is left alone.
        own = ctrl not in self.down_keys()
        if own:
            self._key(ctrl, True)
        try:
            self._clicks(4 if steps > 0 else 5, steps)
        finally:
            if own:
                self._key(ctrl, False)
            self._flush()

    def _tap(self, codes):
        try:
            for code in codes:
                self._key(code, True)
        finally:
            for code in reversed(codes):
                if code in self.held_keys:
                    self._key(code, False)
            self._flush()

    def press(self, name):
        self._tap([self._keycode(name)])

    def chord(self, keys, game=False):
        hotkey_codes(keys, US_KEYMAP, game)  # the uinput path's validation: modifiers once each, a known key
        codes = [self._keycode(k) for k in keys]
        if len(set(codes)) != len(codes):
            raise InputError("repeated key")
        self._tap(codes)

    def _hold_codes(self, keys, game=False):
        hotkey_codes(keys, US_KEYMAP, game)  # the uinput path's validation
        codes = [self._keycode(k) for k in keys]
        if len(set(codes)) != len(codes):
            raise InputError("repeated key")
        return codes

    def hold(self, keys, game=False):
        """key-down: modifiers then the key, left down (the X server keeps them)."""
        codes = self._hold_codes(keys, game)
        try:
            for code in codes:
                self._key(code, True)
            self.holds[hold_id(keys)] = codes
        finally:
            self._flush()

    def lift(self, keys, game=False):
        """key-up: the key then the modifiers, each only if the X server has it down. The
        keycodes key-down pressed when recorded (the X keymap may have changed since)."""
        # Recorded codes first: the current keymap is asked only when nothing was recorded
        # (it may no longer type that key at all).
        hotkey_codes(keys, US_KEYMAP, game)  # the names are checked whatever the X keymap is now
        codes = self.holds.pop(hold_id(keys), None) or self._hold_codes(keys, game)
        down = self.down_keys()
        for code in reversed(codes):
            if code in down or code in self.held_keys:
                self._key(code, False)
        self._flush()

    def _free_codes(self):
        """Keycodes with no keysyms at all, to borrow for characters the layout lacks."""
        if self.free is None:
            low, high = ctypes.c_int(), ctypes.c_int()
            self.x11.XDisplayKeycodes(self.dpy, ctypes.byref(low), ctypes.byref(high))
            per = ctypes.c_int()
            count = high.value - low.value + 1
            syms = self.x11.XGetKeyboardMapping(self.dpy, low.value, count, ctypes.byref(per))
            self.free = []
            if syms:
                try:
                    # From the top: the free codes sit above the real keys. A handful is plenty.
                    for i in range(count - 1, -1, -1):
                        if all(syms[i * per.value + j] == 0 for j in range(per.value)):
                            self.free.append(low.value + i)
                            if len(self.free) == 16:
                                break
                finally:
                    self.x11.XFree(syms)
        if not self.free:
            raise Untypeable("no free keycode to type this character")
        return self.free

    def _borrow(self, sym):
        """A free keycode bound to `sym`: one code per character, so a key already sent is never
        looked up against a newer binding; the oldest is rebound only past 16 at once."""
        for code, bound in self.borrowed.items():
            if bound == sym:
                return code
        free = self._free_codes()
        code = next((c for c in free if c not in self.borrowed), None)
        if code is None:
            code = next(iter(self.borrowed))
            del self.borrowed[code]
            time.sleep(0.05)  # its last key has been read before it changes
        pair = (ctypes.c_ulong * 2)(sym, sym)
        self.x11.XChangeKeyboardMapping(self.dpy, code, 2, pair, 1)
        self.x11.XSync(self.dpy, 0)
        self.borrowed[code] = sym
        time.sleep(0.02)  # let clients read the new mapping (MappingNotify) before the key arrives
        return code

    def text(self, text):
        """Each character's own key (level 1 or Shift) from the X server's keymap,
        whatever the layout; anything else on a borrowed keycode (as xdotool does),
        given back afterwards. Any Unicode character, no input method needed."""
        plan_text(text, US_KEYMAP)  # the same validation as uinput (raises on control characters)
        try:
            previous = ""
            for char in text:
                crlf = char == "\n" and previous == "\r"
                previous = char
                if crlf:
                    continue  # CRLF is one Enter, as in plan_text
                if char in "\r\n\t":
                    self.press("tab" if char == "\t" else "enter")
                    continue
                cp = ord(char)
                sym = cp if 0x20 <= cp <= 0x7E or 0xA0 <= cp <= 0xFF else 0x01000000 + cp
                code = self.x11.XKeysymToKeycode(self.dpy, sym)
                if code and code not in self.borrowed and self.x11.XKeycodeToKeysym(self.dpy, code, 0) == sym:
                    self._tap([code])
                elif code and code not in self.borrowed and self.x11.XKeycodeToKeysym(self.dpy, code, 1) == sym:
                    self._tap([self._keycode("shift"), code])
                else:
                    self._tap([self._borrow(sym)])
                    self.x11.XSync(self.dpy, 0)
                time.sleep(0.002)
        finally:
            self._give_back()

    def _give_back(self):
        if not self.borrowed:
            return
        time.sleep(0.05)
        none = (ctypes.c_ulong * 2)(0, 0)
        for code in list(self.borrowed):
            self.x11.XChangeKeyboardMapping(self.dpy, code, 2, none, 1)
        self.borrowed.clear()
        self.x11.XSync(self.dpy, 0)

    def release(self):
        self.holds.clear()
        for number in reversed(list(self.held_buttons)):
            self._button(number, False)
        for code in reversed(list(self.held_keys)):
            self._key(code, False)
        self._flush()

    def close(self):
        try:
            self.release()
            self._give_back()
        finally:
            self.x11.XCloseDisplay(self.dpy)


def open_backend(env, uinput_path=UINPUT_PATH, settle=None):
    """uinput first (X11 and Wayland), XTest on X11, else NoBackend("uinput" | "no-display")."""
    forced = env.get("DOKKI_INPUT_BACKEND", "")
    if not has_session(env) and forced != "uinput":
        # No desktop session around this process: never type into a text console.
        raise NoBackend("no-display")
    if forced != "xtest":
        try:
            device = UInputDevice(uinput_path)
        except NoBackend:
            if forced == "uinput":
                raise
        else:
            # The compositor (libinput) or X server opens a new device a moment after it appears.
            time.sleep(settle if settle is not None else int(env.get("DOKKI_UINPUT_SETTLE_MS", "350") or 350) / 1000)
            backend = UInputBackend(device, load_keymap(env))
            if is_x11(env):
                # X11 text through XTest even next to a uinput pointer: any character, no input method.
                try:
                    backend.x_text = XTestBackend(env)
                except NoBackend:
                    pass
            return backend
    if is_x11(env):
        return XTestBackend(env)
    raise NoBackend("uinput" if is_wayland(env) else "no-display")


def backend_text(backend, text):
    typist = getattr(backend, "x_text", None)
    if typist:
        backend.release()
        typist.text(text)
    else:
        backend.text(text)


# ---- focus (the phone keyboard's target) ----

class X11Focus:
    """The X input focus window, read with XGetInputFocus (metadata only)."""

    def __init__(self, env):
        self.x11 = ctypes.CDLL(ctypes.util.find_library("X11") or "libX11.so.6")
        self.x11.XOpenDisplay.restype, self.x11.XOpenDisplay.argtypes = ctypes.c_void_p, [ctypes.c_char_p]
        self.x11.XGetInputFocus.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong), ctypes.POINTER(ctypes.c_int)]
        self.x11.XQueryKeymap.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
        self.x11.XStringToKeysym.restype, self.x11.XStringToKeysym.argtypes = ctypes.c_ulong, [ctypes.c_char_p]
        self.x11.XKeysymToKeycode.restype, self.x11.XKeysymToKeycode.argtypes = ctypes.c_ubyte, [ctypes.c_void_p, ctypes.c_ulong]
        self.x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
        self.dpy = self.x11.XOpenDisplay((env.get("DISPLAY") or "").encode() or None)
        if not self.dpy:
            raise OSError("no display")
        names = ("Shift_L", "Shift_R", "Control_L", "Control_R", "Alt_L", "Alt_R", "Super_L", "Super_R", "ISO_Level3_Shift")
        self.modifier_codes = {self.x11.XKeysymToKeycode(self.dpy, self.x11.XStringToKeysym(n.encode())) for n in names} - {0}

    def __call__(self):
        window, revert = ctypes.c_ulong(), ctypes.c_int()
        self.x11.XGetInputFocus(self.dpy, ctypes.byref(window), ctypes.byref(revert))
        return str(window.value)

    def modifier_down(self):
        """A modifier held on the computer's keyboard, which would turn typed text into shortcuts."""
        state = ctypes.create_string_buffer(32)
        self.x11.XQueryKeymap(self.dpy, state)
        return any(state.raw[code // 8] & (1 << (code % 8)) for code in self.modifier_codes)

    def close(self):
        self.x11.XCloseDisplay(self.dpy)


class Focus:
    """Mints a target per focused window and field, like KeyboardFocus.cs. The reader
    (native/linux/dokki_focus.py) returns the window's id, or (identity, editable, password)
    when AT-SPI knows the focused field. Nothing known (a Wayland desktop without AT-SPI):
    one target for the session, editable and password false, and the phone opens its
    keyboard by hand."""

    def __init__(self, read_window=None, every=0.25, clock=time.monotonic):
        self.read_window = read_window or (lambda: "")
        self.window, self.revision, self.target, self.requested = None, 0, "", True
        self.editable = self.password = False
        self.every, self.clock, self.last = every, clock, None

    def modifier_down(self):
        """A modifier held on the computer (as on Windows and macOS): X11, and Wayland where AT-SPI may watch keys."""
        check = getattr(self.read_window, "modifier_down", None)
        try:
            return bool(check and check())
        except Exception:  # noqa: BLE001
            return False

    def close(self):
        closer = getattr(self.read_window, "close", None)
        if closer:
            closer()

    def refresh(self):
        self.requested = True
        again = getattr(self.read_window, "refresh", None)
        if again:
            again()

    def _read(self):
        """(identity, editable, password)."""
        try:
            value = self.read_window()
        except Exception:  # noqa: BLE001 - a focus read must never end the session
            return "", False, False
        if isinstance(value, tuple) and len(value) == 3:
            return str(value[0]), bool(value[1]), bool(value[2])
        return str(value or ""), False, False

    def _current(self):
        return self._read()[0]

    def poll(self):
        """The focus event to send now, or None (the focus is read every 250 ms at most)."""
        now = self.clock()
        if not self.requested and self.target and self.last is not None and now - self.last < self.every:
            return None
        self.last = now
        window, editable, password = self._read()
        changed = window != self.window or editable != self.editable or password != self.password
        if changed or not self.target:
            self.window, self.editable, self.password = window, editable, password
            self.revision += 1
            self.target = str(self.revision)
        if changed or self.requested:
            self.requested = False
            return {"type": "focus", "target": self.target, "editable": self.editable, "password": self.password}
        return None

    def accepts(self, target):
        return bool(self.target) and target == self.target and self._current() == self.window and not self.modifier_down()


def focus_reader(env, events=True, windows=True):
    """dokki_focus.FocusReader for this session (X11 / sway / Hyprland window, AT-SPI field), or None.
    windows=False: the AT-SPI field only (type-text's password check)."""
    here = os.path.dirname(os.path.realpath(__file__))
    if here not in sys.path:
        sys.path.append(here)
    try:
        import dokki_focus  # noqa: PLC0415 - next to this script
    except ImportError:
        return None
    window = None
    if not windows:
        pass
    elif is_x11(env):
        try:
            window = X11Focus(env)
        except OSError:
            window = None
    elif is_wayland(env):
        window = dokki_focus.compositor_window(env)
    atspi = dokki_focus.make_atspi(env, events=events)
    if window is None and atspi is None:
        return None
    return dokki_focus.FocusReader(window, atspi)


def focus_report(env):
    """`dokki-input focus`: the sources this session has and the focused field's kind (no ids, no text)."""
    reader = focus_reader(env)
    if reader is None:
        return {"window": None, "atspi": False, "editable": False, "password": False}
    try:
        identity, editable, password = reader()
        kind = identity.split(":", 1)[0] if ":" in identity.split("|", 1)[0] else ("x11" if reader.window is not None else None)
        return {"window": kind if reader.window is not None else None, "atspi": reader.atspi is not None, "editable": editable, "password": password}
    finally:
        reader.close()


def make_focus(env):
    reader = focus_reader(env)
    if reader is not None:
        return Focus(reader)
    if is_x11(env):
        try:
            return Focus(X11Focus(env))
        except OSError:
            pass
    return Focus()


# ---- the touchpad session ----

class Session:
    """Buttons held by this session only, like Touchpad.cs Session."""

    def __init__(self, backend):
        self.backend, self.left, self.right = backend, False, False

    def release(self):
        try:
            if self.left:
                self.backend.button("left", False)
            if self.right:
                self.backend.button("right", False)
        finally:
            self.left = self.right = False
            self.backend.release()

    def apply(self, message):
        kind = message.get("type")
        if kind == "ping":
            return
        if kind == "release":
            self.release()
        elif kind in ("move", "scroll"):
            dx, dy = message.get("dx"), message.get("dy")
            if not all(isinstance(v, int) and not isinstance(v, bool) and -2048 <= v <= 2048 for v in (dx, dy)):
                raise InputError("invalid movement")
            (self.backend.move if kind == "move" else self.backend.scroll)(dx, dy)
        elif kind == "zoom":
            steps = message.get("steps")
            if not isinstance(steps, int) or isinstance(steps, bool) or steps == 0 or not -8 <= steps <= 8:
                raise InputError("invalid zoom")
            if not self.left and not self.right:
                self.backend.zoom(steps)
        elif kind == "button":
            name, down = message.get("button"), message.get("down")
            if name not in ("left", "right") or not isinstance(down, bool):
                raise InputError("invalid button")
            if (self.left if name == "left" else self.right) != down:
                self.backend.button(name, down)
                if name == "left":
                    self.left = down
                else:
                    self.right = down
        # gestures (none on Linux yet) and types a newer host knows: ignored, like Touchpad.cs


def emit(out, value):
    out.write(json.dumps(value) + "\n")
    out.flush()


def handle_line(line, session, focus, out):
    if len(line) > 8192:
        raise InputError("input too large")
    message = json.loads(line)
    if not isinstance(message, dict):
        raise InputError("expected an object")
    kind = message.get("type")
    if kind == "focus":
        focus.refresh()
    elif kind in ("text", "key"):
        try:
            if not focus.accepts(message.get("target")):
                raise InputError("focus changed")
            session.release()
            if kind == "text":
                backend_text(session.backend, message.get("text"))
            else:
                if message.get("key") not in PRESS:
                    raise InputError("invalid key")
                session.backend.press(PRESS[message["key"]])
        except Exception:  # noqa: BLE001 - as Touchpad.Run.cs: tell the phone, keep the session
            emit(out, {"type": "input-error"})
            focus.refresh()
    else:
        session.apply(message)


def run_touchpad(backend, focus, stdin_fd=0, out=sys.stdout, idle=2.0):
    session = Session(backend)
    emit(out, {"type": "ready", "gestures": False})
    selector = selectors.DefaultSelector()
    selector.register(stdin_fd, selectors.EVENT_READ)
    buffer, last = b"", time.monotonic()
    try:
        while True:
            event = focus.poll()
            if event:
                emit(out, event)
            if selector.select(0.25):
                chunk = os.read(stdin_fd, 65536)
                if not chunk:
                    return 0
                buffer += chunk
                last = time.monotonic()
                if len(buffer) > 65536:
                    raise InputError("input too large")
                while b"\n" in buffer:
                    line, buffer = buffer.split(b"\n", 1)
                    if line.strip():
                        handle_line(line.decode("utf-8"), session, focus, out)
            elif time.monotonic() - last > idle:
                # Also releases buttons if Node stops forwarding the phone's heartbeat.
                return 0
    finally:
        try:
            session.release()
        finally:
            try:
                backend.close()
            finally:
                focus.close()


# ---- signatures ----

WIDTH, HEIGHT = 1600, 600
INK = {"black": (24, 24, 27), "blue": (30, 64, 175)}


def parse_signature(value):
    """{"color","strokes":[[[x,y],...],...]} in 0..1 -> (rgb, [[(px, py), ...], ...])."""
    if not isinstance(value, dict) or set(value) - {"color", "strokes"} or value.get("color") not in INK:
        raise InputError("invalid ink")
    strokes = value.get("strokes")
    if not isinstance(strokes, list) or not 1 <= len(strokes) <= 64:
        raise InputError("invalid strokes")
    lines, count = [], 0
    for stroke in strokes:
        if not isinstance(stroke, list) or not stroke:
            raise InputError("invalid stroke")
        count += len(stroke)
        if count > 2048:
            raise InputError("too many points")
        line = []
        for point in stroke:
            if not isinstance(point, list) or len(point) != 2 or not all(isinstance(n, (int, float)) and not isinstance(n, bool) and 0 <= n <= 1 for n in point):
                raise InputError("invalid point")
            line.append((point[0] * (WIDTH - 1), point[1] * (HEIGHT - 1)))
        lines.append(line)
    return INK[value["color"]], lines


def rasterize(lines, width=WIDTH, height=HEIGHT, radius=2.0):
    """Round-capped 4 px strokes, antialiased: one alpha byte a pixel."""
    alpha = bytearray(width * height)
    outer, inner = radius + 0.5, radius - 0.5
    solid = b"\xff" * width

    def span(ax, ay, bx, by, y, reach):
        """The x interval of row y within `reach` of segment AB (a capsule: convex), or None."""
        lo, hi = None, None
        for px, py in ((ax, ay), (bx, by)):
            dy = y - py
            if abs(dy) < reach:
                half = (reach * reach - dy * dy) ** 0.5
                lo = px - half if lo is None else min(lo, px - half)
                hi = px + half if hi is None else max(hi, px + half)
        vx, vy = bx - ax, by - ay
        length = (vx * vx + vy * vy) ** 0.5
        if length > 0 and vy != 0:
            nx, ny = -vy / length * reach, vx / length * reach
            corners = [(ax + nx, ay + ny), (bx + nx, by + ny), (bx - nx, by - ny), (ax - nx, ay - ny)]
            for (x1, y1), (x2, y2) in zip(corners, corners[1:] + corners[:1]):
                if (y1 - y) * (y2 - y) <= 0 and y1 != y2:
                    x = x1 + (y - y1) * (x2 - x1) / (y2 - y1)
                    lo = x if lo is None else min(lo, x)
                    hi = x if hi is None else max(hi, x)
        elif length > 0 and abs(y - ay) < reach:  # horizontal: the band is the whole run
            lo = min(ax, bx) if lo is None else min(lo, ax, bx)
            hi = max(ax, bx) if hi is None else max(hi, ax, bx)
        return None if lo is None else (lo, hi)

    def segment(ax, ay, bx, by):
        vx, vy = bx - ax, by - ay
        length = vx * vx + vy * vy
        for y in range(max(0, int(min(ay, by) - outer)), min(height - 1, int(max(ay, by) + outer + 1)) + 1):
            out = span(ax, ay, bx, by, y, outer)
            if out is None:
                continue
            row = y * width
            x0, x1 = max(0, int(out[0])), min(width - 1, int(out[1]) + 1)
            core = span(ax, ay, bx, by, y, inner) if inner > 0 else None
            c0, c1 = (max(x0, int(core[0]) + 1), min(x1, int(core[1]))) if core else (x1 + 1, x1)
            if c0 <= c1:
                alpha[row + c0:row + c1 + 1] = solid[:c1 - c0 + 1]  # the pen's solid middle, at C speed
            for x in list(range(x0, min(c0, x1 + 1))) + list(range(max(c1 + 1, x0), x1 + 1)):
                px, py = x - ax, y - ay
                t = 0.0 if length == 0 else max(0.0, min(1.0, (px * vx + py * vy) / length))
                dx, dy = px - t * vx, py - t * vy
                cover = outer - (dx * dx + dy * dy) ** 0.5
                if cover > 0:
                    a = 255 if cover >= 1 else int(cover * 255)
                    if a > alpha[row + x]:
                        alpha[row + x] = a

    for line in lines:
        if len(line) == 1:
            segment(line[0][0], line[0][1], line[0][0], line[0][1])
        for (ax, ay), (bx, by) in zip(line, line[1:]):
            segment(ax, ay, bx, by)
    return alpha


def encode_png(rgb, alpha, width=WIDTH, height=HEIGHT):
    """A transparent RGBA PNG, the ink's colour where the alpha says."""
    pixel = bytes(rgb) + b"\0"
    raw = bytearray()
    blank = b"\0" + b"\0" * (4 * width)
    for y in range(height):
        row = alpha[y * width:(y + 1) * width]
        if not any(row):
            raw += blank
            continue
        rgba = bytearray(pixel * width)
        rgba[3::4] = row
        raw += b"\0" + rgba

    def chunk(kind, data):
        return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)

    header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
    return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(bytes(raw), 6)) + chunk(b"IEND", b"")


def pictures_dir(env, read=_read):
    """XDG_PICTURES_DIR from user-dirs.dirs (a localized Bilder, Imágenes...), else ~/Pictures."""
    home = env.get("HOME") or os.path.expanduser("~")
    config = env.get("XDG_CONFIG_HOME") or os.path.join(home, ".config")
    match = re.search(r'^\s*XDG_PICTURES_DIR\s*=\s*"([^"\n]*)"', read(os.path.join(config, "user-dirs.dirs")), re.M)
    if match:
        value = match.group(1).replace("$HOME", home)
        if os.path.isabs(value) and value.rstrip("/") != home.rstrip("/") and ".." not in value.split("/"):
            return value
    return os.path.join(home, "Pictures")


def save_signature(png, env, now=None):
    folder = os.path.join(pictures_dir(env), "Dokki", "Signatures")
    os.makedirs(folder, mode=0o755, exist_ok=True)
    stamp = (now or datetime.datetime.now()).strftime("%Y-%m-%d-%H%M%S")
    path = os.path.join(folder, "Signature-%s-%s.png" % (stamp, secrets.token_hex(16)))
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0), 0o644)
    with os.fdopen(fd, "wb") as handle:
        handle.write(png)
    return path


def copy_png(png, env):
    """The PNG on the clipboard: wl-copy under Wayland, else xclip. False when neither works."""
    tools = []
    if is_wayland(env):
        tools.append(["wl-copy", "--type", "image/png"])
    if env.get("DISPLAY"):
        tools.append(["xclip", "-selection", "clipboard", "-t", "image/png", "-i"])
    for argv in tools:
        exe = shutil.which(argv[0], path=env.get("PATH"))
        if not exe:
            continue
        try:
            # Both fork a helper that keeps the selection: never hand it our pipes.
            if subprocess.run([exe] + argv[1:], input=png, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5, check=False).returncode == 0:
                return True
        except (OSError, subprocess.SubprocessError):
            continue
    return False


def read_stdin(limit=65536):
    data = b""
    while len(data) <= limit:
        chunk = os.read(0, limit + 1 - len(data))
        if not chunk:
            break
        data += chunk
    if len(data) > limit:
        raise InputError("signature too large")
    return data


def run_signature(env, insert, out=sys.stdout):
    focus = make_focus(env) if insert else None
    before = focus._current() if focus else None
    backend = None
    if insert:
        try:
            backend = open_backend(env)
        except NoBackend:
            backend = None
    try:
        rgb, lines = parse_signature(json.loads(read_stdin().decode("utf-8")))
        png = encode_png(rgb, rasterize(lines))
        path = save_signature(png, env)
        copied, pasted = False, False
        if insert:
            copied = copy_png(png, env)
            # Paste only into the window that had the focus when the phone asked (X11 can tell).
            if copied and backend and focus._current() == before and not focus.modifier_down():
                try:
                    backend.chord(["ctrl", "v"])
                    pasted = True
                except Exception:  # noqa: BLE001 - the PNG stays on disk and on the clipboard
                    pasted = False
        emit(out, {"type": "saved", "path": path, "name": os.path.basename(path), "width": WIDTH, "height": HEIGHT, "clipboard": copied, "pasteRequested": pasted})
        return 0
    finally:
        try:
            if backend:
                backend.close()
        finally:
            if focus:
                focus.close()


def run_once(env, verb, payload, opener=None, game=False, reader_factory=None):
    """game: hotkey/key-down/key-up for a game macro (bare keys); nothing else changes here."""
    if game and verb not in GAME_VERBS:
        raise InputError("game is for hotkey, key-down and key-up")
    if verb == "mouse-click":
        clicked = parse_click(payload)  # refused before any device is made
    else:
        try:
            value = json.loads(payload)
        except (TypeError, ValueError) as error:
            raise InputError("invalid JSON") from error
        if verb in GAME_VERBS:
            hotkey_codes(value, US_KEYMAP, game)  # refused before any device is made
        if verb == "type-text":
            # A password field is refused before any device is made, and nothing is typed.
            request = text_request(value)
            reader = (reader_factory or (lambda e: focus_reader(e, events=False, windows=False)))(env)
            try:
                refuse_password(reader, request.get("anyField", False))
            finally:
                if reader:
                    reader.close()
    # X11 text (and held keys) are XTest: no virtual device to make and wait for, and the
    # X server keeps a held key down after this process ends.
    backend = None
    if verb in ("type-text", "key-down", "key-up", "mouse-click") and is_x11(env) and env.get("DOKKI_INPUT_BACKEND") != "uinput":
        try:
            backend = XTestBackend(env)
        except NoBackend:
            backend = None  # no libXtst: the uinput keyboard, when Dokki has input access
    backend = backend or (opener or open_backend)(env)
    try:
        if verb == "mouse-click":
            click(backend, *clicked)
        elif verb == "hotkey":
            backend.chord(value, game)
        elif verb == "key-down":
            if not hasattr(backend, "held_keys"):
                # A uinput device (and every key it holds) goes with this process: only the
                # input daemon can hold a key there.
                hotkey_codes(value, US_KEYMAP, game)
                raise InputError("key-down needs the input daemon here")
            backend.hold(value, game)
            backend.held_keys = []  # handed to the X server: closing must not let go of them
        elif verb == "key-up":
            backend.lift(value, game)
        else:
            text_request(value)
            backend_text(backend, value.get("text"))
            if value.get("enter"):
                backend.press("enter")
        time.sleep(0.05)  # let the last events reach the compositor before the device goes away
        return 0
    finally:
        backend.close()


# ---- input-daemon (src/platform/input-daemon.js) ----

DAEMON_PROTOCOL = 1
DAEMON_VERBS = ("hotkey", "key-down", "key-up", "type-text", "release-all", "ping", "mouse-click", "pad-probe", "pad-state", "pad-close")
PAD_VERBS = ("pad-probe", "pad-state", "pad-close")
GAME_VERBS = ("hotkey", "key-down", "key-up")
DAEMON_MAX_LINE = 65536


def parse_daemon_request(line):
    """A request line -> (id, argv), argv None when it cannot be served (answered with 2);
    None when the line has no usable id (nothing to answer)."""
    if len(line) > DAEMON_MAX_LINE:
        return None
    try:
        message = json.loads(line)
    except ValueError:
        return None
    if not isinstance(message, dict):
        return None
    ident = message.get("id")
    if not isinstance(ident, int) or isinstance(ident, bool) or not 1 <= ident <= 9007199254740991:
        return None
    argv = message.get("argv")
    if (set(message) - {"id", "argv"} or not isinstance(argv, list) or not 1 <= len(argv) <= 4
            or not all(isinstance(a, str) and len(a) <= 16384 for a in argv) or argv[0] not in DAEMON_VERBS):
        return ident, None
    return ident, argv


def release_names(argv):
    """`release-all [<json names>]` -> the key names a crashed request may have left down."""
    if len(argv) > 2:
        raise InputError("usage: release-all [<json>]")
    if len(argv) == 1:
        return []
    names = json.loads(argv[1])
    if not isinstance(names, list) or len(names) > 16 or not all(isinstance(n, str) for n in names):
        raise InputError("expected key names")
    return [n.lower() for n in names if n.lower() in NAMED or (len(n) == 1 and n.lower() in US_KEYMAP)]


class InputDaemon:
    """The one-shot verbs' work (run_once) on backends kept open between requests: the uinput
    device is made (and settles) once, the X connection stays up. `opener`/`xtest` are for tests."""

    def __init__(self, env, opener=None, xtest=None, keymap_loader=None, pad_factory=None, pad_access=None, reader_factory=None):
        self.env = env
        self.opener = opener or open_backend
        self.xtest = xtest or XTestBackend
        self.keymap_loader = keymap_loader or load_keymap
        self.pad_factory = pad_factory or (lambda slot: UInputGamepad())
        self.pad_access = pad_access or pad_writable
        self.click_sleep = time.sleep
        self.backend, self.typist = None, None
        self.pads = {}  # slot -> UInputGamepad, plugged on its first pad-state
        self.reader_factory = reader_factory or (lambda env: focus_reader(env, events=False, windows=False))
        self.reader = None  # AT-SPI, kept between type-text requests (no events: nothing queues up)

    def _close_pad(self, slot):
        pad = self.pads.pop(slot, None)
        if pad:
            try:
                pad.close()
            except Exception:  # noqa: BLE001 - gone already
                pass

    def perform_pad(self, argv):
        """pad-probe / pad-state / pad-close: the gamepads only, never the keyboard/pointer device."""
        verb = argv[0]
        try:
            if verb == "pad-probe":
                if len(argv) != 1:
                    raise InputError("usage")
                return (0, "") if self.pads or self.pad_access() else (EXIT_PAD, PAD_UNWRITABLE)
            if verb == "pad-close":
                if len(argv) > 2:
                    raise InputError("usage")
                for slot in [parse_pad_slot(argv[1])] if len(argv) == 2 else sorted(self.pads):
                    self._close_pad(slot)
                return 0, ""
            if len(argv) != 3:
                raise InputError("usage")
            slot, state = parse_pad_slot(argv[1]), parse_pad_state(argv[2])
        except InputError as error:
            return EXIT_INVALID, "invalid: %s" % error
        pad = self.pads.get(slot)
        if pad is None:
            try:
                pad = self.pad_factory(slot)
            except PadUnavailable as error:
                return EXIT_PAD, str(error)
            except Exception as error:  # noqa: BLE001
                return EXIT_PAD, "failed: %s" % error
            self.pads[slot] = pad
        try:
            pad.submit(state)
        except Exception as error:  # noqa: BLE001 - this pad is broken: a fresh one next time
            self._close_pad(slot)
            return 1, "failed: %s" % error
        return 0, ""

    def _main(self):
        if self.backend is None:
            self.backend = self.opener(self.env)
        return self.backend

    def _xtest(self):
        """Every X connection this daemon holds (each must read its own MappingNotify)."""
        found = [self.typist, self.backend, getattr(self.backend, "x_text", None)]
        return [b for b in found if b and hasattr(b, "refresh_mapping")]

    def _text(self):
        # As run_once: X11 text is always XTest (no virtual device to make and wait for).
        if is_x11(self.env) and self.env.get("DOKKI_INPUT_BACKEND") != "uinput":
            if self.typist is None and getattr(self.backend, "x_text", None):
                return self.backend  # its uinput device types X11 text through its own XTest connection
            if self.typist is None:
                try:
                    self.typist = self.xtest(self.env)
                except NoBackend:
                    self.typist = False  # no libXtst: the uinput keyboard
            if self.typist:
                return self.typist
        return self._main()

    def perform(self, argv):
        """-> (exit code, stderr line): exactly what `dokki-input <verb>` would end with."""
        if argv[0] in PAD_VERBS:
            return self.perform_pad(argv)
        try:
            for connection in self._xtest():
                connection.refresh_mapping()
            verb = argv[0]
            if verb == "ping":
                return (0, "") if len(argv) == 1 else (EXIT_INVALID, "invalid: usage")
            if verb == "release-all":
                names = release_names(argv)
                # What a key-down left down on this daemon's own device or X connection.
                for backend in (self.backend, self.typist):
                    if backend:
                        backend.release()
                # A uinput device goes with its process (the kernel lifts its keys); X keeps XTest's.
                if is_x11(self.env):
                    connections = self._xtest()
                    if not connections:
                        try:
                            self.typist = self.xtest(self.env)
                            connections = [self.typist]
                        except NoBackend:
                            connections = []
                    if connections:
                        connections[0].release_down(names)  # the X server's key state is shared
                return 0, ""
            if len(argv) == 3 and verb in GAME_VERBS:
                if argv[2] != "game":
                    raise InputError("usage: %s <json> [game]" % verb)
            elif len(argv) != 2:
                raise InputError("usage: %s <json>" % verb)
            if verb == "mouse-click":
                click(self._main(), *parse_click(argv[1]), sleep=self.click_sleep)
                return 0, ""
            try:
                value = json.loads(argv[1])
            except ValueError as error:
                raise InputError("invalid JSON") from error
            game = len(argv) == 3  # checked above: exactly "game"
            if verb == "hotkey":
                self._main().chord(value, game)
            elif verb == "key-down":
                self._main().hold(value, game)
            elif verb == "key-up":
                self._main().lift(value, game)
            else:
                text_request(value)
                if self.reader is None:
                    self.reader = self.reader_factory(self.env) or False
                refuse_password(self.reader, value.get("anyField", False))
                backend = self._text()
                backend_text(backend, value.get("text"))
                if value.get("enter"):
                    backend.press("enter")
            return 0, ""
        except NoBackend as error:
            return EXIT_NO_BACKEND, "no-backend: %s" % error
        except Untypeable as error:
            return EXIT_UNTYPEABLE, "untypeable: %s" % error
        except PasswordField:
            return EXIT_PASSWORD, "password-field"
        except UnknownFocus:
            return EXIT_UNKNOWN_FOCUS, "unknown-focus"
        except (InputError, ValueError, UnicodeDecodeError) as error:
            return EXIT_INVALID, "invalid: %s" % error
        except Exception as error:  # noqa: BLE001 - a device or display gone: start over next time
            self._close_backends()  # the keyboard/pointer side only: the pads stay plugged
            return 1, "failed: %s" % error

    def refresh_keymap(self):
        """While idle: the layout may have changed (uinput looks characters up in it)."""
        if self.backend is not None and hasattr(self.backend, "keymap"):
            try:
                self.backend.keymap = self.keymap_loader(self.env)
            except Exception:  # noqa: BLE001 - keep the old one
                pass

    def close(self):
        for slot in sorted(self.pads):
            self._close_pad(slot)
        self._close_backends()
        if self.reader:
            try:
                self.reader.close()
            except Exception:  # noqa: BLE001
                pass
        self.reader = None

    def _close_backends(self):
        for backend in (self.typist, self.backend):
            if backend:
                try:
                    backend.close()
                except Exception:  # noqa: BLE001
                    pass
        self.backend, self.typist = None, None


def run_daemon(env, stdin_fd=0, out=sys.stdout, daemon=None, idle_refresh=10.0):
    daemon = daemon or InputDaemon(env)
    emit(out, {"type": "ready", "protocol": DAEMON_PROTOCOL, "verbs": list(DAEMON_VERBS)})
    selector = selectors.DefaultSelector()
    selector.register(stdin_fd, selectors.EVENT_READ)
    buffer, refreshed = b"", True

    def stop(signum, frame):
        raise SystemExit(0)  # the finally below: keys and buttons let go, pads neutral and unplugged
    previous = {}
    try:
        for signum in (signal.SIGTERM, signal.SIGHUP):
            previous[signum] = signal.signal(signum, stop)
    except ValueError:
        pass  # not the main thread: nothing to install
    try:
        while True:
            if not selector.select(idle_refresh):
                if not refreshed:
                    daemon.refresh_keymap()
                    refreshed = True
                continue
            chunk = os.read(stdin_fd, 65536)
            if not chunk:
                return 0
            buffer += chunk
            while b"\n" in buffer:
                line, buffer = buffer.split(b"\n", 1)
                try:
                    request = parse_daemon_request(line.decode("utf-8"))
                except UnicodeDecodeError:
                    request = None
                if request is None:
                    continue
                ident, argv = request
                code, message = daemon.perform(argv) if argv else (EXIT_INVALID, "invalid: request")
                emit(out, {"id": ident, "code": code, **({"error": message} if code else {})})
                refreshed = False
            if len(buffer) > DAEMON_MAX_LINE:
                buffer = b""  # an oversized line: dropped (the host times it out)
    finally:
        try:
            daemon.close()
        finally:
            for signum, handler in previous.items():
                signal.signal(signum, handler)


def probe(env, uinput_path=UINPUT_PATH):
    if not has_session(env):
        return {"backend": None, "reason": "no-display"}
    if os.access(uinput_path, os.W_OK):
        return {"backend": "uinput"}
    if is_x11(env):
        return {"backend": "xtest"}
    return {"backend": None, "reason": "uinput" if is_wayland(env) else "no-display"}


def main(argv, env=None):
    env = dict(os.environ if env is None else env)
    verb = argv[1] if len(argv) > 1 else ""
    try:
        if verb == "touchpad" and len(argv) == 2:
            return run_touchpad(open_backend(env, settle=None if env.get("DOKKI_UINPUT_SETTLE_MS") else 0.15), make_focus(env))
        if verb in ("signature", "signature-insert") and len(argv) == 2:
            return run_signature(env, verb == "signature-insert")
        if verb in ("hotkey", "key-down", "key-up", "type-text", "mouse-click") and len(argv) == 3:
            return run_once(env, verb, argv[2])
        if verb in GAME_VERBS and len(argv) == 4 and argv[3] == "game":
            return run_once(env, verb, argv[2], game=True)
        if verb == "input-daemon" and len(argv) == 2:
            return run_daemon(env)
        if verb == "probe" and len(argv) == 2:
            emit(sys.stdout, probe(env))
            return 0
        if verb == "focus" and len(argv) == 2:
            emit(sys.stdout, focus_report(env))
            return 0
        sys.stderr.write("usage: dokki-input touchpad|signature|signature-insert|type-text <json>|hotkey <json> [game]|key-down <json> [game]|key-up <json> [game]|mouse-click <json>|input-daemon|probe|focus\n")
        return EXIT_INVALID
    except NoBackend as error:
        sys.stderr.write("no-backend: %s\n" % error)
        return EXIT_NO_BACKEND
    except Untypeable as error:
        sys.stderr.write("untypeable: %s\n" % error)
        return EXIT_UNTYPEABLE
    except PasswordField:
        sys.stderr.write("password-field\n")
        return EXIT_PASSWORD
    except UnknownFocus:
        sys.stderr.write("unknown-focus\n")
        return EXIT_UNKNOWN_FOCUS
    except (InputError, ValueError, UnicodeDecodeError) as error:
        sys.stderr.write("invalid: %s\n" % error)
        return EXIT_INVALID


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