#!/usr/bin/env python3
"""VGS helper CLI.

Self-contained runtime helper for VanillaGreen Shell.  This replaces the old
the old external helper fallback and external theme pipeline for the workstation
integration used by this repo.
"""
from __future__ import annotations

import argparse
import base64
import colorsys
import contextlib
import errno
import fcntl
import glob
import grp
import hashlib
import json
import math
import os
import pwd
import re
import signal
import shlex
import shutil
import stat
import subprocess
import sys
import threading
import time
import tempfile
import urllib.parse
import urllib.request
import tomllib
import mimetypes
from collections import Counter
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Tuple

_HELPER_MODULE_DIR = str(Path(__file__).resolve().parent)
if _HELPER_MODULE_DIR not in sys.path:
    sys.path.insert(0, _HELPER_MODULE_DIR)

_NIRI_SUPPORT: Any = None
import vshell_theme_color as _theme_color

try:
    from PIL import Image
except Exception:  # pragma: no cover - handled at runtime
    Image = None  # type: ignore

HEX_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
TEMPLATE_RE = re.compile(r"\{([A-Za-z0-9_]+)(?:\.(strip|rgb))?\}")
ANSI_NAMES = [
    "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
    "bright_black", "bright_red", "bright_green", "bright_yellow", "bright_blue", "bright_magenta", "bright_cyan", "bright_white",
]
MATUGEN_SCHEMES = {
    "scheme-tonal-spot", "scheme-content", "scheme-expressive", "scheme-fidelity",
    "scheme-fruit-salad", "scheme-monochrome", "scheme-neutral", "scheme-rainbow", "scheme-vibrant",
}
THEME_MODES = {"auto", "dark", "light"}
COLOR_KEYS = {
    "background", "bg", "foreground", "fg", "accent", "primary", "cursor",
    "selection_background", "selectionbackground", "selection_foreground", "selectionforeground",
    "theme_type", "mode", "variant", "scheme",
    *{f"color{i}" for i in range(16)}, *ANSI_NAMES,
}
CAMEL = {
    "bright_black": "brightBlack",
    "bright_red": "brightRed",
    "bright_green": "brightGreen",
    "bright_yellow": "brightYellow",
    "bright_blue": "brightBlue",
    "bright_magenta": "brightMagenta",
    "bright_cyan": "brightCyan",
    "bright_white": "brightWhite",
}
DEFAULT_COLORS = [
    "#32344a", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#ad8ee6", "#449dab", "#787c99",
    "#444b6a", "#ff7a93", "#b9f27c", "#ff9e64", "#7da6ff", "#bb9af7", "#0db9d7", "#acb0d0",
]
GREETER_RUNTIME_BIN_FILES = {
    "vshell": 0o750,
    "vshell-helper": 0o750,
    "vshell_theme_color.py": 0o640,
    "vshell_niri.py": 0o640,
    "vshell_niri_kdl.py": 0o640,
}


def eprint(*args: Any) -> None:
    print(*args, file=sys.stderr)


def run(cmd: List[str], check: bool = False, **kwargs: Any) -> subprocess.CompletedProcess[str]:
    return subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=check, **kwargs)


def repo_root() -> Path:
    return Path(__file__).resolve().parents[1]


def home() -> Path:
    sudo_user = os.environ.get("SUDO_USER", "").strip()
    if os.geteuid() == 0 and sudo_user and sudo_user != "root":
        try:
            return Path(pwd.getpwnam(sudo_user).pw_dir)
        except Exception:
            pass
    return Path.home()


def cfg_dir() -> Path:
    return home() / ".config" / "vshell"


def state_dir() -> Path:
    return home() / ".local" / "state" / "vshell"


def cache_dir() -> Path:
    return home() / ".cache" / "vshell"


def generated_dir() -> Path:
    return cfg_dir() / "generated"


def local_cfg_dir() -> Path:
    return home() / ".config" / "vshell-local"


def user_blueprints_dir() -> Path:
    return cfg_dir() / "blueprints"


def builtin_blueprints_dir() -> Path:
    return repo_root() / "themes" / "blueprints"


def user_themes_dir() -> Path:
    return cfg_dir() / "themes"


def builtin_themes_dir() -> Path:
    return repo_root() / "themes"


def targets_dir() -> Path:
    return repo_root() / "themes" / "targets"


_THEME_MUTATION_THREAD_LOCK = threading.RLock()
_THEME_MUTATION_LOCK_DEPTH = 0
_THEME_MUTATION_LOCK_FD: int | None = None


@contextlib.contextmanager
def theme_mutation_lock() -> Iterable[None]:
    """Serialize theme mutations across helper processes.

    Theme commands commonly compose user overlays, change one field, and write
    the complete file back. Atomic replacement protects readers from partial
    files, but without a transaction lock two QML/CLI helpers can still both
    read the same old value and silently discard one another's edits. Keep one
    process-wide flock for the outermost mutation and make nested apply calls
    reentrant so command-level transactions can safely call apply_theme_obj().
    """
    global _THEME_MUTATION_LOCK_DEPTH, _THEME_MUTATION_LOCK_FD

    with _THEME_MUTATION_THREAD_LOCK:
        if _THEME_MUTATION_LOCK_DEPTH == 0:
            lock_dir = cfg_dir()
            lock_dir.mkdir(parents=True, exist_ok=True)
            flags = os.O_CREAT | os.O_RDWR | getattr(os, "O_CLOEXEC", 0)
            if hasattr(os, "O_NOFOLLOW"):
                flags |= os.O_NOFOLLOW
            fd = os.open(lock_dir / ".theme-mutation.lock", flags, 0o600)
            try:
                os.fchmod(fd, 0o600)
                fcntl.flock(fd, fcntl.LOCK_EX)
            except BaseException:
                os.close(fd)
                raise
            _THEME_MUTATION_LOCK_FD = fd

        _THEME_MUTATION_LOCK_DEPTH += 1
        try:
            yield
        finally:
            _THEME_MUTATION_LOCK_DEPTH -= 1
            if _THEME_MUTATION_LOCK_DEPTH == 0:
                fd = _THEME_MUTATION_LOCK_FD
                _THEME_MUTATION_LOCK_FD = None
                if fd is not None:
                    try:
                        fcntl.flock(fd, fcntl.LOCK_UN)
                    finally:
                        os.close(fd)


def deps_file() -> Path:
    return repo_root() / "config" / "vshell" / "dependencies.json"


APPLE_VENDOR = "05ac"

# Apple displays expose brightness through a USB HID "monitor control" feature
# report (VESA MCCS brightness, report id 1, 32-bit little-endian value) rather
# than /sys/class/backlight -- unless the in-kernel `appledisplay` module claims
# them, in which case they also appear as a backlight and VGS prefers that.
#
# `min`/`max` are the raw HID logical range in centi-nits (the descriptor sets
# unit cd/m^2 with exponent -2), read from each display's report descriptor:
# XDR declares 400..50000 (4..500.00 nits, its SDR ceiling), Studio Display
# declares 400..60000 (4..600.00 nits). nikosdion/asdcontrol#6's "practical
# ceiling of 50000" is simply the XDR's declared max; the panel accepts and
# stores larger values but clamps physical output at its own maximum, so
# `set 100` writes the descriptor max and reaches the panel's true maximum.
# The control interface number is NOT hardcoded -- VGS probes every HID
# interface the display exposes and keeps the one that answers a brightness
# read in range.
APPLE_DISPLAYS: Dict[str, Dict[str, Any]] = {
    "9243": {"alias": "apple-xdr", "label": "Apple Pro Display XDR", "min": 400, "max": 50000},
    "1114": {"alias": "apple-studio", "label": "Apple Studio Display", "min": 400, "max": 60000},
}

# Probe tolerance ceiling for the brightness-read range gate. A display can
# store a raw value above its descriptor max (e.g. an XDR that was written
# 60000 before per-display maxima landed) and still be the right interface,
# so the gate discriminates against garbage from *other* interfaces using the
# largest Apple ceiling rather than the per-display max.
APPLE_RAW_PROBE_CEILING = 60000

# Human Thunderbolt device_name -> USB product id, so `doctor` can report that an
# Apple display is present over Thunderbolt/DisplayPort while its USB control
# interface never enumerated (video tunnel up, USB tunnel absent -> no backend
# can reach brightness).
APPLE_TB_NAMES: Dict[str, str] = {
    "pro display xdr": "9243",
    "studio display": "1114",
}


def resolve_path(value: str | None) -> str:
    if not value:
        return ""
    value = value.replace("${VSHELL_ROOT}", str(repo_root()))
    value = os.path.expandvars(value)
    if value.startswith("~"):
        value = str(home()) + value[1:]
    return value


def expand_dest(value: str) -> Path:
    return Path(resolve_path(value)).expanduser()


def ensure_dirs() -> None:
    for p in [cfg_dir(), local_cfg_dir(), state_dir(), cache_dir(), generated_dir(), user_blueprints_dir(), user_themes_dir()]:
        p.mkdir(parents=True, exist_ok=True)


def clean_hex(value: str | None, fallback: str = "#000000") -> str:
    if not value:
        return fallback.lower()
    value = value.strip().strip('"').strip("'")
    if not value:
        return fallback.lower()
    if not value.startswith("#"):
        value = "#" + value
    if not HEX_RE.match(value):
        return fallback.lower()
    return value.lower()


def parse_hex_strict(value: str, label: str = "color") -> str:
    raw = (value or "").strip().strip('"').strip("'")
    if not raw.startswith("#"):
        raw = "#" + raw
    if not HEX_RE.match(raw):
        raise ValueError(f"invalid {label}: {value}")
    return raw.lower()


def strip_hash(value: str) -> str:
    return clean_hex(value).lstrip("#")


def rgb(value: str) -> Tuple[int, int, int]:
    h = clean_hex(value).lstrip("#")
    return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)


def hexc(r: float, g: float, b: float) -> str:
    return "#%02x%02x%02x" % (max(0, min(255, round(r))), max(0, min(255, round(g))), max(0, min(255, round(b))))


def blend(a: str, b: str, ratio: float) -> str:
    ar, ag, ab = rgb(a)
    br, bg, bb = rgb(b)
    return hexc(ar * (1 - ratio) + br * ratio, ag * (1 - ratio) + bg * ratio, ab * (1 - ratio) + bb * ratio)


def luminance(value: str) -> float:
    r, g, b = [x / 255.0 for x in rgb(value)]
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


def relative_luminance(value: str) -> float:
    def channel(v: int) -> float:
        x = v / 255.0
        return x / 12.92 if x <= 0.04045 else ((x + 0.055) / 1.055) ** 2.4
    r, g, b = [channel(v) for v in rgb(value)]
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


def contrast_ratio(a: str, b: str) -> float:
    la = relative_luminance(a)
    lb = relative_luminance(b)
    high = max(la, lb)
    low = min(la, lb)
    return (high + 0.05) / (low + 0.05)


def ensure_contrast(fg: str, bg: str, min_ratio: float = 4.5, prefer: str | None = None) -> str:
    fg = clean_hex(fg)
    bg = clean_hex(bg)
    if contrast_ratio(fg, bg) >= min_ratio:
        return fg
    targets = []
    if prefer:
        targets.append(clean_hex(prefer))
    targets.extend(sorted(["#000000", "#ffffff"], key=lambda c: contrast_ratio(c, bg), reverse=True))
    best = fg
    best_ratio = contrast_ratio(fg, bg)
    for target in targets:
        for step in range(1, 21):
            candidate = blend(fg, target, step / 20.0)
            ratio = contrast_ratio(candidate, bg)
            if ratio > best_ratio:
                best = candidate
                best_ratio = ratio
            if ratio >= min_ratio:
                return candidate
    return best


def saturation(value: str) -> float:
    r, g, b = [x / 255.0 for x in rgb(value)]
    return colorsys.rgb_to_hsv(r, g, b)[1]


def hue(value: str) -> float:
    r, g, b = [x / 255.0 for x in rgb(value)]
    return colorsys.rgb_to_hsv(r, g, b)[0] * 360.0


def lighten(value: str, amount: float) -> str:
    r, g, b = [x / 255.0 for x in rgb(value)]
    h, l, s = colorsys.rgb_to_hls(r, g, b)
    l = min(1.0, l + (1.0 - l) * amount)
    rr, gg, bb = colorsys.hls_to_rgb(h, l, s)
    return hexc(rr * 255, gg * 255, bb * 255)


def darken(value: str, amount: float) -> str:
    r, g, b = [x / 255.0 for x in rgb(value)]
    h, l, s = colorsys.rgb_to_hls(r, g, b)
    l = max(0.0, l * (1.0 - amount))
    rr, gg, bb = colorsys.hls_to_rgb(h, l, s)
    return hexc(rr * 255, gg * 255, bb * 255)


def clamp(value: float, lo: float, hi: float) -> float:
    return max(lo, min(hi, value))


def normalize_scheme(value: str | None) -> str:
    scheme = (value or "scheme-tonal-spot").strip().lower().replace("_", "-")
    if not scheme.startswith("scheme-"):
        scheme = "scheme-" + scheme
    return scheme if scheme in MATUGEN_SCHEMES else "scheme-tonal-spot"


def normalize_contrast(value: float | int | str | None) -> float:
    try:
        raw = float(value if value is not None else 0)
    except Exception:
        raw = 0.0
    # theme extraction may expose -1..1; tolerate percent sliders too.
    if abs(raw) > 1:
        raw = raw / 100.0
    return clamp(raw, -1.0, 1.0)


def normalize_mode(value: str | None, default: str = "auto") -> str:
    mode = (value or default or "auto").strip().lower()
    return mode if mode in THEME_MODES else default


def readable_on(value: str) -> str:
    return "#000000" if contrast_ratio("#000000", value) >= contrast_ratio("#ffffff", value) else "#ffffff"


def color_chroma(value: str) -> float:
    r, g, b = rgb(value)
    return (max(r, g, b) - min(r, g, b)) / 255.0


def color_hue(value: str) -> float | None:
    r, g, b = [x / 255.0 for x in rgb(value)]
    mx, mn = max(r, g, b), min(r, g, b)
    d = mx - mn
    if d < 1e-6:
        return None
    if mx == r:
        h = ((g - b) / d) % 6
    elif mx == g:
        h = (b - r) / d + 2
    else:
        h = (r - g) / d + 4
    return h * 60.0


def _hue_distance(a: float, b: float) -> float:
    d = abs(a - b) % 360.0
    return min(d, 360.0 - d)


_theme_color.configure(_theme_color.ThemeColorRuntime(
    clean_hex=clean_hex,
    rgb=rgb,
    hexc=hexc,
    clamp=clamp,
    contrast_ratio=contrast_ratio,
    ensure_contrast=ensure_contrast,
))
color_to_oklab = _theme_color.color_to_oklab
_oklch_max_chroma = _theme_color._oklch_max_chroma
_bounded_lightness = _theme_color._bounded_lightness
_relative_oklch = _theme_color._relative_oklch
_map_oklch_lightness = _theme_color._map_oklch_lightness
oklch_to_hex = _theme_color.oklch_to_hex
_oklab_contrast_adjust = _theme_color._oklab_contrast_adjust


def ensure_usable_accent(accent: str, bg: str, palette: Dict[str, str], prefer: str,
                         min_contrast: float = 3.0, continuous: bool = False) -> str:
    """The shell uses `accent` (Theme.primary) as a FOREGROUND for active
    indicators, selected items, and icons. A palette whose accent sits on (or too
    near) its own background — or is achromatic — makes those elements invisible
    or unreadable (e.g. arc-raiders sets accent == background, so icons render
    black on the theme's purple-tinted surfaces). When the given accent can't work
    as a UI accent, pick a palette color that reads against the background,
    preferring the same hue family so the theme keeps its identity. This is a
    derived *shell* role, so it applies to curated palettes too without touching
    their terminal ANSI colors."""
    if continuous:
        # Restyle sliders must not make the derived accent jump between unrelated
        # ANSI swatches as a contrast/chroma threshold is crossed.
        return _oklab_contrast_adjust(accent, bg, min_contrast, prefer)
    if contrast_ratio(accent, bg) >= 2.5 and color_chroma(accent) >= 0.10:
        return accent
    candidates: List[Tuple[str, float]] = []
    for key in ("blue", "cyan", "magenta", "green", "yellow", "red",
                "brightBlue", "brightCyan", "brightMagenta", "brightGreen",
                "brightYellow", "brightRed"):
        candidate = palette.get(key)
        if not candidate:
            continue
        cr = contrast_ratio(candidate, bg)
        if cr >= min_contrast:
            candidates.append((candidate, cr))
    if not candidates:
        return ensure_contrast(accent, bg, min_contrast, prefer)

    def vividness(pair: Tuple[str, float]) -> float:
        c, cr = pair
        return color_chroma(c) * min(cr, 7.0)

    # If the original accent has a clear hue, keep the theme on-brand by preferring
    # a readable color in that hue family before falling back to the most vivid.
    orig_hue = color_hue(accent) if color_chroma(accent) >= 0.15 else None
    if orig_hue is not None:
        near = [p for p in candidates
                if color_hue(p[0]) is not None and _hue_distance(color_hue(p[0]), orig_hue) <= 45.0]
        if near:
            return max(near, key=vividness)[0]
    return max(candidates, key=vividness)[0]


def ensure_background_supports_text(bg: str, mode: str, min_ratio: float = 7.0) -> str:
    bg = clean_hex(bg)
    if max(contrast_ratio("#000000", bg), contrast_ratio("#ffffff", bg)) >= min_ratio:
        return bg
    target = "#ffffff" if mode == "light" else "#000000"
    best = bg
    best_ratio = max(contrast_ratio("#000000", bg), contrast_ratio("#ffffff", bg))
    for step in range(1, 21):
        candidate = blend(bg, target, step / 20.0)
        ratio = max(contrast_ratio("#000000", candidate), contrast_ratio("#ffffff", candidate))
        if ratio > best_ratio:
            best = candidate
            best_ratio = ratio
        if ratio >= min_ratio:
            return candidate
    return best


def ensure_contrast_set(value: str, checks: List[Tuple[str, float]], targets: List[str], steps: int = 100) -> str:
    """Move a color minimally until it satisfies every contrast pair.

    ANSI colors are used both as foregrounds and backgrounds by TUIs. The normal
    foreground-on-terminal-background checks are not enough for structural pairs
    such as Claude Code light-ansi `30` on `47` or dark-ansi `97` on `100`.
    """
    value = clean_hex(value)
    normalized = [(clean_hex(other), ratio) for other, ratio in checks if ratio > 0]
    if not normalized:
        return value

    def meets(candidate: str) -> bool:
        return all(contrast_ratio(candidate, other) >= ratio for other, ratio in normalized)

    def score(candidate: str) -> float:
        return min(contrast_ratio(candidate, other) / ratio for other, ratio in normalized)

    if meets(value):
        return value

    best = value
    best_score = score(value)
    for target in targets:
        target = clean_hex(target)
        for step in range(1, steps + 1):
            candidate = blend(value, target, step / steps)
            candidate_score = score(candidate)
            if candidate_score > best_score:
                best = candidate
                best_score = candidate_score
            if meets(candidate):
                return candidate
    return best


def move_toward_while_contrast(value: str, target: str, checks: List[Tuple[str, float]], steps: int = 100) -> str:
    """Move a color toward a target until any required contrast would break."""
    value = clean_hex(value)
    target = clean_hex(target)
    normalized = [(clean_hex(other), ratio) for other, ratio in checks if ratio > 0]
    best = value
    for step in range(1, steps + 1):
        candidate = blend(value, target, step / steps)
        if not all(contrast_ratio(candidate, other) >= ratio for other, ratio in normalized):
            return best
        best = candidate
    return best


def cap_saturation(value: str, max_sat: float) -> str:
    value = clean_hex(value)
    r, g, b = [x / 255.0 for x in rgb(value)]
    h, l, s = colorsys.rgb_to_hls(r, g, b)
    s = min(s, max_sat)
    rr, gg, bb = colorsys.hls_to_rgb(h, l, s)
    return hexc(rr * 255, gg * 255, bb * 255)


def set_ansi_role(roles: Dict[str, str], index: int, name: str, value: str) -> None:
    value = clean_hex(value)
    roles[f"color{index}"] = value
    roles[name] = value


def stabilize_ansi_role_pairs(roles: Dict[str, str], bg: str, mode: str) -> None:
    """Anchor the 16-color ANSI palette for foreground and background use.

    Modern TUIs use both normal backgrounds (40-47) and bright backgrounds
    (100-107). Do not force every ANSI slot to be a readable foreground on the
    terminal default background; instead keep normal colors background-capable,
    bright colors high-intensity, and neutral endpoints sane for both polarities.
    """
    normal_colors = [(1, "red"), (2, "green"), (3, "yellow"), (4, "blue"), (5, "magenta"), (6, "cyan")]
    bright_colors = [(9, "bright_red"), (10, "bright_green"), (11, "bright_yellow"), (12, "bright_blue"), (13, "bright_magenta"), (14, "bright_cyan")]

    if mode == "light":
        black = ensure_contrast_set(cap_saturation(roles["black"], 0.12), [(bg, 7.0)], ["#000000"])
        set_ansi_role(roles, 0, "black", black)

        bright_black = ensure_contrast_set(cap_saturation(roles["bright_black"], 0.10), [(bg, 4.5)], ["#000000"])
        set_ansi_role(roles, 8, "bright_black", bright_black)

        white = ensure_contrast_set(
            cap_saturation(roles["white"], 0.10),
            [(black, 4.5), (bright_black, 3.0), (bg, 1.5)],
            ["#ffffff"],
        )
        white = move_toward_while_contrast(
            white,
            "#ffffff",
            [(bg, 1.5), (black, 4.5), (bright_black, 3.0)],
        )
        set_ansi_role(roles, 7, "white", white)

        bright_white = ensure_contrast_set(
            cap_saturation(roles["bright_white"], 0.08),
            [(black, 4.5), (bg, 1.05)],
            ["#ffffff"],
        )
        bright_white = move_toward_while_contrast(bright_white, "#ffffff", [(black, 4.5), (bg, 1.05)])
        set_ansi_role(roles, 15, "bright_white", bright_white)

        for index, name in normal_colors:
            set_ansi_role(roles, index, name, ensure_contrast_set(roles[name], [(bg, 4.5), (white, 4.5)], ["#000000"]))

        for index, name in bright_colors:
            set_ansi_role(roles, index, name, ensure_contrast_set(roles[name], [(bg, 3.0), (black, 4.5)], ["#ffffff"]))
        return

    bright_white = ensure_contrast_set(cap_saturation(roles["bright_white"], 0.08), [(bg, 7.0)], ["#ffffff"])
    bright_white = move_toward_while_contrast(bright_white, "#ffffff", [(bg, 7.0)])
    set_ansi_role(roles, 15, "bright_white", bright_white)

    black = ensure_contrast_set(cap_saturation(roles["black"], 0.12), [(bright_white, 4.5), (bg, 1.25)], [bg, "#000000"])
    black = move_toward_while_contrast(black, bg, [(bright_white, 4.5), (bg, 1.25)])
    set_ansi_role(roles, 0, "black", black)

    white = ensure_contrast_set(
        cap_saturation(roles["white"], 0.10),
        [(bg, 4.5), (black, 3.0)],
        ["#ffffff"],
    )
    white = move_toward_while_contrast(white, "#ffffff", [(bg, 4.5), (black, 3.0)])
    set_ansi_role(roles, 7, "white", white)

    bright_black = ensure_contrast_set(
        cap_saturation(roles["bright_black"], 0.10),
        [(bright_white, 4.5), (white, 3.0), (bg, 1.25)],
        [bg, "#000000"],
    )
    bright_black = move_toward_while_contrast(bright_black, bg, [(bright_white, 4.5), (white, 3.0), (bg, 1.5)])
    set_ansi_role(roles, 8, "bright_black", bright_black)

    for index, name in normal_colors:
        set_ansi_role(roles, index, name, ensure_contrast_set(roles[name], [(bg, 3.0), (white, 4.5)], ["#000000"]))

    for index, name in bright_colors:
        set_ansi_role(roles, index, name, ensure_contrast_set(roles[name], [(bg, 4.5), (black, 4.5)], ["#ffffff"]))


def rotate_color(value: str, degrees: float, sat_mul: float = 1.0, val_mul: float = 1.0) -> str:
    r, g, b = [x / 255.0 for x in rgb(value)]
    h, s, v = colorsys.rgb_to_hsv(r, g, b)
    h = ((h * 360.0 + degrees) % 360.0) / 360.0
    s = clamp(s * sat_mul, 0.0, 1.0)
    v = clamp(v * val_mul, 0.0, 1.0)
    rr, gg, bb = colorsys.hsv_to_rgb(h, s, v)
    return hexc(rr * 255, gg * 255, bb * 255)


def tune_color(value: str, sat_mul: float = 1.0, val_mul: float = 1.0) -> str:
    return rotate_color(value, 0, sat_mul=sat_mul, val_mul=val_mul)


# --- Whole-palette restyle adjustments -----------------------------------------
# The color-space and whole-palette implementation lives in the focused module;
# these aliases preserve the helper's stable Python/CLI surface for callers.
ADJUST_KEYS = _theme_color.ADJUST_KEYS
ADJUST_RANGE = _theme_color.ADJUST_RANGE
BASE_COLOR_KEYS = _theme_color.BASE_COLOR_KEYS
normalize_adjustments = _theme_color.normalize_adjustments
adjustments_all_zero = _theme_color.adjustments_all_zero
apply_adjustments = _theme_color.apply_adjustments


def normalize_color_map(data: Dict[str, str]) -> Dict[str, str]:
    out: Dict[str, str] = {}
    items = list(data.items())

    def assign(candidate: str, value: str) -> bool:
        ccompact = candidate.replace("_", "")
        if candidate in COLOR_KEYS:
            out.setdefault(candidate, value)
            return True
        if ccompact in COLOR_KEYS:
            out.setdefault(ccompact, value)
            return True
        return False

    # Pass 1: exact key matches claim their slot first. An explicit
    # `background = ...` must win the `background` slot before a compound UI key
    # (e.g. `active_tab_background`, `selection_background`) can alias onto it via
    # the fuzzy last-token fallback in pass 2. Without this precedence, file order
    # plus setdefault let the wrong value stick, so dark themes carrying an
    # `active_tab_background` rendered with a light window background.
    for key, value in items:
        norm = key.replace("-", "_").lower()
        if norm in {"theme_type", "mode", "variant", "scheme"}:
            out[norm] = value
        assign(norm, value)

    # Pass 2: fuzzy fallbacks fill only the slots left unclaimed above. Handles
    # matugen/alacritty nesting flattened into single keys such as
    # `colors_primary_background` or `colors_normal_red`.
    for key, value in items:
        norm = key.replace("-", "_").lower()
        candidates: List[str] = []
        for prefix in ("colors_", "palette_", "ansi_"):
            if norm.startswith(prefix):
                candidates.append(norm[len(prefix):])
        parts = norm.split("_")
        if len(parts) >= 3:
            candidates.append("_".join(parts[-2:]))
        if len(parts) >= 2:
            candidates.append(parts[-1])
        for candidate in candidates:
            if assign(candidate, value):
                break

    if "selectionbackground" in out and "selection_background" not in out:
        out["selection_background"] = out["selectionbackground"]
    if "selectionforeground" in out and "selection_foreground" not in out:
        out["selection_foreground"] = out["selectionforeground"]
    if "variant" in out and "mode" not in out:
        out["mode"] = out["variant"]
    return out


def recognized_color_count(data: Dict[str, str]) -> int:
    normalized = normalize_color_map(data)
    return len([k for k in normalized if k in COLOR_KEYS and k not in {"theme_type", "mode", "variant", "scheme"}])


def parse_colors_toml(path: Path) -> Dict[str, str]:
    if not path.exists():
        raise ValueError(f"colors file not found: {path}")

    raw_text = path.read_text(errors="ignore")

    def flatten(prefix: str, value: Any, out: Dict[str, str]) -> None:
        if isinstance(value, dict):
            for k, v in value.items():
                flatten(f"{prefix}_{k}" if prefix else str(k), v, out)
            return
        key = prefix
        if isinstance(value, str):
            val = value.strip()
            if HEX_RE.match(val):
                out[key] = clean_hex(val)
            elif key.replace("-", "_").lower() in {"theme_type", "mode", "variant", "scheme"}:
                out[key] = val.lower()

    out: Dict[str, str] = {}
    toml_error: Exception | None = None
    try:
        parsed = tomllib.loads(raw_text)
        flatten("", parsed, out)
    except Exception as exc:
        toml_error = exc

    # Also support loose matugen-style variants that are close to TOML.
    if not out:
        for raw in raw_text.splitlines():
            m = re.match(r"^\s*([A-Za-z0-9_\-.]+)\s*=\s*['\"]?((?:#)?[0-9A-Fa-f]{6})['\"]?", raw)
            if m:
                out[m.group(1)] = clean_hex(m.group(2))
            else:
                sm = re.match(r"^\s*([A-Za-z0-9_\-.]+)\s*=\s*['\"]([^'\"]+)['\"]", raw)
                if sm and sm.group(1).replace("-", "_").lower() in {"theme_type", "mode", "variant", "scheme"}:
                    out[sm.group(1)] = sm.group(2).lower()

    normalized = normalize_color_map(out)
    if recognized_color_count(normalized) == 0:
        detail = f": {toml_error}" if toml_error else ""
        raise ValueError(f"no recognized colors in {path}{detail}")
    return normalized


def palette_from_colors_map(data: Dict[str, str], name: str = "imported", wallpaper: str = "", source: str = "generated") -> Dict[str, Any]:
    data = normalize_color_map(data)
    curated = source == "curated"
    colors: List[str] = []
    for i in range(16):
        candidates = [f"color{i}"]
        if i < len(ANSI_NAMES):
            candidates.append(ANSI_NAMES[i])
        val = ""
        for key in candidates:
            if key in data:
                val = data[key]
                break
        colors.append(clean_hex(val, DEFAULT_COLORS[i]))

    bg = clean_hex(data.get("background") or data.get("bg") or colors[0], colors[0])
    mode = (data.get("theme_type") or data.get("mode") or ("light" if luminance(bg) > 0.5 else "dark")).lower()
    if mode not in {"dark", "light"}:
        mode = "light" if luminance(bg) > 0.5 else "dark"
    fg_prefer = "#000000" if mode == "light" else "#ffffff"
    fg = clean_hex(data.get("foreground") or data.get("fg") or colors[7], colors[7])
    accent = clean_hex(data.get("accent") or data.get("primary") or colors[4], colors[4])

    if not curated:
        # Generated/imported palettes get normalize + contrast; curated data is
        # kept byte-exact and only missing values fall back to role mapping.
        bg = ensure_background_supports_text(bg, mode, 7.0)
        fg = ensure_contrast(fg, bg, 7.0, fg_prefer)
        accent = ensure_contrast(accent, bg, 3.0, fg_prefer)
        adjusted_colors: List[str] = []
        for i, color in enumerate(colors):
            minimum = 4.5 if i in {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} else 3.0
            adjusted_colors.append(ensure_contrast(color, bg, minimum, fg_prefer))
        colors = adjusted_colors
        colors[7] = ensure_contrast(colors[7], bg, 4.5, fg_prefer)
        colors[8] = ensure_contrast(colors[8], bg, 4.5, fg_prefer)
        colors[15] = fg

    selection_bg = clean_hex(data.get("selection_background") or data.get("selectionBackground") or blend(accent, bg, 0.25), accent)
    selection_fg = clean_hex(data.get("selection_foreground") or data.get("selectionForeground") or readable_on(selection_bg), readable_on(selection_bg))
    cursor = clean_hex(data.get("cursor") or accent, accent)
    if not curated:
        selection_bg = ensure_contrast(selection_bg, bg, 1.6, fg_prefer)
        selection_fg = ensure_contrast(selection_fg, selection_bg, 4.5)
        cursor = ensure_contrast(cursor, bg, 3.0, fg_prefer)
    extended = {
        "background": bg,
        "foreground": fg,
        "accent": accent,
        "cursor": cursor,
        "selection_background": selection_bg,
        "selection_foreground": selection_fg,
    }
    return {
        "name": name,
        "source": source if source in {"curated", "generated"} else "generated",
        "palette": {
            "colors": colors,
            "wallpaper": wallpaper,
            "mode": mode,
            "lightMode": mode == "light",
            "extendedColors": extended,
            "wallpaperSource": "vshell",
        },
        "timestamp": int(time.time() * 1000),
    }


def quantize_wallpaper(path: Path, limit: int = 16) -> List[str]:
    if Image is None:
        raise RuntimeError("Pillow is required for wallpaper palette extraction")
    img = Image.open(path).convert("RGB")
    img.thumbnail((220, 220))
    quantized = img.quantize(colors=max(16, limit * 3), method=Image.Quantize.MEDIANCUT)
    palette = quantized.getpalette() or []
    pixels = quantized.get_flattened_data() if hasattr(quantized, "get_flattened_data") else quantized.getdata()
    counts = Counter(pixels)
    ranked: List[Tuple[int, str]] = []
    for idx, count in counts.most_common(64):
        off = idx * 3
        if off + 2 >= len(palette):
            continue
        c = hexc(palette[off], palette[off + 1], palette[off + 2])
        ranked.append((count, c))
    # de-dupe near-identical colors by coarse RGB buckets
    seen = set()
    colors: List[str] = []
    for _count, color in ranked:
        r, g, b = rgb(color)
        bucket = (r // 24, g // 24, b // 24)
        if bucket in seen:
            continue
        seen.add(bucket)
        colors.append(color)
    return colors or DEFAULT_COLORS


def wallpaper_average_luminance(path: Path) -> float:
    if Image is None:
        return 0.0
    img = Image.open(path).convert("RGB")
    img.thumbnail((96, 96))
    raw_pixels = img.get_flattened_data() if hasattr(img, "get_flattened_data") else img.getdata()
    pixels = list(raw_pixels)
    if not pixels:
        return 0.0
    total = 0.0
    for r, g, b in pixels:
        total += 0.2126 * (r / 255.0) + 0.7152 * (g / 255.0) + 0.0722 * (b / 255.0)
    return total / len(pixels)


def pick_hue(candidates: List[str], lo: float, hi: float, fallback: str, prefer_light: bool = False) -> str:
    def in_range(h: float) -> bool:
        if lo <= hi:
            return lo <= h < hi
        return h >= lo or h < hi
    pool = [c for c in candidates if saturation(c) > 0.18 and in_range(hue(c))]
    if not pool:
        return fallback
    return sorted(pool, key=lambda c: (abs(luminance(c) - (0.62 if prefer_light else 0.48)), -saturation(c)))[0]


def blueprint_from_wallpaper(path: Path, name: str = "wallpaper", scheme: str = "scheme-tonal-spot", contrast: float = 0.0, mode: str = "auto") -> Dict[str, Any]:
    if not path.exists():
        raise ValueError(f"wallpaper not found: {path}")
    scheme = normalize_scheme(scheme)
    contrast = normalize_contrast(contrast)
    mode = normalize_mode(mode)
    cols = quantize_wallpaper(path, 16)
    darks = [c for c in cols if luminance(c) < 0.35]
    lights = [c for c in cols if luminance(c) > 0.62]
    saturated = sorted(cols, key=lambda c: (saturation(c), abs(luminance(c) - 0.52)), reverse=True)
    raw_dark = min(darks or cols, key=lambda c: luminance(c))
    raw_light = max(lights or cols, key=lambda c: luminance(c))
    base_accent = saturated[0] if saturated else DEFAULT_COLORS[4]
    average_luma = wallpaper_average_luminance(path)
    if mode == "auto":
        # White/bright wallpapers should propose light mode instead of forcing a dark shell
        # from a single tiny dark pixel. Dark wallpapers still produce dark palettes.
        mode = "light" if average_luma > 0.58 and luminance(raw_light) > 0.62 else "dark"

    bg_source = raw_dark
    fg_source = raw_light
    if mode == "dark" and luminance(bg_source) > 0.45:
        bg_source = tune_color(base_accent, sat_mul=0.65, val_mul=0.42)
    if mode == "light" and luminance(fg_source) < 0.55:
        fg_source = tune_color(base_accent, sat_mul=0.24, val_mul=1.15)

    contrast_pos = max(0.0, contrast)
    contrast_neg = max(0.0, -contrast)
    if mode == "dark":
        bg = darken(bg_source, 0.18 + contrast_pos * 0.18)
        fg = lighten(fg_source, 0.12 + contrast_pos * 0.10)
        if luminance(bg) > 0.30:
            bg = darken(bg, 0.45)
        if luminance(fg) < 0.68:
            fg = lighten(fg, 0.45)
        if contrast_neg:
            bg = blend(bg, fg, contrast_neg * 0.18)
            fg = blend(fg, bg, contrast_neg * 0.12)
    else:
        bg = lighten(fg_source, 0.20 + contrast_pos * 0.10)
        fg = darken(bg_source, 0.24 + contrast_pos * 0.14)
        if luminance(bg) < 0.82:
            bg = lighten(bg, 0.35)
        if luminance(fg) > 0.36:
            fg = darken(fg, 0.45)
        if contrast_neg:
            bg = blend(bg, fg, contrast_neg * 0.15)
            fg = blend(fg, bg, contrast_neg * 0.14)

    sat_boost = clamp(1.0 + contrast * 0.18, 0.72, 1.24)
    val_boost = clamp(1.0 + contrast * 0.08, 0.84, 1.12)
    accent = tune_color(base_accent, sat_mul=sat_boost, val_mul=val_boost)

    # theme extraction surface: scheme controls color-theory/style.
    if scheme == "scheme-content" or scheme == "scheme-fidelity":
        red = pick_hue(cols, 345, 25, rotate_color(accent, 145))
        green = pick_hue(cols, 80, 165, rotate_color(accent, -120))
        yellow = pick_hue(cols, 35, 75, rotate_color(accent, 70), True)
        blue = pick_hue(cols, 185, 255, accent)
        magenta = pick_hue(cols, 255, 345, rotate_color(accent, -45))
        cyan = pick_hue(cols, 165, 205, rotate_color(accent, 35))
    elif scheme == "scheme-monochrome":
        red = green = yellow = blue = magenta = cyan = tune_color(accent, sat_mul=0.05, val_mul=0.92)
    elif scheme == "scheme-neutral":
        red = rotate_color(accent, 150, sat_mul=0.25)
        green = rotate_color(accent, -120, sat_mul=0.25)
        yellow = rotate_color(accent, 75, sat_mul=0.25)
        blue = tune_color(accent, sat_mul=0.35)
        magenta = rotate_color(accent, -45, sat_mul=0.30)
        cyan = rotate_color(accent, 35, sat_mul=0.30)
    elif scheme == "scheme-vibrant":
        red = rotate_color(accent, 150, sat_mul=1.45, val_mul=1.08)
        green = rotate_color(accent, -120, sat_mul=1.45, val_mul=1.08)
        yellow = rotate_color(accent, 70, sat_mul=1.35, val_mul=1.12)
        blue = tune_color(accent, sat_mul=1.5, val_mul=1.10)
        magenta = rotate_color(accent, -45, sat_mul=1.45, val_mul=1.08)
        cyan = rotate_color(accent, 35, sat_mul=1.45, val_mul=1.08)
    elif scheme == "scheme-expressive":
        accent = rotate_color(accent, 240, sat_mul=1.12, val_mul=1.04)
        red = rotate_color(accent, 115)
        green = rotate_color(accent, 210)
        yellow = rotate_color(accent, 55)
        blue = accent
        magenta = rotate_color(accent, -70)
        cyan = rotate_color(accent, 80)
    elif scheme == "scheme-fruit-salad":
        accent = rotate_color(accent, -50, sat_mul=1.15)
        red = rotate_color(accent, 95)
        green = rotate_color(accent, -80)
        yellow = rotate_color(accent, 45)
        blue = rotate_color(accent, 160)
        magenta = rotate_color(accent, -140)
        cyan = rotate_color(accent, -35)
    elif scheme == "scheme-rainbow":
        red = rotate_color(accent, 0)
        yellow = rotate_color(accent, 60)
        green = rotate_color(accent, 120)
        cyan = rotate_color(accent, 180)
        blue = rotate_color(accent, 240)
        magenta = rotate_color(accent, 300)
    else:  # scheme-tonal-spot
        red = rotate_color(accent, 145, sat_mul=0.75)
        green = rotate_color(accent, -120, sat_mul=0.68)
        yellow = rotate_color(accent, 70, sat_mul=0.70, val_mul=1.08)
        blue = tune_color(accent, sat_mul=0.78)
        magenta = rotate_color(accent, -45, sat_mul=0.72)
        cyan = rotate_color(accent, 35, sat_mul=0.70)

    if mode == "light":
        black = ensure_contrast(darken(fg, 0.08), bg, 4.5, "#000000")
        bright_black = ensure_contrast(blend(fg, bg, 0.32), bg, 4.5, "#000000")
        white = ensure_contrast(blend(fg, bg, 0.20), bg, 4.5, "#000000")
    else:
        black = ensure_contrast(darken(bg, 0.05), bg, 3.0, "#ffffff")
        bright_black = ensure_contrast(lighten(bg, clamp(0.28 + contrast * 0.10, 0.16, 0.40)), bg, 4.5, "#ffffff")
        white = ensure_contrast(blend(fg, bg, 0.25), bg, 4.5, "#ffffff")
    bright_white = ensure_contrast(fg, bg, 7.0, "#000000" if mode == "light" else "#ffffff")
    ansi = [
        black, red, green, yellow, blue, magenta, cyan, white,
        bright_black, lighten(red, 0.25), lighten(green, 0.25), lighten(yellow, 0.18), lighten(blue, 0.25), lighten(magenta, 0.25), lighten(cyan, 0.25), bright_white,
    ]
    data = {f"color{i}": c for i, c in enumerate(ansi)}
    data.update({
        "background": bg,
        "foreground": fg,
        "accent": accent,
        "cursor": accent,
        "selection_background": blend(accent, bg, 0.20),
        "selection_foreground": readable_on(accent),
        "mode": mode,
    })
    bp = palette_from_colors_map(data, name=name, wallpaper=str(path))
    bp["palette"]["wallpaperSource"] = "extracted"
    bp["palette"]["scheme"] = scheme
    bp["palette"]["contrast"] = contrast
    bp["palette"]["modePreference"] = mode
    bp["palette"]["averageLuminance"] = round(average_luma, 4)
    return bp


def blueprint_paths() -> List[Path]:
    ensure_dirs()
    paths: List[Path] = []
    for directory in [builtin_blueprints_dir(), user_blueprints_dir()]:
        if not directory.exists():
            continue
        paths.extend(sorted(directory.glob("*.json")))
    return paths


def load_blueprint(path: Path) -> Dict[str, Any]:
    bp = json.loads(path.read_text())
    bp.setdefault("name", path.stem)
    bp.setdefault("timestamp", int(path.stat().st_mtime * 1000))
    pal = bp.setdefault("palette", {})
    if pal.get("wallpaper"):
        pal["wallpaper"] = resolve_path(str(pal["wallpaper"]))
    bp["path"] = str(path)
    bp["builtin"] = str(path).startswith(str(builtin_blueprints_dir()))
    return bp


def list_themes() -> List[Dict[str, Any]]:
    """All themes: v2 packages plus legacy v1 blueprints (packages shadow by name)."""
    by_name: Dict[str, Dict[str, Any]] = {}
    for path in blueprint_paths():
        try:
            bp = load_blueprint(path)
            prior = by_name.get(bp["name"])
            if prior and prior.get("pair") and not bp.get("pair"):
                # User overrides shadow builtins by name; keep builtin pairing metadata.
                bp["pair"] = prior["pair"]
            if prior and prior.get("source") and not bp.get("source"):
                bp["source"] = prior["source"]
            by_name[bp["name"]] = bp
        except Exception as exc:
            eprint(f"skip blueprint {path}: {exc}")
    for name in theme_package_names():
        pkg = load_theme_package(name)
        if not pkg:
            continue
        prior = by_name.get(pkg["name"])
        if prior and prior.get("pair") and not pkg.get("pair"):
            pkg["pair"] = prior["pair"]
        by_name[pkg["name"]] = pkg
    return sorted(by_name.values(), key=lambda b: (b.get("timestamp", 0), b.get("name", "")), reverse=True)


def find_theme(name: str) -> Dict[str, Any] | None:
    lname = name.strip().lower()
    if not lname:
        return None
    themes = list_themes()
    for bp in themes:
        if bp.get("name", "").lower() == lname or Path(bp.get("path", "")).stem.lower() == lname:
            return bp
    for bp in themes:
        if lname in bp.get("name", "").lower():
            return bp
    return None


# Name conventions tried when a blueprint has no explicit `pair` metadata:
# swap or strip a mode suffix, or append the target mode as a suffix.
MODE_SUFFIX_SWAPS = {
    "dark": [("-light", "-dark"), ("-light", ""), ("-day", "")],
    "light": [("-dark", "-light"), ("-dark", ""), ("", "-day")],
}


def blueprint_mode(bp: Dict[str, Any]) -> str:
    pal = bp.get("palette", {})
    mode = (pal.get("mode") or ("light" if pal.get("lightMode") else "dark") or "dark").lower()
    return mode if mode in {"dark", "light"} else "dark"


def paired_blueprint(base: Dict[str, Any], target_mode: str) -> Dict[str, Any] | None:
    """Resolve the counterpart blueprint of `base` in `target_mode`.

    Explicit `pair` metadata wins; otherwise try common name-suffix conventions.
    Returns None when no existing blueprint of the target mode matches.
    """
    name = str(base.get("name") or "")
    candidates: List[str] = []
    explicit = str(base.get("pair") or "").strip()
    if explicit:
        candidates.append(explicit)
    lname = name.lower()
    for old, new in MODE_SUFFIX_SWAPS.get(target_mode, []):
        if not old:
            candidates.append(lname + new)
        elif lname.endswith(old):
            candidates.append(lname[: -len(old)] + new)
    candidates.append(f"{lname}-{target_mode}")
    seen = set()
    for candidate in candidates:
        key = candidate.lower()
        if not key or key == lname or key in seen:
            continue
        seen.add(key)
        bp = find_theme(candidate)
        if bp and blueprint_mode(bp) == target_mode:
            return bp
    return None



# --- Theme packages (v2 directory format) --------------------------------------
#
# A theme is a directory: theme.json (metadata), colors.toml (base palette),
# backgrounds/ (wallpapers, first alphabetical = default), optional preview.png,
# and apps/ (curated per-app configs that win over template generation).
# Built-ins live in themes/<name>/, user themes in ~/.config/vshell/themes/<name>/;
# a user directory overlays the built-in one file-by-file (user file wins).

# `catalog-previews/` holds the shipped screenshots of themes that are not
# installed (see the download catalog below); it is not a theme package.
RESERVED_THEME_SUBDIRS = {"blueprints", "targets", "wallpapers", "catalog-previews"}


def theme_package_names() -> List[str]:
    names = set()
    for root in (builtin_themes_dir(), user_themes_dir()):
        if not root.is_dir():
            continue
        for meta in root.glob("*/theme.json"):
            if meta.parent.name not in RESERVED_THEME_SUBDIRS:
                names.add(meta.parent.name)
    return sorted(names)


def compose_theme_files(name: str) -> Dict[str, Path]:
    """File-level compose of a theme package: built-in files first, user files win."""
    files: Dict[str, Path] = {}
    for root in (builtin_themes_dir() / name, user_themes_dir() / name):
        if not root.is_dir():
            continue
        for path in sorted(root.rglob("*")):
            if path.is_file():
                files[path.relative_to(root).as_posix()] = path
    return files


def load_theme_package(name: str) -> Dict[str, Any] | None:
    files = compose_theme_files(name)
    meta_path = files.get("theme.json")
    if not meta_path:
        return None
    try:
        meta = json.loads(meta_path.read_text())
    except Exception as exc:
        eprint(f"skip theme package {name}: {exc}")
        return None
    source = str(meta.get("source") or "curated").strip().lower()
    if source not in {"curated", "generated"}:
        source = "curated"
    hidden = {h for h in (meta.get("hiddenBackgrounds") or []) if isinstance(h, str)}
    backgrounds = sorted((rel, p) for rel, p in files.items() if rel.startswith("backgrounds/") and Path(rel).name not in hidden)
    wallpaper = ""
    default_bg = str(meta.get("wallpaper") or "").strip()
    if default_bg:
        wallpaper = next((str(p) for rel, p in backgrounds if Path(rel).name == default_bg), "")
    if not wallpaper:
        wallpaper = str(backgrounds[0][1]) if backgrounds else ""
    colors: Dict[str, str] = {}
    if "colors.toml" in files:
        try:
            colors = parse_colors_toml(files["colors.toml"])
        except Exception as exc:
            eprint(f"theme package {name}: {exc}")
    if meta.get("mode") in {"dark", "light"}:
        colors["mode"] = meta["mode"]
    # Restyle adjustments transform the base palette before role derivation and
    # stay non-destructive (colors.toml is never rewritten). All-zero is a no-op.
    adjustments = normalize_adjustments(meta.get("adjustments"))
    if not adjustments_all_zero(adjustments):
        colors = apply_adjustments(colors, adjustments)
    bp = palette_from_colors_map(colors, name=str(meta.get("name") or name), wallpaper=wallpaper, source=source)
    builtin_dir = builtin_themes_dir() / name
    user_dir = user_themes_dir() / name
    bp["pair"] = str(meta.get("pair") or "")
    bp["package"] = True
    bp["builtin"] = builtin_dir.is_dir()
    bp["path"] = str(builtin_dir if builtin_dir.is_dir() else user_dir)
    bp["userDir"] = str(user_dir) if user_dir.is_dir() else ""
    bp["apps"] = {Path(rel).name: str(p) for rel, p in files.items() if rel.startswith("apps/")}
    bp["backgrounds"] = [str(p) for _rel, p in backgrounds]
    bp["packagedPreview"] = str(files["preview.png"]) if "preview.png" in files else ""
    bp["adjustments"] = adjustments
    bp["modified"] = builtin_dir.is_dir() and user_dir.is_dir() and any(p.is_file() for p in user_dir.rglob("*"))
    bp["appOverrides"] = {app: len(roles) for app, roles in _parse_app_overrides_text(
        files["app-colors.toml"].read_text(errors="ignore") if "app-colors.toml" in files else "").items()}
    stamps = [meta_path.stat().st_mtime]
    if "colors.toml" in files:
        stamps.append(files["colors.toml"].stat().st_mtime)
    bp["timestamp"] = int(max(stamps) * 1000)
    return bp


def resolve_theme_package(name: str) -> Dict[str, Any] | None:
    """Resolve a theme-package blueprint by name, defaulting to the current theme."""
    target = (name or "").strip() or str(current_theme().get("name") or "")
    bp = find_theme(target)
    if not bp or not bp.get("package"):
        return None
    return bp


# --- Theme download catalog ----------------------------------------------------
#
# The `core` package bundle ships one theme (packaging/install-system.sh), so
# without a catalog the theme browser browses a single entry. `themes/catalog.json`
# (generated by scripts/gen-theme-catalog.py) lists every published theme with its
# palette and a per-file sha256 manifest, and `core` also ships every theme's
# screenshot under themes/catalog-previews/, so the browser can show what is not
# installed yet. Downloads land in the ordinary user theme dir.
#
# Safety rules for anything fetched here:
# - the manifest is local, pinned to a git ref, and every byte is checked against
#   the committed sha256 before it reaches the theme dir;
# - only https (a file:// base URL is accepted solely from the test-only
#   VGS_THEME_CATALOG_BASE_URL override);
# - only the closed set of theme-package paths below, so a manifest can never ask
#   for a write outside the theme's own directory;
# - nothing downloaded is ever executed — theme packages are data, applied through
#   the same path as built-ins.

CATALOG_ALLOWED_TOP_LEVEL = {"theme.json", "colors.toml", "preview.png"}
CATALOG_ALLOWED_DIRS = ("apps/", "backgrounds/")
CATALOG_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
CATALOG_MAX_FILE_BYTES = 128 * 1024 * 1024
CATALOG_TIMEOUT = 60
CATALOG_MARKER = ".vgs-catalog.json"


def theme_catalog_path() -> Path:
    return builtin_themes_dir() / "catalog.json"


def theme_catalog_previews_dir() -> Path:
    return builtin_themes_dir() / "catalog-previews"


def load_theme_catalog() -> Dict[str, Any]:
    path = theme_catalog_path()
    if not path.is_file():
        return {}
    try:
        data = json.loads(path.read_text())
    except Exception as exc:
        eprint(f"theme catalog unreadable: {exc}")
        return {}
    return data if isinstance(data, dict) else {}


def theme_catalog_base_urls(catalog: Dict[str, Any]) -> Tuple[List[str], bool]:
    """(base URLs to try in order, whether non-https schemes are allowed).

    More than one location, because the checksums are generated from the working
    tree while the primary ref is a release tag: between releases a theme edited
    on `main` is served correctly only by the moving ref. Trying a second
    location cannot weaken anything — the committed size and sha256 remain the
    sole authority for what is accepted, so a wrong location simply fails to
    match and the next one is tried. The override is test-only.
    """
    override = os.environ.get("VGS_THEME_CATALOG_BASE_URL", "").strip()
    if override:
        return [override.rstrip("/")], True
    source = catalog.get("source") or {}
    urls = [str(u).rstrip("/") for u in (source.get("baseUrls") or []) if str(u).strip()]
    primary = str(source.get("baseUrl") or "").rstrip("/")
    if primary and primary not in urls:
        urls.insert(0, primary)
    return urls, False


def catalog_theme_entry(catalog: Dict[str, Any], name: str) -> Dict[str, Any] | None:
    for entry in catalog.get("themes") or []:
        if isinstance(entry, dict) and entry.get("name") == name:
            return entry
    return None


def _catalog_check_name(name: str) -> str:
    clean = (name or "").strip()
    if not CATALOG_NAME_RE.match(clean) or clean in RESERVED_THEME_SUBDIRS:
        raise ValueError(f"invalid theme name: {name!r}")
    return clean


def _catalog_check_relpath(rel: str) -> str:
    """Validate one manifest path as untrusted input. Every rule here is load-bearing."""
    if not isinstance(rel, str) or not rel or rel != rel.strip():
        raise ValueError(f"invalid catalog path: {rel!r}")
    if rel.startswith("/") or "\\" in rel or "\x00" in rel:
        raise ValueError(f"unsafe catalog path: {rel!r}")
    parts = rel.split("/")
    if len(parts) > 2:
        raise ValueError(f"catalog path nests too deep: {rel!r}")
    for part in parts:
        # Rejects "", ".", ".." and every dotfile at ANY position — a leading-dot
        # test on the whole string would let `apps/.hidden` through.
        if not part or part.startswith("."):
            raise ValueError(f"unsafe catalog path component {part!r} in {rel!r}")
    if rel not in CATALOG_ALLOWED_TOP_LEVEL and not rel.startswith(CATALOG_ALLOWED_DIRS):
        raise ValueError(f"catalog path outside the theme package shape: {rel!r}")
    return rel


def _catalog_fetch(url: str, allow_local: bool, max_bytes: int = CATALOG_MAX_FILE_BYTES) -> bytes:
    scheme = urllib.parse.urlsplit(url).scheme
    if scheme != "https" and not (scheme == "file" and allow_local):
        raise ValueError(f"refusing to download from {scheme or 'relative'} URL")
    request = urllib.request.Request(url, headers={"User-Agent": "vshell-theme-catalog"})
    with urllib.request.urlopen(request, timeout=CATALOG_TIMEOUT) as response:  # noqa: S310 - scheme checked above
        data = response.read(max_bytes + 1)
    if len(data) > max_bytes:
        raise ValueError(f"download exceeds {max_bytes} bytes: {url}")
    return data


def _catalog_fetch_verified(name: str, rel: str, size: int, digest: str,
                            base_urls: List[str], allow_local: bool) -> bytes:
    """Fetch one theme file from the first location whose bytes match the manifest."""
    quoted = f"{urllib.parse.quote(name)}/" + "/".join(
        urllib.parse.quote(part) for part in rel.split("/"))
    problems: List[str] = []
    for base in base_urls:
        url = f"{base}/{quoted}"
        try:
            data = _catalog_fetch(url, allow_local, max_bytes=max(size, 1) + 1024)
        except Exception as exc:
            problems.append(f"{base}: {exc}")
            continue
        if len(data) != size:
            problems.append(f"{base}: expected {size} bytes, got {len(data)}")
            continue
        if hashlib.sha256(data).hexdigest() != digest:
            problems.append(f"{base}: checksum mismatch")
            continue
        return data
    raise ValueError(f"{name}/{rel}: no source served the catalogued file (" + "; ".join(problems) + ")")


def catalog_marker(name: str) -> Dict[str, Any]:
    path = user_themes_dir() / name / CATALOG_MARKER
    if not path.is_file():
        return {}
    try:
        data = json.loads(path.read_text())
    except Exception:
        return {}
    return data if isinstance(data, dict) else {}


def catalog_owns(name: str) -> bool:
    """Whether this exact directory is one the catalog downloaded.

    The marker lives inside the theme package, and `theme duplicate` copies a
    package wholesale — so a copy inherits the marker. Ownership therefore has to
    be identity, not presence: the marker must name this directory, which the
    copy's inherited marker never does.
    """
    dest = user_themes_dir() / name
    marker = catalog_marker(name)
    if not marker:
        return False
    return str(marker.get("name") or "") == name and str(marker.get("path") or "") == str(dest)


def catalog_preview_path(name: str) -> str:
    """Screenshot for a catalog entry: the installed package's, else the shipped one."""
    files = compose_theme_files(name)
    packaged = files.get("preview.png")
    if packaged and packaged.is_file():
        return str(packaged)
    shipped = theme_catalog_previews_dir() / f"{name}.png"
    return str(shipped) if shipped.is_file() else ""


def catalog_entries() -> List[Dict[str, Any]]:
    catalog = load_theme_catalog()
    installed = set(theme_package_names())
    entries: List[Dict[str, Any]] = []
    for raw in catalog.get("themes") or []:
        if not isinstance(raw, dict):
            continue
        name = str(raw.get("name") or "")
        if not name:
            continue
        marker = catalog_marker(name) if catalog_owns(name) else {}
        entries.append({
            "name": name,
            "mode": raw.get("mode", "dark"),
            "pair": raw.get("pair", ""),
            "source": raw.get("source", "curated"),
            "colors": raw.get("colors", []),
            "background": raw.get("background", ""),
            "foreground": raw.get("foreground", ""),
            "accent": raw.get("accent", ""),
            "size": int(raw.get("size") or 0),
            "fileCount": len(raw.get("files") or []),
            "installed": name in installed,
            "builtin": (builtin_themes_dir() / name / "theme.json").is_file(),
            "downloaded": bool(marker),
            "downloadedRef": str(marker.get("ref") or ""),
            "preview": catalog_preview_path(name),
        })
    entries.sort(key=lambda e: e["name"])
    return entries


def catalog_download_theme(entry: Dict[str, Any], base_urls: List[str], allow_local: bool,
                           force: bool = False) -> Dict[str, Any]:
    """Download one catalog theme into the user theme dir, verifying every file.

    Deliberately NOT run under the theme mutation lock: `install --all` is a
    ~1.1 GiB transfer, and holding the exclusive lock for its duration would
    block every theme apply, the light/dark keybinding, wallpaper changes and
    restyles for hours. Only the directory swap at the end mutates theme state,
    so only the swap takes the lock.
    """
    name = _catalog_check_name(str(entry.get("name") or ""))
    dest = user_themes_dir() / name
    if (builtin_themes_dir() / name / "theme.json").is_file():
        return {"name": name, "status": "skipped", "reason": "already installed as a built-in theme"}
    if dest.exists():
        if not force:
            return {"name": name, "status": "skipped", "reason": "already installed"}
        if not catalog_owns(name):
            raise ValueError(f"{name} is a local theme, not a downloaded one; refusing to overwrite")
    files = entry.get("files") or []
    if not files:
        raise ValueError(f"catalog entry for {name} lists no files")
    if not base_urls:
        raise ValueError("theme catalog has no download source")

    ensure_dirs()
    _catalog_clear_stale()
    staging = Path(tempfile.mkdtemp(prefix=f".catalog-{name}-", dir=str(user_themes_dir())))
    written = 0
    try:
        for spec in files:
            rel = _catalog_check_relpath(str((spec or {}).get("path") or ""))
            digest = str((spec or {}).get("sha256") or "")
            size = int((spec or {}).get("size") or 0)
            if not re.fullmatch(r"[0-9a-f]{64}", digest):
                raise ValueError(f"{name}/{rel}: catalog entry has no usable checksum")
            data = _catalog_fetch_verified(name, rel, size, digest, base_urls, allow_local)
            target = staging / rel
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_bytes(data)
            written += len(data)
        if not (staging / "theme.json").is_file():
            raise ValueError(f"{name}: download has no theme.json")
        (staging / CATALOG_MARKER).write_text(json.dumps({
            "name": name,
            "path": str(dest),
            "ref": str((load_theme_catalog().get("source") or {}).get("ref") or ""),
            "bytes": written,
            "installedAt": int(time.time()),
        }, indent=2) + "\n")
        # Swap, never delete-then-write: a rmtree(dest) followed by a rename
        # leaves a working theme destroyed if the process dies in between. The
        # old copy is renamed aside first (atomic), so the worst interruption
        # window leaves the theme at .catalog-replaced-* and the next run's
        # _catalog_clear_stale() sweeps it.
        with theme_mutation_lock():
            replaced: Path | None = None
            if dest.exists():
                replaced = dest.with_name(f".catalog-replaced-{name}-{os.getpid()}")
                _catalog_discard(replaced)
                dest.replace(replaced)
            try:
                staging.replace(dest)
            except OSError:
                if replaced is not None and not dest.exists():
                    replaced.replace(dest)
                raise
            staging = None  # type: ignore[assignment]
            if replaced is not None:
                _catalog_discard(replaced)
    finally:
        if staging is not None:
            shutil.rmtree(staging, ignore_errors=True)
    return {"name": name, "status": "installed", "path": str(dest), "bytes": written}


def _catalog_discard(path: Path) -> None:
    """Best-effort delete of a staging/replaced entry, symlinks included."""
    if path.is_symlink():
        with contextlib.suppress(OSError):
            path.unlink()
        return
    shutil.rmtree(path, ignore_errors=True)


def _catalog_clear_stale() -> None:
    """Sweep staging/replaced dirs a killed download may have left behind.

    Safe to do unconditionally: a swap is the only moment another download could
    care about these, and swaps hold the theme mutation lock.
    """
    root = user_themes_dir()
    if not root.is_dir():
        return
    for leftover in list(root.glob(".catalog-*")):
        _catalog_discard(leftover)


def current_theme_name() -> str:
    """The applied theme's name, read-only.

    `current_theme()` applies the coppernight default (writing files, running
    hooks) when no theme state exists yet, which a read for a safety check must
    never trigger.
    """
    theme_file = cfg_dir() / "theme.json"
    if not theme_file.is_file():
        return ""
    try:
        return str(json.loads(theme_file.read_text()).get("name") or "")
    except Exception:
        return ""


def catalog_remove_theme(name: str) -> Dict[str, Any]:
    """Remove a downloaded theme. Never touches built-ins or hand-made user themes."""
    clean = _catalog_check_name(name)
    dest = user_themes_dir() / clean
    # Identity, not marker presence: `theme duplicate` copies the marker along
    # with the package, and deleting someone's hand-made copy of a theme is
    # exactly the data loss this predicate exists to prevent.
    if not catalog_owns(clean):
        if catalog_marker(clean):
            raise ValueError(f"{clean} is a copy of a downloaded theme, not one the catalog installed")
        raise ValueError(f"{clean} was not downloaded from the theme catalog")
    if current_theme_name() == clean:
        raise ValueError(f"{clean} is the current theme; apply another theme first")
    # Rename aside first, then delete. The rename is the single operation that
    # decides the outcome: if it fails the theme is untouched and the caller is
    # told loudly (never `ignore_errors` + an unconditional "removed"), and if
    # the subsequent delete fails the theme is already gone from the theme dirs
    # and the leftover is swept by _catalog_clear_stale().
    trash = dest.with_name(f".catalog-removing-{clean}-{os.getpid()}")
    _catalog_discard(trash)
    with theme_mutation_lock():
        try:
            dest.replace(trash)
        except OSError as exc:
            raise ValueError(f"could not remove {clean}: {exc}") from exc
    _catalog_discard(trash)
    if dest.exists():
        raise ValueError(f"could not remove {clean}: {dest} still exists")
    return {"name": clean, "status": "removed", "path": str(dest)}


def theme_wallpaper_entries(bp: Dict[str, Any]) -> List[Dict[str, Any]]:
    """Composed, hidden-filtered wallpaper set with per-entry origin and default flag."""
    pkg_dir_name = Path(str(bp.get("path"))).name
    builtin_root = builtin_themes_dir() / pkg_dir_name
    default_path = str((bp.get("palette") or {}).get("wallpaper", ""))
    entries: List[Dict[str, Any]] = []
    for path in bp.get("backgrounds") or []:
        p = Path(path)
        entries.append({
            "file": p.name,
            "path": str(p),
            "origin": "builtin" if builtin_root in p.parents else "user",
            "default": str(p) == default_path,
        })
    return entries


def read_theme_overlay_meta(pkg_dir_name: str) -> Dict[str, Any]:
    files = compose_theme_files(pkg_dir_name)
    if "theme.json" not in files:
        return {}
    try:
        return json.loads(files["theme.json"].read_text())
    except Exception:
        return {}


def write_theme_overlay_meta(pkg_dir_name: str, meta: Dict[str, Any]) -> None:
    # User overlay shadows the built-in theme.json file-level, so metadata edits
    # never touch the repo copy.
    write_file(user_themes_dir() / pkg_dir_name / "theme.json", json.dumps(meta, indent=2) + "\n")


def colors_toml_from_blueprint(bp: Dict[str, Any]) -> str:
    pal = bp.get("palette", {})
    ext = pal.get("extendedColors") or {}
    colors = [clean_hex(c, DEFAULT_COLORS[i] if i < len(DEFAULT_COLORS) else "#000000") for i, c in enumerate(pal.get("colors", []))]
    while len(colors) < 16:
        colors.append(DEFAULT_COLORS[len(colors)])
    lines: List[str] = []
    for key in ("accent", "cursor", "foreground", "background", "selection_foreground", "selection_background"):
        value = ext.get(key)
        if value:
            lines.append(f'{key} = "{clean_hex(value)}"')
    lines.append("")
    for i, c in enumerate(colors):
        lines.append(f'color{i} = "{c}"')
    return "\n".join(lines) + "\n"


def colors_toml_from_map(data: Dict[str, str]) -> str:
    """Emit a 22-key colors.toml from a normalized colors map (no contrast pass)."""
    lines: List[str] = []
    for key in ("accent", "cursor", "foreground", "background", "selection_foreground", "selection_background"):
        value = data.get(key)
        if value and HEX_RE.match(str(value).strip()):
            lines.append(f'{key} = "{clean_hex(value)}"')
    lines.append("")
    for i in range(16):
        value = data.get(f"color{i}") or (data.get(ANSI_NAMES[i]) if i < len(ANSI_NAMES) else "")
        lines.append(f'color{i} = "{clean_hex(value, DEFAULT_COLORS[i])}"')
    return "\n".join(lines) + "\n"


# --- Per-app color overrides (overlay app-colors.toml) --------------------------
#
# `[<app>]` tables of role = "#hex" stored in a theme's user overlay. Merged over
# the derived role map for that app's target only, at render time.
def _parse_app_overrides_text(text: str) -> Dict[str, Dict[str, str]]:
    out: Dict[str, Dict[str, str]] = {}
    try:
        parsed = tomllib.loads(text)
    except Exception:
        return out
    for app, table in parsed.items():
        if not isinstance(table, dict):
            continue
        roles: Dict[str, str] = {}
        for role, value in table.items():
            if isinstance(value, str) and HEX_RE.match(value.strip()):
                roles[str(role)] = clean_hex(value)
        if roles:
            out[str(app)] = roles
    return out


def theme_app_overrides(pkg_dir_name: str) -> Dict[str, Dict[str, str]]:
    """Composed per-app overrides for a theme (built-in then user, user wins)."""
    files = compose_theme_files(pkg_dir_name)
    path = files.get("app-colors.toml")
    if not path:
        return {}
    return _parse_app_overrides_text(path.read_text(errors="ignore"))


def read_user_app_overrides(pkg_dir_name: str) -> Dict[str, Dict[str, str]]:
    """Only the user overlay app-colors.toml, for read-modify-write editing."""
    path = user_themes_dir() / pkg_dir_name / "app-colors.toml"
    if not path.exists():
        return {}
    return _parse_app_overrides_text(path.read_text(errors="ignore"))


def write_user_app_overrides(pkg_dir_name: str, data: Dict[str, Dict[str, str]]) -> None:
    path = user_themes_dir() / pkg_dir_name / "app-colors.toml"
    lines: List[str] = []
    for app in sorted(data):
        roles = {r: v for r, v in data[app].items() if isinstance(v, str) and HEX_RE.match(v.strip())}
        if not roles:
            continue
        lines.append(f"[{app}]")
        for role in sorted(roles):
            lines.append(f'{role} = "{clean_hex(roles[role])}"')
        lines.append("")
    if lines:
        write_file(path, "\n".join(lines).rstrip("\n") + "\n")
    elif path.exists():
        with contextlib.suppress(OSError):
            path.unlink()


def bp_app_overrides(bp: Dict[str, Any]) -> Dict[str, Dict[str, str]]:
    """Per-app overrides for a resolved blueprint (packages only)."""
    path = bp.get("path")
    if not bp.get("package") or not path:
        return {}
    return theme_app_overrides(Path(str(path)).name)


def save_theme_package(bp: Dict[str, Any], name: str | None = None) -> Path:
    """Persist a theme as a user package: curated apps/ files carry over
    verbatim; every other toggled-on app gets an editable rendered file."""
    bp = dict(bp)
    if name:
        bp["name"] = name
    apps: Dict[str, str] = rendered_apps_for(bp)
    for filename, path in (bp.get("apps") or {}).items():
        with contextlib.suppress(OSError):
            apps[filename] = Path(path).read_text()
    return materialize_theme_package(bp, apps=apps)


def rendered_apps_for(bp: Dict[str, Any]) -> Dict[str, str]:
    """Render an editable apps/<file> for every toggled-on app target.

    Only targets whose curated file directly replaces the generated output
    (no curatedDestination) materialize — pointer-style curated formats
    (vscode.json, icons.theme, hyprland.conf) are hand-written only.
    """
    roles = target_roles(bp)
    theme_apps = theme_apps_settings()
    out: Dict[str, str] = {}
    for cfg_path in sorted(targets_dir().glob("*/config.json")):
        cfg = json.loads(cfg_path.read_text())
        name = cfg.get("curatedFile")
        if not name or cfg.get("curatedDestination") or not cfg.get("template"):
            continue
        if not target_enabled(cfg, theme_apps):
            continue
        out[str(name)] = render_template((cfg_path.parent / cfg["template"]).read_text(), roles)
    return out


def materialize_theme_package(bp: Dict[str, Any], apps: Dict[str, str] | None = None, user: bool = True) -> Path:
    """Write a blueprint out as a v2 theme package directory.

    `apps` maps curated file names to file contents (rendered or hand-written).
    Existing curated files are preserved unless new content is supplied.
    """
    ensure_dirs()
    name = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(bp.get("name") or "theme")).strip("-") or "theme"
    root = (user_themes_dir() if user else builtin_themes_dir()) / name
    root.mkdir(parents=True, exist_ok=True)
    meta = {
        "name": bp.get("name") or name,
        "mode": blueprint_mode(bp),
        "pair": bp.get("pair") or "",
        "source": blueprint_source(bp),
    }
    write_file(root / "theme.json", json.dumps(meta, indent=2) + "\n")
    write_file(root / "colors.toml", colors_toml_from_blueprint(bp))
    wallpaper = resolve_path(str(bp.get("palette", {}).get("wallpaper") or ""))
    if wallpaper and Path(wallpaper).is_file():
        bg_dir = root / "backgrounds"
        bg_dir.mkdir(parents=True, exist_ok=True)
        dest = bg_dir / Path(wallpaper).name
        if not dest.exists() or not Path(wallpaper).samefile(dest):
            shutil.copy2(wallpaper, dest)
    for filename, content in (apps or {}).items():
        write_file(root / "apps" / filename, content)
    return root


def load_settings() -> Dict[str, Any]:
    primary = cfg_dir() / "settings.json"
    if primary.exists():
        return load_required_json_file(primary)
    fallback = repo_root() / "config" / "vshell" / "settings.default.json"
    if fallback.exists():
        return load_required_json_file(fallback)
    return {}


def blueprint_source(bp: Dict[str, Any]) -> str:
    """Whether a theme's palette is hand-curated or machine-generated.

    Curated palettes pass through untouched (no contrast rewriting); generated
    palettes get role normalization + contrast enforcement.
    """
    src = str(bp.get("source") or "").strip().lower()
    return src if src in {"curated", "generated"} else "generated"


def lint_blueprint(bp: Dict[str, Any]) -> List[Dict[str, Any]]:
    """Contrast warnings for a theme palette. Reports, never rewrites —
    the curated-theme counterpart of generated-theme contrast enforcement."""
    pal = bp.get("palette", {})
    colors = [clean_hex(c, DEFAULT_COLORS[i] if i < len(DEFAULT_COLORS) else "#000000") for i, c in enumerate(pal.get("colors", []))]
    while len(colors) < 16:
        colors.append(DEFAULT_COLORS[len(colors)])
    ext = pal.get("extendedColors") or {}
    mode = blueprint_mode(bp)
    bg = clean_hex(ext.get("background") or colors[0], colors[0])
    warnings: List[Dict[str, Any]] = []

    def check(role: str, value: str, against: str, minimum: float, against_role: str = "background") -> None:
        ratio = contrast_ratio(value, against)
        if ratio < minimum:
            warnings.append({
                "role": role, "color": value, "against": against_role,
                "contrast": round(ratio, 2), "minimum": minimum,
                "message": f"{role} {value} has {ratio:.2f}:1 contrast against {against_role} {against} (want >= {minimum:g}:1)",
            })

    fg = clean_hex(ext.get("foreground") or colors[7], colors[7])
    accent = clean_hex(ext.get("accent") or colors[4], colors[4])
    selection_bg = clean_hex(ext.get("selection_background") or ext.get("selectionBackground") or colors[4], colors[4])
    selection_fg = clean_hex(ext.get("selection_foreground") or ext.get("selectionForeground") or colors[15], colors[15])
    if mode == "light" and luminance(bg) < 0.5:
        warnings.append({"role": "background", "color": bg, "message": f"background {bg} looks dark but theme mode is light"})
    if mode == "dark" and luminance(bg) > 0.5:
        warnings.append({"role": "background", "color": bg, "message": f"background {bg} looks light but theme mode is dark"})
    check("foreground", fg, bg, 7.0)
    check("accent", accent, bg, 3.0)
    check("cursor", clean_hex(ext.get("cursor") or accent, accent), bg, 3.0)
    check("selection_foreground", selection_fg, selection_bg, 4.5, "selection_background")
    for i in range(1, 16):
        check(f"color{i} ({ANSI_NAMES[i]})", colors[i], bg, 3.0)
    return warnings


def target_roles(bp: Dict[str, Any]) -> Dict[str, str]:
    pal = bp.get("palette", {})
    curated = blueprint_source(bp) == "curated"
    colors = [clean_hex(c, DEFAULT_COLORS[i] if i < len(DEFAULT_COLORS) else "#000000") for i, c in enumerate(pal.get("colors", []))]
    while len(colors) < 16:
        colors.append(DEFAULT_COLORS[len(colors)])
    ext = pal.get("extendedColors") or {}
    mode = (pal.get("mode") or ("light" if pal.get("lightMode") else "dark") or "dark").lower()
    background = clean_hex(ext.get("background") or colors[0], colors[0])
    if not curated:
        background = ensure_background_supports_text(background, mode, 7.0)
    roles: Dict[str, str] = {
        "name": str(bp.get("name") or "vgs-theme"),
        "source": "curated" if curated else "generated",
        "theme_type": mode,
        "wallpaper": resolve_path(str(pal.get("wallpaper") or "")),
        "background": background,
        "foreground": clean_hex(ext.get("foreground") or colors[7], colors[7]),
        "accent": clean_hex(ext.get("accent") or colors[4], colors[4]),
        "cursor": clean_hex(ext.get("cursor") or ext.get("accent") or colors[4], colors[4]),
        "selection_background": clean_hex(ext.get("selection_background") or ext.get("selectionBackground") or colors[4], colors[4]),
        "selection_foreground": clean_hex(ext.get("selection_foreground") or ext.get("selectionForeground") or colors[15], colors[15]),
    }
    bg = roles["background"]
    prefer_text = "#000000" if mode == "light" else "#ffffff"
    if not curated:
        # Generated palettes only: normalize + enforce contrast. Curated palettes
        # pass through untouched so hand-picked colors reach targets verbatim.
        roles["foreground"] = _oklab_contrast_adjust(roles["foreground"], bg, 7.0, prefer_text)
        roles["accent"] = _oklab_contrast_adjust(roles["accent"], bg, 3.0, prefer_text)
        roles["cursor"] = _oklab_contrast_adjust(roles["cursor"], bg, 3.0, prefer_text)
        roles["selection_background"] = _oklab_contrast_adjust(
            roles["selection_background"], bg, 4.5, prefer_text
        )
        roles["selection_foreground"] = bg
    for i, c in enumerate(colors[:16]):
        adjusted = c
        if not curated:
            minimum = 4.5 if i in {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} else 3.0
            adjusted = _oklab_contrast_adjust(c, bg, minimum, prefer_text)
            if i == 15:
                adjusted = roles["foreground"]
        roles[f"color{i}"] = adjusted
        roles[ANSI_NAMES[i]] = adjusted
    # These are shell-facing roles even on curated themes. A continuous
    # hue-preserving correction keeps restyle sweeps readable without rewriting
    # the curated ANSI palette or introducing a special branch at zero.
    roles["foreground"] = _oklab_contrast_adjust(roles["foreground"], bg, 7.0, prefer_text)
    roles["cursor"] = _oklab_contrast_adjust(roles["cursor"], bg, 3.0, prefer_text)
    roles["selection_background"] = _oklab_contrast_adjust(
        roles["selection_background"], bg, 4.5, prefer_text
    )
    roles["selection_foreground"] = bg
    fg = roles["foreground"]
    accent = ensure_usable_accent(
        roles["accent"],
        bg,
        roles,
        prefer_text,
        min_contrast=4.5,
        continuous=True,
    )
    roles["accent"] = accent
    dark_mode = mode != "light"

    def surface(amount: float) -> str:
        lightness, _relative, _chroma, _hue = _relative_oklch(bg)
        target_l = (
            lightness + (1.0 - lightness) * amount
            if dark_mode else lightness * (1.0 - amount)
        )
        return _map_oklch_lightness(bg, target_l)

    def readable_surface(candidate: str, minimum: float = 4.0) -> str:
        """Pull a surface toward bg without ever crossing its tone polarity."""
        if contrast_ratio(fg, candidate) >= minimum:
            return candidate
        bg_l = color_to_oklab(bg)[0]
        candidate_l = color_to_oklab(candidate)[0]
        if contrast_ratio(fg, bg) < minimum:
            return bg
        low, high = 0.0, 1.0
        for _ in range(18):
            fraction = (low + high) / 2.0
            tone = bg_l + (candidate_l - bg_l) * fraction
            probe = _map_oklch_lightness(bg, tone)
            if contrast_ratio(fg, probe) >= minimum:
                low = fraction
            else:
                high = fraction
        return _map_oklch_lightness(bg, bg_l + (candidate_l - bg_l) * low)

    def readable_container(candidate: str, minimum: float = 4.5) -> str:
        """Retain as much tint as possible while keeping fg readable."""
        if contrast_ratio(fg, candidate) >= minimum:
            return candidate
        low, high = 0.0, 1.0
        for _ in range(18):
            fraction = (low + high) / 2.0
            probe = blend(bg, candidate, fraction)
            if contrast_ratio(fg, probe) >= minimum:
                low = fraction
            else:
                high = fraction
        return blend(bg, candidate, low)

    def tinted(color: str, strength: float = 0.24) -> str:
        return blend(color, bg, 1.0 - strength)

    semantic_bases = ({
        "error": "#b91c1c",
        "warning": "#8a5a00",
        "success": "#166534",
        "info": "#1d4ed8",
    } if mode == "light" else {
        "error": "#ff6b6b",
        "warning": "#fbbf24",
        "success": "#5bd77a",
        "info": "#60a5fa",
    })
    roles["primary"] = accent
    # Primary/semantic colors are foreground-readable against the base surface,
    # so using that same surface as their companion avoids a black/white
    # readable_on() polarity flip as a slider crosses the midpoint.
    roles["onPrimary"] = bg
    roles["error"] = _oklab_contrast_adjust(semantic_bases["error"], bg, 4.5, prefer_text)
    roles["warning"] = _oklab_contrast_adjust(semantic_bases["warning"], bg, 4.5, prefer_text)
    roles["success"] = _oklab_contrast_adjust(semantic_bases["success"], bg, 4.5, prefer_text)
    roles["info"] = _oklab_contrast_adjust(semantic_bases["info"], bg, 4.5, prefer_text)
    roles["surface"] = bg
    roles["surfaceVariant"] = surface(0.06)
    roles["surfaceContainerLowest"] = bg
    roles["surfaceContainerLow"] = surface(0.035)
    roles["surfaceContainer"] = surface(0.06)
    roles["surfaceContainerHigh"] = surface(0.10)
    roles["surfaceContainerHighest"] = surface(0.15)
    # Medium-dark backgrounds can lighten far enough that foreground text on the
    # top containers drops below readable contrast (e.g. moon-orbit). Pull the
    # container back toward the background until foreground reads again.
    for _ck in ("surfaceContainerHigh", "surfaceContainerHighest"):
        roles[_ck] = readable_surface(roles[_ck])
    roles["primaryContainer"] = readable_container(tinted(accent, 0.30))
    roles["secondaryContainer"] = readable_container(tinted(roles["cyan"], 0.24))
    roles["tertiaryContainer"] = readable_container(tinted(roles["magenta"], 0.24))
    # M3-ish companion roles for ported app themes (vscode/zed/emacs/kde).
    roles["secondary"] = _oklab_contrast_adjust(roles["cyan"], bg, 4.5, prefer_text)
    roles["tertiary"] = _oklab_contrast_adjust(roles["magenta"], bg, 4.5, prefer_text)
    roles["onSecondary"] = bg
    roles["onTertiary"] = bg
    roles["onError"] = bg
    roles["onPrimaryContainer"] = fg
    roles["onSecondaryContainer"] = fg
    roles["onTertiaryContainer"] = fg
    roles["inverseSurface"] = fg
    roles["inverseOnSurface"] = bg
    roles["inversePrimary"] = roles["primaryContainer"]
    roles["shadow"] = "#000000"
    roles["scrim"] = "#000000"
    roles["successContainer"] = tinted(roles["success"], 0.20)
    roles["warningContainer"] = tinted(roles["warning"], 0.20)
    roles["errorContainer"] = readable_container(tinted(roles["error"], 0.20))
    roles["infoContainer"] = tinted(roles["info"], 0.20)
    roles["onErrorContainer"] = fg
    roles["outline"] = ensure_contrast(blend(fg, bg, 0.52), bg, 3.0, prefer_text)
    roles["outlineVariant"] = ensure_contrast(blend(fg, bg, 0.68), bg, 2.2, prefer_text)
    roles["muted"] = ensure_contrast(blend(fg, bg, 0.35), bg, 4.5, prefer_text)
    roles["dim"] = ensure_contrast(blend(fg, bg, 0.50), bg, 4.5, prefer_text)
    status_surface = roles["surfaceContainerHighest"] if dark_mode else ensure_contrast(roles["surfaceContainerHighest"], bg, 1.25, "#000000")
    roles["statusBg"] = ensure_background_supports_text(status_surface, mode, 7.0)
    roles["statusFg"] = ensure_contrast(fg, roles["statusBg"], 7.0, prefer_text)
    roles["statusMuted"] = ensure_contrast(roles["muted"], roles["statusBg"], 4.5, prefer_text)
    roles["statusAccent"] = ensure_contrast(accent, roles["statusBg"], 4.5, prefer_text)
    roles["statusError"] = ensure_contrast(roles["error"], roles["statusBg"], 4.5, prefer_text)
    roles["statusWarning"] = ensure_contrast(roles["warning"], roles["statusBg"], 4.5, prefer_text)
    roles["statusSuccess"] = ensure_contrast(roles["success"], roles["statusBg"], 4.5, prefer_text)
    roles["statusInfo"] = ensure_contrast(roles["info"], roles["statusBg"], 4.5, prefer_text)
    roles["groupbarActiveBg"] = accent
    roles["groupbarActiveFg"] = ensure_contrast(roles["onPrimary"], roles["groupbarActiveBg"], 4.5)
    roles["groupbarInactiveBg"] = ensure_background_supports_text(roles["surfaceContainerHighest"], mode, 7.0)
    roles["groupbarInactiveFg"] = ensure_contrast(fg, roles["groupbarInactiveBg"], 7.0, prefer_text)
    # Locked groups reuse the active-tab color: the lock state is surfaced via a
    # notification on toggle, so the tab itself needs no distinct color.
    roles["groupbarLockedBg"] = roles["groupbarActiveBg"]
    roles["groupbarLockedFg"] = roles["groupbarActiveFg"]
    if not curated:
        stabilize_ansi_role_pairs(roles, bg, mode)
    # Friendly aliases for generated JSON consumers.
    for snake, camel in CAMEL.items():
        roles[camel] = roles[snake]
    return roles


def app_target_roles(bp: Dict[str, Any], shell_roles: Dict[str, str] | None = None) -> Dict[str, str]:
    """Keep curated base/ANSI colors byte-faithful for external app targets.

    Shell readability roles may adjust foreground/selection/accent continuously,
    but terminal and app templates must receive the curated package values.
    Derived semantic/surface roles still come from the shell map.
    """
    roles = dict(shell_roles or target_roles(bp))
    if blueprint_source(bp) != "curated":
        return roles
    pal = bp.get("palette", {})
    colors = [
        clean_hex(color, DEFAULT_COLORS[index] if index < len(DEFAULT_COLORS) else "#000000")
        for index, color in enumerate(pal.get("colors", []))
    ]
    while len(colors) < 16:
        colors.append(DEFAULT_COLORS[len(colors)])
    ext = pal.get("extendedColors") or {}
    raw = {
        "background": clean_hex(ext.get("background") or colors[0], colors[0]),
        "foreground": clean_hex(ext.get("foreground") or colors[7], colors[7]),
        "accent": clean_hex(ext.get("accent") or colors[4], colors[4]),
        "cursor": clean_hex(ext.get("cursor") or ext.get("accent") or colors[4], colors[4]),
        "selection_background": clean_hex(
            ext.get("selection_background") or ext.get("selectionBackground") or colors[4],
            colors[4],
        ),
        "selection_foreground": clean_hex(
            ext.get("selection_foreground") or ext.get("selectionForeground") or colors[15],
            colors[15],
        ),
    }
    for index, color in enumerate(colors[:16]):
        raw[f"color{index}"] = color
        raw[ANSI_NAMES[index]] = color
    roles.update(raw)
    for snake, camel in CAMEL.items():
        if snake in raw:
            roles[camel] = raw[snake]
    return roles


def render_template(text: str, roles: Dict[str, str]) -> str:
    def repl(match: re.Match[str]) -> str:
        name = match.group(1)
        modifier = match.group(2)
        if name not in roles:
            # Preserve foreign template syntaxes such as tmux #{pane_id}.
            return match.group(0)
        value = roles.get(name, "")
        if modifier == "strip":
            return strip_hash(value)
        if modifier == "rgb":
            r, g, b = rgb(value)
            return f"{r},{g},{b}"
        return value
    return TEMPLATE_RE.sub(repl, text)


def write_file(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{time.time_ns()}")
    tmp.write_text(content)
    tmp.replace(path)


def _niri() -> Any:
    """Load the Niri/KDL subsystem only for commands and hooks that use it."""
    global _NIRI_SUPPORT
    if _NIRI_SUPPORT is None:
        import vshell_niri
        vshell_niri.configure(vshell_niri.NiriRuntime(
            home=home,
            cfg_dir=cfg_dir,
            run=run,
            write_file=write_file,
            load_settings=load_settings,
            coerce_int=_coerce_int,
            optional_nonnegative_int=_optional_nonnegative_int,
        ))
        _NIRI_SUPPORT = vshell_niri
    return _NIRI_SUPPORT


def write_chromium_policy(roles: Dict[str, str], allow_prompt: bool = False) -> bool:
    color = roles.get("surfaceContainerHigh") or roles.get("background") or "#1a1b26"
    payload = {"BrowserThemeColor": color.lower()}
    generated = generated_dir() / "chromium-policy.json"
    write_file(generated, json.dumps(payload, indent=2) + "\n")
    target = Path("/etc/chromium/policies/managed/color.json")
    try:
        target.parent.mkdir(parents=True, exist_ok=True)
        write_file(target, json.dumps(payload, indent=2) + "\n")
        return True
    except PermissionError:
        if shutil.which("sudo"):
            cmd = ["sudo"]
            if not allow_prompt:
                cmd.append("-n")
            cmd.extend(["install", "-Dm644", str(generated), str(target)])
            proc = subprocess.run(cmd, text=True, stdout=subprocess.PIPE if not allow_prompt else None, stderr=subprocess.PIPE if not allow_prompt else None)
            if proc.returncode != 0 and not allow_prompt:
                eprint("chromium policy needs root; run `vshell theme chromium-policy` in a terminal")
            return proc.returncode == 0
    except Exception:
        return False
    return False


def resolve_vshell_cli() -> str:
    repo_cli = repo_root() / "bin" / "vshell"
    if repo_cli.exists() and os.access(repo_cli, os.X_OK):
        return str(repo_cli)
    local = home() / ".local" / "bin" / "vshell"
    if local.exists() and os.access(local, os.X_OK):
        return str(local)
    return shutil.which("vshell") or str(repo_cli)


def open_theme_picker(mode: str = "") -> Dict[str, Any]:
    cli = resolve_vshell_cli()
    if mode in {"dark", "light"}:
        cmd = [cli, "ipc", "call", "theme-picker", "openMode", mode]
    else:
        cmd = [cli, "ipc", "call", "theme-picker", "open"]
    return _run_hook_cmd("theme-picker", cmd, timeout=5)


def _run_hook_cmd(hook: str, cmd: List[str], **kwargs: Any) -> Dict[str, Any]:
    try:
        proc = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=kwargs.pop("timeout", 8), **kwargs)
        return {"hook": hook, "ok": proc.returncode == 0, "code": proc.returncode, "stdout": proc.stdout.strip(), "stderr": proc.stderr.strip()}
    except Exception as exc:
        return {"hook": hook, "ok": False, "error": str(exc)}


def process_pids_by_comm(comm: str) -> List[int]:
    pids: List[int] = []
    proc = Path("/proc")
    if not proc.exists():
        return pids
    for entry in proc.iterdir():
        if not entry.name.isdigit():
            continue
        try:
            if entry.stat().st_uid != os.getuid():
                continue
            name = (entry / "comm").read_text().strip()
            if name == comm:
                pids.append(int(entry.name))
        except Exception:
            continue
    return sorted(set(pids))


def reload_ghostty_hook() -> Dict[str, Any]:
    pids = process_pids_by_comm("ghostty")
    signaled: List[int] = []
    failures: List[str] = []
    for pid in pids:
        try:
            os.kill(pid, signal.SIGUSR2)
            signaled.append(pid)
        except ProcessLookupError:
            continue
        except Exception as exc:
            failures.append(f"{pid}: {exc}")
    if signaled or failures:
        return {"hook": "ghostty-reload", "ok": not failures, "method": "SIGUSR2", "pids": signaled, "error": "; ".join(failures)}

    if shutil.which("gdbus"):
        result = _run_hook_cmd(
            "ghostty-reload",
            ["gdbus", "call", "--session", "--dest", "com.mitchellh.ghostty", "--object-path", "/com/mitchellh/ghostty", "--method", "org.gtk.Actions.Activate", "reload-config", "[]", "{}"],
            timeout=3,
        )
        if result.get("ok"):
            result["method"] = "dbus"
            return result

    return {"hook": "ghostty-reload", "ok": True, "skipped": True, "reason": "no running ghostty process"}


def nvim_sockets() -> List[Path]:
    candidates: List[Path] = []
    runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
    for base in [Path(runtime), Path("/tmp")]:
        if not base.exists():
            continue
        candidates.extend(base.glob("nvim.*"))
        candidates.extend(base.glob("nvim.*/0"))
    return sorted({p for p in candidates if p.exists()})


def reload_nvim_hook() -> Dict[str, Any]:
    if not shutil.which("nvim"):
        return {"hook": "nvim-reload", "ok": True, "skipped": True, "reason": "nvim not found"}
    sockets = nvim_sockets()
    if not sockets:
        return {"hook": "nvim-reload", "ok": True, "skipped": True, "reason": "no nvim sockets"}
    expr = "luaeval('(function() local ok=pcall(vim.cmd, \"VGSReloadTheme\"); return ok and 1 or 0 end)()')"
    reloaded: List[str] = []
    failures: List[str] = []
    missing: List[str] = []
    pruned: List[str] = []
    # A crashed/killed nvim leaves its socket file behind; connecting to it fails
    # with E247/"Connection refused"/"No such file". Those are dead, not failures:
    # prune the orphan and move on. Otherwise a pile of stale sockets (24 seen on a
    # live machine, only 1 live) both floods the apply with errors and flips the
    # hook to ok=False, masking the one live nvim that actually reloaded.
    dead_markers = ("connection refused", "e247", "failed to connect", "no such file", "econnrefused")
    for sock in sockets:
        result = _run_hook_cmd("nvim-reload", ["nvim", "--server", str(sock), "--remote-expr", expr], timeout=3)
        stdout = (result.get("stdout") or "").strip()
        if result.get("ok") and stdout == "1":
            reloaded.append(str(sock))
            continue
        if result.get("ok"):
            missing.append(str(sock))
            continue
        blob = f"{result.get('stderr') or ''} {result.get('error') or ''}".lower()
        if any(marker in blob for marker in dead_markers):
            try:
                path = Path(sock)
                if path.is_socket():
                    path.unlink()
                    pruned.append(str(sock))
            except Exception:
                pass
            continue
        failures.append(f"{sock}: {result.get('stderr') or result.get('error') or 'failed'}")
    return {"hook": "nvim-reload", "ok": not failures, "reloaded": reloaded, "missingCommand": missing, "pruned": pruned, "error": "; ".join(failures)}


_VGS_INCLUDE_BEGIN = "# BEGIN VGS managed theme include"
_VGS_INCLUDE_END = "# END VGS managed theme include"


def _config_include_value(line: str, separator: str) -> str:
    stripped = line.strip()
    if separator not in stripped:
        return ""
    prefix, value = stripped.split(separator, 1)
    if prefix.strip() != "include":
        return ""
    value = value.split("#", 1)[0].strip().strip("\"'")
    return value


def _include_resolves_to(value: str, config: Path, target: Path) -> bool:
    if not value:
        return False
    expanded = Path(os.path.expandvars(os.path.expanduser(value)))
    if not expanded.is_absolute():
        expanded = config.parent / expanded
    try:
        return expanded.resolve(strict=False) == target.resolve(strict=False)
    except OSError:
        return expanded.absolute() == target.absolute()


def ensure_theme_include(config: Path, target: Path, include_line: str,
                         separator: str, seed_lines: List[str] | None = None) -> Dict[str, Any]:
    """Idempotently append one managed include while preserving user content."""
    original = config.read_text() if config.is_file() else ""
    if any(
        _include_resolves_to(_config_include_value(line, separator), config, target)
        for line in original.splitlines()
    ):
        return {"ok": True, "changed": False, "config": str(config), "target": str(target)}

    block_lines = [_VGS_INCLUDE_BEGIN, *(seed_lines or []), include_line, _VGS_INCLUDE_END]
    block = "\n".join(block_lines) + "\n"
    if _VGS_INCLUDE_BEGIN in original and _VGS_INCLUDE_END in original:
        start = original.index(_VGS_INCLUDE_BEGIN)
        end = original.index(_VGS_INCLUDE_END, start) + len(_VGS_INCLUDE_END)
        content = original[:start] + block.rstrip("\n") + original[end:]
        if original.endswith("\n") and not content.endswith("\n"):
            content += "\n"
    else:
        content = original
        if content and not content.endswith("\n"):
            content += "\n"
        if content:
            content += "\n"
        content += block
    write_file(config, content)
    return {"ok": True, "changed": True, "config": str(config), "target": str(target)}


def ensure_foot_theme_config() -> Dict[str, Any]:
    config = home() / ".config" / "foot" / "foot.ini"
    target = home() / ".config" / "foot" / "vgs-theme.ini"
    seed_lines: List[str] = []
    if not config.exists():
        xdg_dirs = os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")
        for base in (Path(item) for item in xdg_dirs if item):
            system_config = base / "foot" / "foot.ini"
            if system_config.is_file():
                seed_lines.append(f"include={system_config}")
                break
    result = ensure_theme_include(
        config, target, "include=~/.config/foot/vgs-theme.ini", "=", seed_lines
    )
    return {"hook": "foot-config", **result}


def ensure_kitty_theme_config() -> Dict[str, Any]:
    config = home() / ".config" / "kitty" / "kitty.conf"
    target = home() / ".config" / "kitty" / "vgs-theme.conf"
    result = ensure_theme_include(config, target, "include vgs-theme.conf", " ")
    return {"hook": "kitty-config", **result}


def ensure_niri_colors_config() -> Dict[str, Any]:
    return {"hook": "niri-colors-config", **_niri().ensure_niri_include("colors.kdl")}


def ensure_qtct_theme_config(version: int) -> Dict[str, Any]:
    app = f"qt{version}ct"
    config = home() / ".config" / app / f"{app}.conf"
    palette = home() / ".config" / app / "colors" / "vgs.conf"
    original = config.read_text() if config.is_file() else ""
    lines = original.splitlines()
    appearance_index = next(
        (index for index, line in enumerate(lines) if line.strip().lower() == "[appearance]"),
        -1,
    )
    if appearance_index < 0:
        if lines and lines[-1].strip():
            lines.append("")
        lines.append("[Appearance]")
        appearance_index = len(lines) - 1
    section_end = next(
        (index for index in range(appearance_index + 1, len(lines))
         if lines[index].strip().startswith("[") and lines[index].strip().endswith("]")),
        len(lines),
    )

    def set_key(key: str, value: str) -> None:
        nonlocal section_end
        for index in range(appearance_index + 1, section_end):
            if re.match(rf"^{re.escape(key)}\s*=", lines[index].strip(), re.IGNORECASE):
                lines[index] = f"{key}={value}"
                return
        lines.insert(section_end, f"{key}={value}")
        section_end += 1

    set_key("color_scheme_path", str(palette))
    set_key("custom_palette", "true")
    content = "\n".join(lines) + "\n"
    changed = content != original
    if changed:
        write_file(config, content)
    return {"hook": f"{app}-config", "ok": True, "changed": changed, "config": str(config)}


def run_hook(hook: Any, roles: Dict[str, str]) -> Dict[str, Any]:
    if isinstance(hook, dict):
        name = str(hook.get("name") or hook.get("type") or "hook")
        return {"hook": name, "ok": True, "skipped": True, "reason": "object hooks are not enabled by default"}

    if hook == "hypr-reload":
        if not shutil.which("hyprctl"):
            return {"hook": hook, "ok": True, "skipped": True, "reason": "hyprctl not found"}
        env = os.environ.copy()
        if not env.get("XDG_RUNTIME_DIR"):
            env["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}"
        # uwsm can leave stale HYPRLAND_INSTANCE_SIGNATURE in inherited shells after session restart.
        # Prefer live instances from hyprctl over inherited env, but never let probing abort theme apply.
        try:
            instances = subprocess.run(["hyprctl", "instances", "-j"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env={k: v for k, v in env.items() if k != "HYPRLAND_INSTANCE_SIGNATURE"}, timeout=3)
            data = json.loads(instances.stdout or "[]")
            live = sorted(data, key=lambda item: int(item.get("time") or 0), reverse=True)
            if live:
                env["HYPRLAND_INSTANCE_SIGNATURE"] = live[0].get("instance") or live[0].get("signature") or ""
        except Exception as exc:
            eprint(f"hook {hook} instance probe failed: {exc}")
        if not env.get("HYPRLAND_INSTANCE_SIGNATURE"):
            hypr_dir = Path(env["XDG_RUNTIME_DIR"]) / "hypr"
            if hypr_dir.exists():
                sockets = sorted(hypr_dir.glob("*/.socket.sock"), key=lambda p: p.stat().st_mtime, reverse=True)
                if sockets:
                    env["HYPRLAND_INSTANCE_SIGNATURE"] = sockets[0].parent.name
        return _run_hook_cmd(hook, ["hyprctl", "reload"], env=env, timeout=10)
    if hook == "niri-reload":
        if not shutil.which("niri"):
            return {"hook": hook, "ok": True, "skipped": True, "reason": "niri not found"}
        if not os.environ.get("NIRI_SOCKET"):
            return {"hook": hook, "ok": True, "skipped": True, "reason": "not a Niri session"}
        return _run_hook_cmd(hook, ["niri", "msg", "action", "load-config-file"], timeout=10)
    if hook == "niri-colors-config":
        try:
            return ensure_niri_colors_config()
        except (OSError, UnicodeError) as exc:
            return {"hook": hook, "ok": False, "error": str(exc)}
    if hook == "foot-config":
        try:
            return ensure_foot_theme_config()
        except (OSError, UnicodeError) as exc:
            return {"hook": hook, "ok": False, "error": str(exc)}
    if hook == "kitty-config":
        try:
            return ensure_kitty_theme_config()
        except (OSError, UnicodeError) as exc:
            return {"hook": hook, "ok": False, "error": str(exc)}
    if hook == "qt6ct-config":
        try:
            return ensure_qtct_theme_config(6)
        except (OSError, UnicodeError) as exc:
            return {"hook": hook, "ok": False, "error": str(exc)}
    if hook == "qt5ct-config":
        try:
            return ensure_qtct_theme_config(5)
        except (OSError, UnicodeError) as exc:
            return {"hook": hook, "ok": False, "error": str(exc)}
    if hook == "tmux-source":
        if not shutil.which("tmux"):
            return {"hook": hook, "ok": True, "skipped": True, "reason": "tmux not found"}
        return _run_hook_cmd(hook, ["tmux", "source-file", str(home() / ".config" / "tmux" / "vgs-theme.conf")])
    if hook == "ghostty-reload":
        return reload_ghostty_hook()
    if hook == "nvim-reload":
        return reload_nvim_hook()
    if hook == "pi-theme-link":
        try:
            agent = home() / ".pi" / "agent" / "themes" / "vgs-theme.json"
            user = home() / ".pi" / "themes" / "vgs-theme.json"
            user.parent.mkdir(parents=True, exist_ok=True)
            if agent.exists() or agent.is_symlink():
                if user.exists() or user.is_symlink():
                    user.unlink()
                user.symlink_to(agent)
            return {"hook": hook, "ok": True}
        except Exception as exc:
            return {"hook": hook, "ok": False, "error": str(exc)}
    if hook == "shell-reload":
        cli = resolve_vshell_cli()
        result = _run_hook_cmd(hook, [cli, "ipc", "call", "theme", "reload"], timeout=5)
        # During hermetic/CI smoke there may be no running qs IPC service. Theme files are still valid.
        if not result.get("ok") and ("Failed to connect" in (result.get("stderr") or "") or "No such file" in (result.get("stderr") or "")):
            result["optional"] = True
        return result
    if hook == "chromium-policy":
        ok = write_chromium_policy(roles)
        return {"hook": hook, "ok": ok, "optional": True, "error": "chromium policy needs root" if not ok else ""}
    if hook == "gtk-settings":
        return apply_gtk_settings_hook(roles)
    if hook == "claude-theme":
        return apply_claude_theme_hook(roles)
    if hook == "kitty-reload":
        return signal_reload_hook("kitty-reload", "kitty", signal.SIGUSR1)
    if hook == "btop-reload":
        # Select the installed theme (else btop ignores vgs.theme), then reload.
        selected = ensure_btop_color_theme("vgs")
        result = signal_reload_hook("btop-reload", "btop", signal.SIGUSR2)
        result["colorThemeSelected"] = selected
        return result
    if hook == "vscode-theme":
        return apply_vscode_theme_hook(roles)
    if hook == "icon-theme":
        return apply_icon_theme_hook(roles)
    if hook == "fastfetch-logo":
        return apply_fastfetch_logo_hook(roles)
    if hook == "pywalfox-update":
        return apply_pywalfox_hook(roles)
    if hook == "obsidian-theme":
        return apply_obsidian_theme_hook(roles)
    return {"hook": hook, "ok": True, "skipped": True, "reason": "unknown hook"}


def apply_pywalfox_hook(roles: Dict[str, str]) -> Dict[str, Any]:
    if not shutil.which("pywalfox"):
        return {"hook": "pywalfox-update", "ok": True, "skipped": True, "reason": "pywalfox not found"}
    mode = (roles.get("theme_type") or "dark").lower()
    mode_result = _run_hook_cmd("pywalfox-update", ["pywalfox", mode], timeout=5)
    update_result = _run_hook_cmd("pywalfox-update", ["pywalfox", "update"], timeout=5)
    ok = bool(mode_result.get("ok") and update_result.get("ok"))
    return {"hook": "pywalfox-update", "ok": ok, "optional": True, "mode": mode,
            "error": "; ".join(x for x in (mode_result.get("stderr", ""), update_result.get("stderr", "")) if x) if not ok else ""}


def apply_obsidian_theme_hook(roles: Dict[str, str]) -> Dict[str, Any]:
    """Install the generated (or curated) obsidian CSS as a 'VGS' theme in
    every vault registered in ~/.config/obsidian/obsidian.json.
    The user selects the VGS theme once in Obsidian's appearance settings."""
    css = generated_dir() / "obsidian.css"
    if not css.exists():
        return {"hook": "obsidian-theme", "ok": True, "skipped": True, "reason": "no obsidian css generated"}
    registry = home() / ".config" / "obsidian" / "obsidian.json"
    if not registry.exists():
        return {"hook": "obsidian-theme", "ok": True, "skipped": True, "reason": "no obsidian vault registry"}
    try:
        vaults = json.loads(registry.read_text()).get("vaults") or {}
    except Exception as exc:
        return {"hook": "obsidian-theme", "ok": False, "error": f"unreadable vault registry: {exc}"}
    manifest = {
        "name": "VGS",
        "version": "1.0.0",
        "minAppVersion": "0.16.0",
        "description": "Synced with the current VanillaGreen Shell theme",
        "author": "VGS",
    }
    synced: List[str] = []
    for vault in vaults.values():
        vault_path = Path(str(vault.get("path") or ""))
        if not (vault_path / ".obsidian").is_dir():
            continue
        theme_dir = vault_path / ".obsidian" / "themes" / "VGS"
        if not (theme_dir / "manifest.json").exists():
            write_file(theme_dir / "manifest.json", json.dumps(manifest, indent=2) + "\n")
        write_file(theme_dir / "theme.css", css.read_text())
        synced.append(str(vault_path))
    if not synced:
        return {"hook": "obsidian-theme", "ok": True, "skipped": True, "reason": "no vaults found"}
    return {"hook": "obsidian-theme", "ok": True, "vaults": synced}


def ensure_btop_color_theme(value: str = "vgs") -> bool:
    """Point btop.conf at the VGS-installed theme. Installing
    ~/.config/btop/themes/vgs.theme does NOT select it — btop keeps whatever
    `color_theme` it had (often "Default"/"TTY", which renders greyscale), so
    the theme file is ignored until we set the name here."""
    conf = home() / ".config" / "btop" / "btop.conf"
    line = f'color_theme = "{value}"'
    try:
        if conf.exists():
            text = conf.read_text()
            if re.search(r'^\s*color_theme\s*=', text, re.M):
                new = re.sub(r'^\s*color_theme\s*=.*$', line, text, count=1, flags=re.M)
            else:
                new = text.rstrip("\n") + "\n" + line + "\n"
            if new != text:
                write_file(conf, new)
        else:
            conf.parent.mkdir(parents=True, exist_ok=True)
            write_file(conf, line + "\n")
        return True
    except Exception as exc:
        eprint(f"btop color_theme select failed: {exc}")
        return False


def signal_reload_hook(hook: str, comm: str, sig: int) -> Dict[str, Any]:
    pids = process_pids_by_comm(comm)
    if not pids:
        return {"hook": hook, "ok": True, "skipped": True, "reason": f"no running {comm} process"}
    signaled: List[int] = []
    failures: List[str] = []
    for pid in pids:
        try:
            os.kill(pid, sig)
            signaled.append(pid)
        except ProcessLookupError:
            continue
        except Exception as exc:
            failures.append(f"{pid}: {exc}")
    return {"hook": hook, "ok": not failures, "pids": signaled, "error": "; ".join(failures)}


VSCODE_VARIANTS = [
    {"id": "vscode", "ext": "~/.vscode/extensions", "settings": "~/.config/Code/User/settings.json", "cli": ["code"]},
    {"id": "code-oss", "ext": "~/.vscode-oss/extensions", "settings": "~/.config/Code - OSS/User/settings.json", "cli": ["code-oss"]},
    {"id": "vscodium", "ext": "~/.vscode-oss/extensions", "settings": "~/.config/VSCodium/User/settings.json", "cli": ["codium", "vscodium"]},
    {"id": "cursor", "ext": "~/.cursor/extensions", "settings": "~/.config/Cursor/User/settings.json", "cli": ["cursor"]},
]


def hook_state_file() -> Path:
    return state_dir() / "theme-hooks.json"


def load_hook_state() -> Dict[str, Any]:
    try:
        return json.loads(hook_state_file().read_text())
    except Exception:
        return {}


def save_hook_state(state: Dict[str, Any]) -> None:
    ensure_dirs()
    write_file(hook_state_file(), json.dumps(state, indent=2) + "\n")


def _vgs_vscode_labels(ext_dir: Path) -> set:
    """Theme labels the local vgs.vgs-theme extension contributes -- the themes VGS
    owns and may switch between freely."""
    labels: set = set()
    pkg = ext_dir / "vgs.vgs-theme-1.0.0" / "package.json"
    try:
        data = json.loads(pkg.read_text())
        for theme in data.get("contributes", {}).get("themes", []):
            if theme.get("label"):
                labels.add(str(theme["label"]))
    except Exception:
        pass
    return labels


def _set_vscode_color_theme(settings_path: Path, theme_name: str, state: Dict[str, Any], vgs_labels: set) -> str:
    """Point workbench.colorTheme at `theme_name`. VGS owns every theme it
    contributes, so a switch always takes effect when the live theme is one VGS
    provides -- we only step aside for a genuinely foreign theme the user picked
    (a VS Code builtin or a non-VGS extension). The previous check keyed off our
    own saved pointer, which permanently locked out switching once that pointer
    drifted from the live value (e.g. pointer="Dracula" while live="Eldritch"
    blocked every subsequent VGS theme switch)."""
    try:
        data = json.loads(settings_path.read_text()) if settings_path.exists() else {}
    except Exception as exc:
        return f"unreadable settings ({exc})"
    pointers = state.setdefault("vscodePointers", {})
    key = str(settings_path)
    current = data.get("workbench.colorTheme")
    managed = pointers.get(key)
    if current == theme_name:
        pointers[key] = theme_name
        return ""
    # Leave a genuinely foreign theme alone: one VGS does not provide AND that we
    # did not set ourselves. A VGS-owned live theme (even if our pointer drifted)
    # stays ours to switch.
    if current and managed and current not in vgs_labels and current != managed:
        pointers[key] = current
        return "user-set foreign theme left alone"
    data["workbench.colorTheme"] = theme_name
    settings_path.parent.mkdir(parents=True, exist_ok=True)
    write_file(settings_path, json.dumps(data, indent=2) + "\n")
    pointers[key] = theme_name
    return ""


def _vscode_extension_installed(ext_dir: Path, ext_id: str) -> bool:
    if not ext_dir.is_dir() or not ext_id:
        return False
    prefix = ext_id.lower() + "-"
    for entry in ext_dir.iterdir():
        name = entry.name.lower()
        if name == ext_id.lower() or name.startswith(prefix):
            return True
    return False


def _vscode_install_extension(variant: Dict[str, Any], ext_dir: Path, ext_id: str) -> str:
    """Install a marketplace extension through the variant's own CLI.
    Returns an empty string on success, else a reason."""
    cli = next((c for c in variant["cli"] if shutil.which(c)), "")
    if not cli:
        return f"no CLI ({'/'.join(variant['cli'])}) to install {ext_id}"
    result = _run_hook_cmd("vscode-theme", [cli, "--install-extension", ext_id], timeout=90)
    if not result.get("ok"):
        return f"install of {ext_id} failed: {result.get('stderr') or result.get('error') or 'unknown error'}"
    if not _vscode_extension_installed(ext_dir, ext_id):
        return f"{ext_id} still missing after install"
    return ""


def _vgs_theme_slug(name: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")
    return slug or "vgs"


def _read_vgs_theme_name(theme_file: Path) -> str:
    """The theme's display name from its JSON `name` (JSONC-tolerant: some source
    bundles ship comments/trailing commas)."""
    try:
        txt = theme_file.read_text()
        txt = re.sub(r"/\*.*?\*/", "", txt, flags=re.S)
        txt = re.sub(r"(^|[^:])//[^\n]*", lambda m: m.group(1), txt)
        txt = re.sub(r",(\s*[}\]])", r"\1", txt)
        name = json.loads(txt).get("name")
        return str(name) if name else "VGS"
    except Exception:
        return "VGS"


def _all_bundled_vscode_themes() -> List[Tuple[str, str, str, str]]:
    """(slug, label, uiTheme, content) for every theme package shipping a bundled
    vscode-theme.json (builtin + user; user overlays builtin by name). Registering
    them all up front is what lets VSCodium know every theme label at startup: a
    running VSCodium cannot learn a theme contributed after it launched, so
    registering only applied themes left a just-applied theme's colorTheme pointing
    at an unknown label until the next reload. The bundled source files carry no
    {role} placeholders, but each is run through augment_vscode_colors() with the
    theme's own role map so the tab-state treatment (active label/border, no bottom
    border, modified-tab colors) applies here too — this is the copy VSCodium's
    theme picker actually loads. Derivation is deterministic per source, so the
    extension stays byte-stable across applies."""
    bp_by_key: Dict[str, Dict[str, Any]] = {}
    for bp in list_themes():
        for key in (str(bp.get("name") or ""), Path(bp.get("path") or "").stem):
            k = key.strip().lower()
            if k:
                bp_by_key.setdefault(k, bp)
    out: Dict[str, Tuple[str, str, str, str]] = {}
    for base in (builtin_themes_dir(), user_themes_dir()):
        if not base.is_dir():
            continue
        for tdir in sorted(base.iterdir()):
            if tdir.name in RESERVED_THEME_SUBDIRS or not tdir.is_dir():
                continue
            vf = tdir / "apps" / "vscode-theme.json"
            if not vf.is_file():
                continue
            try:
                content = vf.read_text()
            except OSError:
                continue
            label = _read_vgs_theme_name(vf)
            mode = "dark"
            with contextlib.suppress(Exception):
                mode = (json.loads((tdir / "theme.json").read_text()).get("mode") or "dark").lower()
            bp = bp_by_key.get(tdir.name.lower())
            roles = target_roles(bp) if bp else {"accent": "#808080", "theme_type": mode}
            content = augment_vscode_colors(content, roles)
            ui = "vs" if mode == "light" else "vs-dark"
            out[_vgs_theme_slug(label)] = (_vgs_theme_slug(label), label, ui, content)
    return list(out.values())


def _install_vgs_vscode_extension(ext_dir: Path, theme_file: Path, mode: str) -> str:
    """Install/refresh the local vgs.vgs-theme extension. Each VGS theme is
    contributed under its own distinct label (from the theme JSON's `name`, e.g.
    "VGS Artzen") so VSCodium shows a matching theme and a live switch actually
    changes `workbench.colorTheme` (a single shared "VGS" label never refreshes).
    Applied themes accumulate in a manifest so previously-seen names keep
    resolving. Returns the label to point colorTheme at."""
    ext_root = ext_dir / "vgs.vgs-theme-1.0.0"
    themes_dir = ext_root / "themes"
    name = _read_vgs_theme_name(theme_file)
    slug = _vgs_theme_slug(name)
    ui = "vs" if mode == "light" else "vs-dark"
    content = theme_file.read_text()
    # Per-theme snapshot + shared file (the generic "VGS" alias points here).
    write_file(themes_dir / f"{slug}.json", content)
    write_file(themes_dir / "vgs-theme.json", content)
    # Accumulate applied themes so their labels keep resolving across switches.
    manifest_path = ext_root / "vgs-manifest.json"
    try:
        manifest = json.loads(manifest_path.read_text())
        if not isinstance(manifest, dict):
            manifest = {}
    except Exception:
        manifest = {}
    manifest[slug] = {"label": name, "uiTheme": ui}
    # Register EVERY bundled theme so VSCodium knows all labels at startup, not
    # just the ones applied so far. Write files only on change so the extension
    # stays byte-stable across applies (a mutating extension makes VSCodium reload
    # it every time).
    for b_slug, b_label, b_ui, b_content in _all_bundled_vscode_themes():
        tf = themes_dir / f"{b_slug}.json"
        try:
            unchanged = tf.exists() and tf.read_text() == b_content
        except OSError:
            unchanged = False
        if not unchanged:
            write_file(tf, b_content)
        manifest[b_slug] = {"label": b_label, "uiTheme": b_ui}
    contributes: List[Dict[str, str]] = []
    seen = set()
    for sl, meta in sorted(manifest.items()):
        label = str(meta.get("label") or sl)
        if label in seen:
            continue
        seen.add(label)
        contributes.append({"label": label, "uiTheme": str(meta.get("uiTheme") or "vs-dark"),
                            "path": f"./themes/{sl}.json"})
    # Back-compat generic label -> current theme.
    if "VGS" not in seen:
        contributes.append({"label": "VGS", "uiTheme": ui, "path": "./themes/vgs-theme.json"})
    package = {
        "name": "vgs-theme", "displayName": "VGS Theme", "publisher": "vgs",
        "version": "1.0.0", "engines": {"vscode": "^1.60.0"}, "categories": ["Themes"],
        "contributes": {"themes": contributes},
    }
    write_file(ext_root / "package.json", json.dumps(package, indent=2) + "\n")
    write_file(manifest_path, json.dumps(manifest, indent=2) + "\n")
    _register_vgs_vscode_extension(ext_dir)
    return name


# Stable identifiers so the entry is idempotent across installs.
_VGS_VSCODE_UUID = "b6f8e2a0-1c3d-4e5f-8a9b-0c1d2e3f4a5b"
_VGS_VSCODE_PUBLISHER_ID = "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f"


def _register_vgs_vscode_extension(ext_dir: Path) -> None:
    """VS Code / VSCodium only load extensions listed in `extensions.json`; a
    local unpacked extension dir is otherwise ignored (no theme appears at all,
    no matter how many reloads). Add/refresh our entry so the extension loads."""
    reg = ext_dir / "extensions.json"
    if not reg.exists():
        return  # only touch a real, initialized installation
    try:
        entries = json.loads(reg.read_text())
    except Exception:
        return
    if not isinstance(entries, list):
        return
    ext_root = ext_dir / "vgs.vgs-theme-1.0.0"
    entries = [e for e in entries
               if str((e.get("identifier") or {}).get("id", "")).lower() != "vgs.vgs-theme"]
    entries.append({
        "identifier": {"id": "vgs.vgs-theme", "uuid": _VGS_VSCODE_UUID},
        "version": "1.0.0",
        "location": {"$mid": 1, "path": str(ext_root), "scheme": "file"},
        "relativeLocation": "vgs.vgs-theme-1.0.0",
        "metadata": {
            "isApplicationScoped": False, "isMachineScoped": False, "isBuiltin": False,
            "installedTimestamp": int(time.time() * 1000), "pinned": True, "source": "vsix",
            "id": _VGS_VSCODE_UUID, "publisherDisplayName": "vgs",
            "publisherId": _VGS_VSCODE_PUBLISHER_ID, "isPreReleaseVersion": False,
        },
    })
    write_file(reg, json.dumps(entries, indent=0) + "\n")


def apply_vscode_theme_hook(roles: Dict[str, str]) -> Dict[str, Any]:
    gen = generated_dir() / "vscode"
    curated_pointer = gen / "curated.json"
    theme_file = gen / "vgs-theme.json"
    mode = (roles.get("theme_type") or "dark").lower()
    state = load_hook_state()
    applied: List[str] = []
    notes: List[str] = []

    curated: Dict[str, Any] | None = None
    if curated_pointer.exists():
        with contextlib.suppress(Exception):
            curated = json.loads(curated_pointer.read_text())

    for variant in VSCODE_VARIANTS:
        ext_dir = expand_dest(variant["ext"])
        settings_path = expand_dest(variant["settings"])
        # Only touch variants the user actually runs — never create app config dirs.
        if not settings_path.parent.parent.is_dir():
            continue
        # Curated pointer: make sure the referenced marketplace theme actually
        # exists — install it through the variant's own CLI if missing. If that
        # can't be done, fall back to the generated VGS theme rather than leave
        # a dangling colorTheme pointer.
        theme_name = ""
        if curated and curated.get("name"):
            ext_id = str(curated.get("extension") or "")
            if not ext_id or _vscode_extension_installed(ext_dir, ext_id):
                theme_name = str(curated["name"])
            else:
                reason = _vscode_install_extension(variant, ext_dir, ext_id)
                if reason:
                    notes.append(f"{variant['id']}: {reason}; using generated VGS theme")
                else:
                    theme_name = str(curated["name"])
        if not theme_name:
            if not theme_file.exists() or not ext_dir.is_dir():
                continue
            theme_name = _install_vgs_vscode_extension(ext_dir, theme_file, mode)
        note = _set_vscode_color_theme(settings_path, theme_name, state, _vgs_vscode_labels(ext_dir))
        if note:
            notes.append(f"{variant['id']}: {note}")
        else:
            applied.append(str(settings_path))
    save_hook_state(state)
    if not applied and not notes:
        return {"hook": "vscode-theme", "ok": True, "skipped": True, "reason": "no vscode installation found"}
    return {"hook": "vscode-theme", "ok": True, "applied": applied, "notes": notes, "curated": bool(curated)}


def list_installed_icon_themes() -> List[str]:
    """Installed GTK/desktop icon themes (dirs with an index.theme that declares
    icon Directories), for the Icons settings picker. Excludes the hicolor
    fallback base and cursor-only themes."""
    ensure_bundled_icon_themes()
    names: set[str] = set()
    bases = ["/usr/share/icons", str(home() / ".local" / "share" / "icons"), str(home() / ".icons")]
    for base in bases:
        d = Path(base)
        if not d.is_dir():
            continue
        for entry in d.iterdir():
            index = entry / "index.theme"
            if not entry.is_dir() or not index.is_file():
                continue
            if entry.name in ("hicolor", "default"):
                continue
            try:
                text = index.read_text(errors="ignore")
            except OSError:
                continue
            # Real icon themes declare Directories=; cursor-only themes don't.
            if re.search(r"^\s*Directories\s*=", text, re.M):
                names.add(entry.name)
    return sorted(names, key=str.lower)


def bundled_icons_dir() -> Path:
    return repo_root() / "config" / "vshell" / "icons"


def ensure_bundled_icon_themes() -> List[str]:
    """Make VGS-bundled icon themes discoverable by symlinking them into
    ~/.local/share/icons, so a theme's `icons.theme` pointer resolves even
    without a system icon-theme package installed. Idempotent and
    non-destructive: a real system/user install of the same name always wins,
    and an existing real directory is never clobbered."""
    linked: List[str] = []
    src_root = bundled_icons_dir()
    if not src_root.is_dir():
        return linked
    dest_root = home() / ".local" / "share" / "icons"
    system_root = Path("/usr/share/icons")
    with contextlib.suppress(OSError):
        dest_root.mkdir(parents=True, exist_ok=True)
    for theme_dir in sorted(src_root.iterdir()):
        if not theme_dir.is_dir():
            continue
        name = theme_dir.name
        if (system_root / name).is_dir():
            continue  # a real system install of this theme wins
        link = dest_root / name
        if link.is_symlink():
            if link.resolve() == theme_dir.resolve():
                continue
            with contextlib.suppress(OSError):
                link.unlink()
        elif link.exists():
            continue  # a real user directory already provides it
        with contextlib.suppress(OSError):
            link.symlink_to(theme_dir)
            linked.append(name)
    return linked


def apply_icon_theme_hook(roles: Dict[str, str]) -> Dict[str, Any]:
    ensure_bundled_icon_themes()
    pointer = generated_dir() / "icons.theme"
    if not pointer.exists():
        return {"hook": "icon-theme", "ok": True, "skipped": True, "reason": "theme ships no icons.theme"}
    name = pointer.read_text().strip()
    if not name:
        return {"hook": "icon-theme", "ok": True, "skipped": True, "reason": "empty icons.theme"}
    if not shutil.which("gsettings"):
        return {"hook": "icon-theme", "ok": True, "skipped": True, "reason": "gsettings not found"}
    settings = load_settings()
    # A user "Always use these" selection (fixed iconThemeDark/Light, or per-mode)
    # wins over the theme's icon set — don't let theme apply overwrite it. The old
    # `iconTheme` key is a derived QML value, never persisted, so gate on the real
    # stored fields IconsTab writes.
    dark = settings.get("iconThemeDark") or "System Default"
    light = settings.get("iconThemeLight") or "System Default"
    if settings.get("iconThemePerMode") or dark not in ("", "System Default") or light not in ("", "System Default"):
        return {"hook": "icon-theme", "ok": True, "skipped": True, "reason": "icon theme managed in VGS settings"}
    installed = any((Path(base).expanduser() / name).is_dir() for base in ("/usr/share/icons", "~/.local/share/icons"))
    if not installed:
        return {"hook": "icon-theme", "ok": True, "skipped": True, "reason": f"icon theme not installed: {name}"}
    return _run_hook_cmd("icon-theme", ["gsettings", "set", "org.gnome.desktop.interface", "icon-theme", name], timeout=5)


def apply_fastfetch_logo_hook(roles: Dict[str, str]) -> Dict[str, Any]:
    """Render a theme-matched fastfetch logo from the theme wallpaper role.

    fastfetch shows a static image logo; this derives one from the theme's
    wallpaper role (centre-cropped square, downscaled) so the
    banner tracks the theme. Output is VGS-named at
    ``~/.config/vshell/generated/fastfetch/logo.jpg``. If fastfetch has no user
    config yet, VGS installs its portable boxed-layout seed. Existing fastfetch
    configs are never replaced.
    """
    hook = "fastfetch-logo"
    config_home = Path(os.environ.get("XDG_CONFIG_HOME") or (home() / ".config")) / "fastfetch"
    config_jsonc = config_home / "config.jsonc"

    # Do not shadow an effective config from another Fastfetch search root.
    # The bundled layout is a first-run convenience, never an override.
    config_roots = [
        Path(os.environ.get("XDG_CONFIG_HOME") or (home() / ".config")) / "fastfetch",
        home() / "fastfetch",
    ]
    config_roots.extend(
        Path(entry) / "fastfetch"
        for entry in (os.environ.get("XDG_CONFIG_DIRS") or "/etc/xdg").split(":")
        if entry
    )
    config_roots.append(Path("/etc/fastfetch"))
    effective_config = next(
        (
            root / filename
            for root in config_roots
            for filename in ("config.jsonc", "config.json")
            if (root / filename).is_file()
        ),
        None,
    )
    config_seeded = False
    if effective_config is None:
        seed = repo_root() / "config" / "vshell" / "fastfetch" / "config.jsonc"
        if seed.is_file():
            write_file(config_jsonc, seed.read_text())
            effective_config = config_jsonc
            config_seeded = True

    wallpaper = roles.get("wallpaper", "")
    fallback_wallpaper = repo_root() / "themes" / "coppernight" / "backgrounds" / "4-cats-anime.jpg"
    used_fallback_wallpaper = False
    if (not wallpaper or not Path(wallpaper).is_file()) and fallback_wallpaper.is_file():
        wallpaper = str(fallback_wallpaper)
        used_fallback_wallpaper = True
    if not wallpaper or not Path(wallpaper).is_file():
        return {
            "hook": hook,
            "ok": True,
            "skipped": True,
            "reason": "no wallpaper for this theme",
            "config": str(effective_config or config_jsonc),
            "configSeeded": config_seeded,
        }
    out_dir = generated_dir() / "fastfetch"
    out = out_dir / "logo.jpg"
    source_state = out_dir / "source.json"
    temp_out = out_dir / f".logo.{os.getpid()}.{time.time_ns()}.jpg"
    try:
        out_dir.mkdir(parents=True, exist_ok=True)
        wallpaper_stat = Path(wallpaper).stat()
        fingerprint = {
            "path": str(Path(wallpaper).resolve()),
            "size": wallpaper_stat.st_size,
            "mtimeNs": wallpaper_stat.st_mtime_ns,
        }
        if out.is_file() and source_state.is_file():
            with contextlib.suppress(OSError, ValueError, TypeError):
                if json.loads(source_state.read_text()) == fingerprint:
                    return {
                        "hook": hook,
                        "ok": True,
                        "path": str(out),
                        "config": str(effective_config or config_jsonc),
                        "configSeeded": config_seeded,
                        "cached": True,
                        "fallbackWallpaper": used_fallback_wallpaper,
                    }
        if Image is not None:
            with Image.open(wallpaper) as im:
                im = im.convert("RGB")
                w, h = im.size
                side = min(w, h)
                left, top = (w - side) // 2, (h - side) // 2
                im = im.crop((left, top, left + side, top + side)).resize((600, 600), Image.LANCZOS)
                im.save(temp_out, "JPEG", quality=90)
        elif shutil.which("magick"):
            proc = run([
                "magick", wallpaper, "-auto-orient", "-resize", "600x600^",
                "-gravity", "center", "-extent", "600x600", "-quality", "90",
                str(temp_out),
            ], timeout=15)
            if proc.returncode != 0:
                raise RuntimeError(proc.stderr.strip() or "ImageMagick failed")
        elif shutil.which("ffmpeg"):
            proc = run([
                "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", wallpaper,
                "-vf", "scale=600:600:force_original_aspect_ratio=increase,crop=600:600",
                "-frames:v", "1", str(temp_out),
            ], timeout=15)
            if proc.returncode != 0:
                raise RuntimeError(proc.stderr.strip() or "ffmpeg failed")
        else:
            # Fastfetch decodes the source image itself. Cropping is an
            # enhancement, so keep minimal installations functional by copying
            # the wallpaper bytes to the stable logo path.
            shutil.copy2(wallpaper, temp_out)
        temp_out.replace(out)
        write_file(source_state, json.dumps(fingerprint, sort_keys=True) + "\n")
    except Exception as exc:  # never fail an apply over a decorative logo
        with contextlib.suppress(OSError):
            temp_out.unlink()
        # A newly seeded config must never point at a missing image. Preserve an
        # existing last-known-good logo; otherwise install the shipped default
        # wallpaper without conversion as the guaranteed low-dependency fallback.
        if not out.is_file() and fallback_wallpaper.is_file():
            try:
                shutil.copy2(fallback_wallpaper, temp_out)
                temp_out.replace(out)
                fallback_stat = fallback_wallpaper.stat()
                fallback_fingerprint = {
                    "path": str(fallback_wallpaper.resolve()),
                    "size": fallback_stat.st_size,
                    "mtimeNs": fallback_stat.st_mtime_ns,
                }
                write_file(source_state, json.dumps(fallback_fingerprint, sort_keys=True) + "\n")
                return {
                    "hook": hook,
                    "ok": True,
                    "path": str(out),
                    "config": str(effective_config or config_jsonc),
                    "configSeeded": config_seeded,
                    "fallbackWallpaper": True,
                    "conversionError": str(exc),
                }
            except Exception:
                with contextlib.suppress(OSError):
                    temp_out.unlink()
        if config_seeded and not out.is_file():
            with contextlib.suppress(OSError):
                config_jsonc.unlink()
        return {"hook": hook, "ok": True, "skipped": True, "optional": True, "error": str(exc)}
    return {
        "hook": hook,
        "ok": True,
        "path": str(out),
        "config": str(effective_config or config_jsonc),
        "configSeeded": config_seeded,
        "fallbackWallpaper": used_fallback_wallpaper,
    }


def _quit_windowless_nautilus() -> Dict[str, Any]:
    """Quit Nautilus's persistent D-Bus service when it has no open windows.

    GTK4 reads ~/.config/gtk-4.0/gtk.css once per process and never re-reads
    it, so the lingering org.gnome.Nautilus service keeps opening windows in
    the previous theme's palette after an apply. Quitting the windowless
    service is invisible and makes the next Files window start fresh with the
    regenerated stylesheet. A service that still owns windows is left alone —
    `nautilus -q` would close them.
    """
    if not shutil.which("nautilus"):
        return {"ok": True, "skipped": True, "reason": "nautilus not found"}
    if not process_pids_by_comm("nautilus"):
        return {"ok": True, "skipped": True, "reason": "not running"}
    try:
        if os.environ.get("NIRI_SOCKET") and shutil.which("niri"):
            clients = subprocess.run(["niri", "msg", "-j", "windows"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=3)
            open_windows = [c for c in json.loads(clients.stdout or "[]") if c.get("app_id") == "org.gnome.Nautilus"]
        elif shutil.which("hyprctl"):
            clients = subprocess.run(["hyprctl", "clients", "-j"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=3)
            open_windows = [c for c in json.loads(clients.stdout or "[]") if c.get("class") == "org.gnome.Nautilus"]
        else:
            # Cannot prove there are no open windows; leave the service alone.
            return {"ok": True, "skipped": True, "reason": "compositor window query unavailable"}
    except Exception as exc:
        return {"ok": True, "skipped": True, "reason": f"window probe failed: {exc}"}
    if open_windows:
        return {"ok": True, "skipped": True, "reason": f"{len(open_windows)} window(s) open"}
    # `nautilus -q` exits 255 even on success (remote GApplication quit), so
    # confirm by watching the service actually go away instead.
    try:
        subprocess.run(["nautilus", "-q"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
    except Exception as exc:
        return {"ok": False, "error": f"nautilus -q failed: {exc}"}
    for _ in range(10):
        if not process_pids_by_comm("nautilus"):
            return {"ok": True, "quit": True}
        time.sleep(0.2)
    return {"ok": False, "error": "nautilus still running after quit"}


def apply_gtk_settings_hook(roles: Dict[str, str]) -> Dict[str, Any]:
    mode = (roles.get("theme_type") or "dark").lower()
    color_scheme = "prefer-light" if mode == "light" else "prefer-dark"
    gtk_theme = "adw-gtk3" if mode == "light" else "adw-gtk3-dark"
    if not (Path("/usr/share/themes") / gtk_theme).exists():
        gtk_theme = "Adwaita" if mode == "light" else "Adwaita-dark"
    if not shutil.which("gsettings"):
        return {"hook": "gtk-settings", "ok": True, "skipped": True, "reason": "gsettings not found"}
    failures: List[str] = []
    # Chromium/Electron apps (Slack, Claude Desktop, 1Password) only re-read the
    # OS theme when they catch the portal color-scheme SettingChanged signal, and
    # they silently drop it if their renderer is momentarily busy. Writing gtk-theme
    # and color-scheme back-to-back fires two org.gnome.desktop.interface signals
    # microseconds apart; landing that amid the theme-apply burst (hyprctl reload,
    # icon-theme write, shell reload) made apps miss it intermittently — and which
    # app missed varied run to run. So set gtk-theme first, let the burst settle,
    # then emit color-scheme alone as the final, quiet signal. That mirrors a bare
    # `gsettings set color-scheme`, which every app follows reliably.
    theme_cmd = ["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtk_theme]
    theme_result = _run_hook_cmd("gtk-settings", theme_cmd, timeout=5)
    if not theme_result.get("ok"):
        failures.append(theme_result.get("stderr") or theme_result.get("error") or " ".join(theme_cmd))
    time.sleep(0.3)
    scheme_cmd = ["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", color_scheme]
    scheme_result = _run_hook_cmd("gtk-settings", scheme_cmd, timeout=5)
    if not scheme_result.get("ok"):
        failures.append(scheme_result.get("stderr") or scheme_result.get("error") or " ".join(scheme_cmd))
    # A quit failure never fails the hook: the gsettings emit above already
    # succeeded and the quit is only a freshness nudge for the next launch.
    nautilus_result = _quit_windowless_nautilus()
    return {"hook": "gtk-settings", "ok": not failures, "colorScheme": color_scheme, "gtkTheme": gtk_theme, "nautilus": nautilus_result, "error": "; ".join(failures)}


FONT_HINTING = {"none", "slight", "medium", "full"}
FONT_SUBPIXEL = {"none", "rgb", "bgr", "vrgb", "vbgr"}
FONT_LCD_FILTER = {"default", "light", "legacy", "none"}
FC_HINT_CONST = {"none": "hintnone", "slight": "hintslight", "medium": "hintmedium", "full": "hintfull"}
FC_LCD_CONST = {"default": "lcddefault", "light": "lcdlight", "legacy": "lcdlegacy", "none": "lcdnone"}
GTK_SETTINGS_BEGIN = "# BEGIN VGS font rendering"
GTK_SETTINGS_END = "# END VGS font rendering"


def _choice(value: Any, allowed: Iterable[str], fallback: str) -> str:
    candidate = str(value or "").strip().lower()
    return candidate if candidate in allowed else fallback


def _bool_setting(value: Any, fallback: bool = True) -> bool:
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        return bool(value)
    if isinstance(value, str):
        return value.strip().lower() in {"1", "true", "yes", "on"}
    return fallback


def system_font_env() -> Dict[str, Any]:
    session = (os.environ.get("XDG_SESSION_TYPE") or "").strip().lower()
    desktop = (os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION") or "").strip()
    gsettings_keys: List[str] = []
    if shutil.which("gsettings"):
        with contextlib.suppress(Exception):
            proc = subprocess.run(["gsettings", "list-keys", "org.gnome.desktop.interface"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=3)
            if proc.returncode == 0:
                gsettings_keys = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
    xsettings_managers = []
    for comm in ("gsd-xsettings", "xsettingsd", "xfsettingsd"):
        if process_pids_by_comm(comm):
            xsettings_managers.append(comm)
    return {
        "sessionType": session or "unknown",
        "desktop": desktop or "unknown",
        "isWayland": session == "wayland",
        "isX11": session in {"x11", "xorg"},
        "gsettingsAvailable": bool(gsettings_keys),
        "gsettingsKeys": gsettings_keys,
        "xsettingsManagers": xsettings_managers,
        "fontconfigPath": str(home() / ".config" / "fontconfig" / "conf.d" / "60-vgs-fonts.conf"),
        "gtk3SettingsPath": str(home() / ".config" / "gtk-3.0" / "settings.ini"),
        "gtk4SettingsPath": str(home() / ".config" / "gtk-4.0" / "settings.ini"),
        "qtMechanism": "fontconfig",
    }


def normalized_system_font_settings(settings: Dict[str, Any] | None = None) -> Dict[str, Any]:
    settings = settings or load_settings()
    env = system_font_env()
    wayland = bool(env.get("isWayland"))

    def group(prefix: str) -> Dict[str, Any]:
        subpixel = _choice(settings.get(f"{prefix}Subpixel"), FONT_SUBPIXEL, "none")
        if wayland:
            subpixel = "none"
        hinting = _choice(settings.get(f"{prefix}Hinting"), FONT_HINTING, "slight")
        lcd_filter = _choice(settings.get(f"{prefix}LcdFilter"), FONT_LCD_FILTER, "default")
        return {
            "antialias": _bool_setting(settings.get(f"{prefix}Antialias"), True),
            "hinting": hinting,
            "subpixel": subpixel,
            "lcdFilter": lcd_filter,
            "autohint": _bool_setting(settings.get(f"{prefix}Autohint"), False),
        }

    return {
        "managed": _bool_setting(settings.get("systemFontsManaged"), True),
        "interface": group("systemFontInterface"),
        "monospace": group("systemFontMono"),
        "environment": env,
    }


def _fontconfig_edits(group: Dict[str, Any], indent: str = "    ") -> List[str]:
    antialias = "true" if group["antialias"] else "false"
    hinting_enabled = group["hinting"] != "none"
    hinting = "true" if hinting_enabled else "false"
    hintstyle = FC_HINT_CONST[group["hinting"]]
    rgba = group["subpixel"]
    lcd = FC_LCD_CONST[group["lcdFilter"]]
    autohint = "true" if group["autohint"] else "false"
    return [
        f'{indent}<edit name="antialias" mode="assign"><bool>{antialias}</bool></edit>',
        f'{indent}<edit name="hinting" mode="assign"><bool>{hinting}</bool></edit>',
        f'{indent}<edit name="hintstyle" mode="assign"><const>{hintstyle}</const></edit>',
        f'{indent}<edit name="rgba" mode="assign"><const>{rgba}</const></edit>',
        f'{indent}<edit name="lcdfilter" mode="assign"><const>{lcd}</const></edit>',
        f'{indent}<edit name="autohint" mode="assign"><bool>{autohint}</bool></edit>',
    ]


def render_system_fontconfig(config: Dict[str, Any]) -> str:
    interface = config["interface"]
    mono = config["monospace"]
    lines = [
        '<?xml version="1.0"?>',
        '<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">',
        '<fontconfig>',
        '  <description>VGS managed font rendering</description>',
        "  <match target=\"font\">",
        *_fontconfig_edits(interface, "    "),
        "  </match>",
        "  <match target=\"font\">",
        '    <test name="spacing" compare="eq"><const>mono</const></test>',
        *_fontconfig_edits(mono, "    "),
        "  </match>",
        "</fontconfig>",
        "",
    ]
    return "\n".join(lines)


def _strip_managed_block(lines: List[str]) -> List[str]:
    out: List[str] = []
    skipping = False
    for line in lines:
        if line.strip() == GTK_SETTINGS_BEGIN:
            skipping = True
            continue
        if line.strip() == GTK_SETTINGS_END:
            skipping = False
            continue
        if not skipping:
            out.append(line)
    return out


def _merge_gtk_settings(path: Path, values: Dict[str, str] | None) -> bool:
    original = path.read_text().splitlines() if path.exists() else []
    lines = _strip_managed_block(original)
    if values is None:
        text = "\n".join(lines).rstrip() + ("\n" if lines else "")
        if path.exists() and text != path.read_text():
            write_file(path, text)
            return True
        return False

    block = [GTK_SETTINGS_BEGIN]
    for key, value in values.items():
        block.append(f"{key}={value}")
    block.append(GTK_SETTINGS_END)

    settings_start = -1
    insert_at = len(lines)
    for idx, line in enumerate(lines):
        stripped = line.strip()
        if stripped == "[Settings]":
            settings_start = idx
            insert_at = len(lines)
            for j in range(idx + 1, len(lines)):
                s = lines[j].strip()
                if s.startswith("[") and s.endswith("]"):
                    insert_at = j
                    break
            break

    if settings_start < 0:
        if lines and lines[-1].strip():
            lines.append("")
        lines.extend(["[Settings]", *block])
    else:
        if insert_at > settings_start + 1 and lines[insert_at - 1].strip():
            block = ["", *block]
        lines[insert_at:insert_at] = block

    text = "\n".join(lines).rstrip() + "\n"
    if path.exists() and text == path.read_text():
        return False
    write_file(path, text)
    return True


def _gtk_values(group: Dict[str, Any]) -> Dict[str, str]:
    return {
        "gtk-xft-antialias": "1" if group["antialias"] else "0",
        "gtk-xft-hinting": "0" if group["hinting"] == "none" else "1",
        "gtk-xft-hintstyle": FC_HINT_CONST[group["hinting"]],
        "gtk-xft-rgba": group["subpixel"],
    }


def _gsettings_set_font_rendering(config: Dict[str, Any], reset: bool = False) -> Dict[str, Any]:
    keys = set(config.get("environment", {}).get("gsettingsKeys") or [])
    if not shutil.which("gsettings") or not keys:
        return {"mechanism": "gsettings", "ok": True, "skipped": True, "reason": "schema not available"}
    wanted = [
        ("font-antialiasing", "reset" if reset else ("rgba" if config["interface"]["subpixel"] != "none" and config["interface"]["antialias"] else ("grayscale" if config["interface"]["antialias"] else "none"))),
        ("font-hinting", "reset" if reset else config["interface"]["hinting"]),
        ("font-rgba-order", "reset" if reset else (config["interface"]["subpixel"] if config["interface"]["subpixel"] != "none" else "rgb")),
    ]
    results = []
    failures = []
    for key, value in wanted:
        if key not in keys:
            results.append({"key": key, "skipped": True, "reason": "key missing"})
            continue
        cmd = ["gsettings", "reset", "org.gnome.desktop.interface", key] if reset else ["gsettings", "set", "org.gnome.desktop.interface", key, value]
        result = _run_hook_cmd("system-fonts-gsettings", cmd, timeout=5)
        results.append({"key": key, "ok": result.get("ok"), "stderr": result.get("stderr", "")})
        if not result.get("ok"):
            failures.append(f"{key}: {result.get('stderr') or result.get('error') or 'failed'}")
    return {"mechanism": "gsettings", "ok": not failures, "results": results, "error": "; ".join(failures)}


def apply_system_fonts(reset: bool = False) -> Dict[str, Any]:
    ensure_dirs()
    config = normalized_system_font_settings()
    if reset or not config["managed"]:
        config["managed"] = False

    fc_path = home() / ".config" / "fontconfig" / "conf.d" / "60-vgs-fonts.conf"
    gtk3_path = home() / ".config" / "gtk-3.0" / "settings.ini"
    gtk4_path = home() / ".config" / "gtk-4.0" / "settings.ini"
    changed: List[str] = []
    warnings: List[str] = []

    if config["managed"]:
        text = render_system_fontconfig(config)
        if not fc_path.exists() or fc_path.read_text() != text:
            write_file(fc_path, text)
            changed.append(str(fc_path))
        gtk_values = _gtk_values(config["interface"])
        if _merge_gtk_settings(gtk3_path, gtk_values):
            changed.append(str(gtk3_path))
        if _merge_gtk_settings(gtk4_path, gtk_values):
            changed.append(str(gtk4_path))
        gs = _gsettings_set_font_rendering(config, reset=False)
    else:
        with contextlib.suppress(FileNotFoundError):
            fc_path.unlink()
            changed.append(str(fc_path))
        if _merge_gtk_settings(gtk3_path, None):
            changed.append(str(gtk3_path))
        if _merge_gtk_settings(gtk4_path, None):
            changed.append(str(gtk4_path))
        gs = _gsettings_set_font_rendering(config, reset=True)

    if not gs.get("ok"):
        warnings.append(gs.get("error") or "gsettings failed")

    fc_cache = {"mechanism": "fc-cache", "ok": True, "skipped": True, "reason": "fc-cache not found"}
    if shutil.which("fc-cache"):
        fc_cache = _run_hook_cmd("system-fonts-fc-cache", ["fc-cache", "-f"], timeout=20)
        if not fc_cache.get("ok"):
            warnings.append(fc_cache.get("stderr") or fc_cache.get("error") or "fc-cache failed")

    return {
        "success": not warnings,
        "partial": bool(warnings),
        "managed": config["managed"],
        "changed": changed,
        "environment": config["environment"],
        "effective": {"interface": config["interface"], "monospace": config["monospace"]},
        "mechanisms": [gs, fc_cache],
        "warnings": warnings,
        "restartHint": "gsettings-aware apps may update live; fontconfig and many Qt/browser apps need restart.",
    }


def apply_claude_theme_hook(roles: Dict[str, str]) -> Dict[str, Any]:
    settings_path = home() / ".claude" / "settings.json"
    if not settings_path.exists():
        return {"hook": "claude-theme", "ok": True, "skipped": True, "reason": "~/.claude/settings.json not found"}
    mode = (roles.get("theme_type") or "dark").lower()
    claude_theme = "light-ansi" if mode == "light" else "dark-ansi"
    try:
        data = json.loads(settings_path.read_text())
        if data.get("theme") == claude_theme:
            return {"hook": "claude-theme", "ok": True, "theme": claude_theme, "unchanged": True}
        data["theme"] = claude_theme
        tmp = settings_path.with_name(settings_path.name + f".tmp-{os.getpid()}")
        tmp.write_text(json.dumps(data, indent=2) + "\n")
        os.chmod(tmp, settings_path.stat().st_mode & 0o777)
        tmp.replace(settings_path)
        return {"hook": "claude-theme", "ok": True, "theme": claude_theme}
    except Exception as exc:
        return {"hook": "claude-theme", "ok": False, "error": str(exc)}


def detect_target(cfg: Dict[str, Any]) -> bool:
    """App detection for default-on/off: binary on PATH or config dir present."""
    det = cfg.get("detect")
    if not isinstance(det, dict):
        return True
    for command in det.get("commands", []) or []:
        if shutil.which(str(command)):
            return True
    for path in det.get("paths", []) or []:
        if expand_dest(str(path)).exists():
            return True
    return False


def theme_apps_settings() -> Dict[str, bool]:
    apps = load_settings().get("themeApps")
    if isinstance(apps, dict):
        return {str(k): bool(v) for k, v in apps.items()}
    return {}


def target_enabled(cfg: Dict[str, Any], theme_apps: Dict[str, bool]) -> bool:
    """Shell targets always render; user toggles win; else detection decides."""
    app = str(cfg.get("app") or "")
    if app in ("", "shell"):
        return True
    if app in theme_apps:
        return theme_apps[app]
    return detect_target(cfg)


def set_theme_app_enabled(app: str, enabled: bool) -> Dict[str, bool]:
    """Persist a themeApps toggle into settings.json (merges, atomic write).

    The live shell watches settings.json and reloads external edits, so this
    stays in sync with SettingsData.
    """
    ensure_dirs()
    settings_path = cfg_dir() / "settings.json"
    data: Dict[str, Any] = {}
    if settings_path.exists():
        data = load_required_json_file(settings_path)
    apps = data.get("themeApps")
    if not isinstance(apps, dict):
        apps = {}
    apps[app] = enabled
    data["themeApps"] = apps
    write_file(settings_path, json.dumps(data, indent=2) + "\n")
    return {str(k): bool(v) for k, v in apps.items()}


def set_settings_value(key: str, value: Any) -> Dict[str, Any]:
    ensure_dirs()
    settings_path = cfg_dir() / "settings.json"
    data: Dict[str, Any] = {}
    if settings_path.exists():
        data = load_required_json_file(settings_path)
    data[key] = value
    write_file(settings_path, json.dumps(data, indent=2) + "\n")
    return data


def current_theme_obj() -> Dict[str, Any]:
    """The last applied theme as a full object (palette + curated apps)."""
    current_bp_path = cfg_dir() / "theme-current.json"
    if current_bp_path.exists():
        with contextlib.suppress(Exception):
            bp = json.loads(current_bp_path.read_text())
            # Re-resolve the package so curated apps/ files reflect disk state.
            pkg = find_theme(str(bp.get("name") or "")) if bp.get("name") else None
            return pkg if pkg and pkg.get("package") else bp
    return find_theme(current_theme().get("name", "")) or blueprint_from_current_theme()


def theme_apps_inventory() -> List[Dict[str, Any]]:
    """Per-app view over targets: toggle state, detection, curated status."""
    theme_apps = theme_apps_settings()
    cur = current_theme_obj()
    curated_available = set((cur.get("apps") or {}).keys())
    apps: Dict[str, Dict[str, Any]] = {}
    for cfg_path in sorted(targets_dir().glob("*/config.json")):
        cfg = json.loads(cfg_path.read_text())
        app = str(cfg.get("app") or cfg_path.parent.name)
        entry = apps.setdefault(app, {
            "app": app, "targets": [], "destinations": [], "curatedFiles": [],
            "detected": False, "always": app in ("", "shell"),
        })
        entry["targets"].append(cfg_path.parent.name)
        if cfg.get("destination"):
            entry["destinations"].append(cfg["destination"])
        if cfg.get("curatedFile"):
            entry["curatedFiles"].append(cfg["curatedFile"])
        entry["detected"] = entry["detected"] or detect_target(cfg)
    out: List[Dict[str, Any]] = []
    for app, entry in sorted(apps.items()):
        configured = theme_apps.get(app)
        enabled = True if entry["always"] else (configured if configured is not None else entry["detected"])
        entry["enabled"] = bool(enabled)
        entry["configured"] = configured is not None
        entry["curated"] = any(name in curated_available for name in entry["curatedFiles"])
        entry["curatedFiles"] = sorted(set(entry["curatedFiles"]))
        out.append(entry)
    return out


def _strip_jsonc(text: str) -> str:
    """Strip // and /* */ comments and trailing commas outside of strings so a
    JSONC document parses with the strict json module."""
    out: List[str] = []
    i, n = 0, len(text)
    in_str = esc = False
    while i < n:
        c = text[i]
        if in_str:
            out.append(c)
            if esc:
                esc = False
            elif c == "\\":
                esc = True
            elif c == '"':
                in_str = False
            i += 1
            continue
        if c == '"':
            in_str = True
            out.append(c)
            i += 1
            continue
        if c == "/" and i + 1 < n and text[i + 1] == "/":
            while i < n and text[i] not in "\r\n":
                i += 1
            continue
        if c == "/" and i + 1 < n and text[i + 1] == "*":
            i += 2
            while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
                i += 1
            i += 2
            continue
        out.append(c)
        i += 1
    # Trailing commas: a comma whose next non-space char closes an object/array.
    return re.sub(r",(\s*[}\]])", r"\1", "".join(out))


def augment_vscode_colors(text: str, roles: Dict[str, str]) -> str:
    """Fill in editor-tab state colors for a curated VS Code theme so every theme
    shows a clearly active tab and theme-colored (not VS Code's default blue)
    modified-tab borders.

    Curated themes hand-author their own palette, so values are derived from the
    theme's OWN colors (with role-map fallbacks). We only strengthen the active
    tab when it is near-indistinguishable from inactive tabs, but always ensure
    the modified-border keys exist — those are what stop VS Code falling back to
    its built-in blue on changed files. Parse failures fall back to verbatim.

    VS Code theme files are JSONC (comments + trailing commas allowed), so parse
    leniently; the rendered destination is emitted as strict JSON.
    """
    try:
        data = json.loads(_strip_jsonc(text))
    except Exception:
        return text
    colors = data.get("colors")
    if not isinstance(colors, dict):
        return text

    dark = str(roles.get("theme_type") or "dark").lower() != "light"
    prefer = "#ffffff" if dark else "#000000"
    accent = clean_hex(roles.get("accent") or roles.get("primary") or prefer)

    def hex6(v: Any) -> str | None:
        # Normalize #rgb / #rgba / #rrggbb / #rrggbbaa to opaque #rrggbb; alpha is
        # dropped because it is meaningless for the luminance/blend math here.
        if not isinstance(v, str):
            return None
        s = v.strip().lstrip("#")
        if len(s) in (3, 4):
            s = "".join(ch * 2 for ch in s[:3])
        elif len(s) in (6, 8):
            s = s[:6]
        else:
            return None
        try:
            int(s, 16)
        except ValueError:
            return None
        return "#" + s.lower()

    def pick(*keys: str) -> str | None:
        for k in keys:
            h = hex6(colors.get(k))
            if h:
                return h
        return None

    editor_bg = pick("editor.background") or clean_hex(roles.get("background") or ("#101318" if dark else "#f4f4f4"))

    def alpha_of(v: Any) -> float:
        if not isinstance(v, str):
            return 1.0
        s = v.strip().lstrip("#")
        if len(s) == 4:
            return int(s[3] * 2, 16) / 255.0
        if len(s) == 8:
            return int(s[6:8], 16) / 255.0
        return 1.0

    def effective(*keys: str) -> str | None:
        # Opaque color as actually seen: composite the theme's value (which may be
        # translucent, e.g. rose-pine tabs) over the editor background.
        for k in keys:
            h = hex6(colors.get(k))
            if h:
                return blend(editor_bg, h, alpha_of(colors.get(k)))
        return None

    # The active tab's background is left to the theme; the active signal is the
    # accent top border + accent label (the Catppuccin model). Composite the
    # theme's active bg (may be translucent, e.g. rose-pine) only to judge label
    # legibility.
    active_bg = effective("tab.activeBackground") or editor_bg

    # 1. Accent-colored active label. Keep it a real accent: nudge lightness in a
    #    hue-preserving way only when contrast is too low, instead of blending
    #    toward white (which reads as plain bright body text, not an accent).
    fg = accent
    guard = 0
    while contrast_ratio(fg, active_bg) < 3.0 and guard < 8:
        fg = lighten(fg, 0.12) if dark else darken(fg, 0.12)
        guard += 1
    colors["tab.activeForeground"] = fg
    colors["tab.hoverForeground"] = fg
    colors.setdefault("tab.inactiveForeground",
                      pick("descriptionForeground") or clean_hex(roles.get("outline") or "#808080"))

    # 2. No bottom border on any tab; a transparent value overrides any the theme
    #    set. VS Code renders these bottom/underline borders at a fixed 1px.
    for k in ("tab.activeBorder", "tab.unfocusedActiveBorder", "tab.hoverBorder"):
        colors[k] = "#00000000"

    # 3. Accent top border on the active (saved) tab — VS Code draws this at 1px.
    colors["tab.activeBorderTop"] = accent
    colors.setdefault("tab.unfocusedActiveBorderTop", accent + "80")

    # 4. Modified (unsaved) tabs get VS Code's 2px top border in a THEME color
    #    distinct from the saved accent — this replaces its built-in blue. Prefer
    #    the theme's own modified/warning color. (Shows as a border only when
    #    workbench.editor.highlightModifiedTabs is on; otherwise the dot is used.)
    modified = pick("editorGutter.modifiedBackground", "gitDecoration.modifiedResourceForeground") \
        or clean_hex(roles.get("warning") or accent)
    colors.setdefault("tab.activeModifiedBorder", modified)
    colors.setdefault("tab.inactiveModifiedBorder", modified + "66")
    colors.setdefault("tab.unfocusedActiveModifiedBorder", modified + "99")
    colors.setdefault("tab.unfocusedInactiveModifiedBorder", modified + "66")

    data["colors"] = colors
    return json.dumps(data, indent=2) + "\n"


def install_curated_file(path: Path, dest: Path, roles: Dict[str, str], app: str = "") -> None:
    """Install a curated apps/ file verbatim; only {wallpaper}-style path tokens render."""
    text = path.read_text()
    if "{wallpaper}" in text:
        text = text.replace("{wallpaper}", roles.get("wallpaper", ""))
    if app == "vscode" and dest.suffix == ".json":
        text = augment_vscode_colors(text, roles)
    write_file(dest, text)


def apply_theme_obj(bp: Dict[str, Any], only_app: str | None = None,
                    only_target: str | None = None, run_hooks: bool = True) -> Dict[str, Any]:
    """Apply one complete theme batch without interleaving another mutation."""
    with theme_mutation_lock():
        return _apply_theme_obj_unlocked(bp, only_app, only_target, run_hooks)


def _apply_theme_obj_unlocked(bp: Dict[str, Any], only_app: str | None = None,
                              only_target: str | None = None,
                              run_hooks: bool = True) -> Dict[str, Any]:
    ensure_dirs()
    roles = target_roles(bp)
    external_roles = app_target_roles(bp, roles)
    curated_apps: Dict[str, str] = bp.get("apps") or {}
    app_overrides = bp_app_overrides(bp)
    theme_apps = theme_apps_settings()
    rendered: List[str] = []
    curated_used: List[str] = []
    skipped: List[str] = []
    hook_specs: List[Any] = []
    for cfg_path in sorted(targets_dir().glob("*/config.json")):
        if only_target and cfg_path.parent.name != only_target:
            continue
        cfg = json.loads(cfg_path.read_text())
        if only_app and str(cfg.get("app") or "") != only_app:
            continue
        if not target_enabled(cfg, theme_apps):
            # Disabled apps keep their last output; VGS just stops updating it.
            skipped.append(str(cfg.get("app") or cfg_path.parent.name))
            continue
        curated_name = str(cfg.get("curatedFile") or "")
        curated_src = curated_apps.get(curated_name) if curated_name else None
        dest = expand_dest(cfg["destination"]) if cfg.get("destination") else None
        curated_dest = expand_dest(cfg["curatedDestination"]) if cfg.get("curatedDestination") else dest
        # A curated file wins over generation; "additional" targets render the
        # template too because the curated artifact lands beside it (e.g. nvim
        # colorscheme spec next to the always-generated role table).
        additional = cfg.get("curatedMode") == "additional"
        # A curated *theme* file replaces the generated template output at the
        # main destination (e.g. a full vscode-theme.json derived from the
        # theme's nvim colorscheme, for themes with an official nvim colorscheme
        # but no official VS Code extension). Distinct from curatedFile, which is
        # a pointer-style artifact landing at curatedDestination.
        curated_theme_name = str(cfg.get("curatedThemeFile") or "")
        curated_theme_src = curated_apps.get(curated_theme_name) if curated_theme_name else None
        app_id = str(cfg.get("app") or "")
        base_role_map = roles if cfg_path.parent.name == "vgs-shell" else external_roles
        target_role_map = (
            {**base_role_map, **app_overrides[app_id]}
            if app_id in app_overrides else base_role_map
        )
        if curated_src and curated_dest:
            install_curated_file(Path(curated_src), curated_dest, target_role_map)
            rendered.append(str(curated_dest))
            curated_used.append(curated_name)
        elif cfg.get("curatedDestination") and curated_dest:
            # No curated file in this theme: drop the stale curated artifact so
            # consumers fall back to the generated output.
            with contextlib.suppress(OSError):
                curated_dest.unlink()
        if curated_theme_src and dest:
            install_curated_file(Path(curated_theme_src), dest, target_role_map, app_id)
            rendered.append(str(dest))
            curated_used.append(curated_theme_name)
        if cfg.get("template") and dest and (not curated_src or additional) and not curated_theme_src:
            template = cfg_path.parent / cfg["template"]
            text = render_template(template.read_text(), target_role_map)
            write_file(dest, text)
            rendered.append(str(dest))
        if cfg.get("hook"):
            hook_value = cfg["hook"]
            if isinstance(hook_value, list):
                hook_specs.extend(hook_value)
            else:
                hook_specs.append(hook_value)
    hook_results = [run_hook(hook, roles) for hook in hook_specs] if run_hooks else []
    warnings = []
    for result in hook_results:
        if result.get("ok"):
            continue
        msg = result.get("error") or result.get("stderr") or result.get("stdout") or "failed"
        warnings.append(f"{result.get('hook')}: {msg}")
        eprint(f"hook {result.get('hook')} failed: {msg}")
    if not only_app and not only_target:
        current = dict(bp)
        for key in ("path", "builtin", "userDir", "backgrounds", "packagedPreview"):
            current.pop(key, None)
        current["appliedAt"] = int(time.time() * 1000)
        write_file(cfg_dir() / "theme-current.json", json.dumps(current, indent=2) + "\n")
    return {"success": True, "partial": bool(warnings), "name": bp.get("name"), "rendered": rendered, "curated": sorted(set(curated_used)), "skipped": sorted(set(skipped)), "hooks": hook_results, "warnings": warnings, "wallpaper": roles.get("wallpaper", "")}


# --- Theme preview screenshots -------------------------------------------------
#
# Previews are real screenshots: a nested Hyprland session (hidden on the parent
# compositor inside a silent special workspace) runs themed app instances plus a
# minimal Quickshell flyout, then grim captures the virtual output.

PREVIEW_GENERATOR_VERSION = 7
PREVIEW_SIZE = (1920, 1080)
PREVIEW_OUTPUT = "VGSPREVIEW"
# Nested Hyprland sessions surface on the parent as `aquamarine` windows; park them
# fullscreen on the headless staging output, unfocusable, so generation is invisible
# to the user. `no_focus` (never focusable) is deliberate over `no_initial_focus`
# (only the first map): output add/remove warps the pointer across the staging
# output, and with `follow_mouse` on that would otherwise pull keyboard focus onto
# the nested window every capture — stealing the user's focus every few seconds.
# `no_focus` also prevents the on-close focus bounce. Keyboard focus is never needed:
# the nested session runs its exec-rule apps and captures itself.
# Runtime window rules cannot be individually removed, and a config reload (the only
# way to flush them) disturbs the live session — so the rule is simply left
# registered. It only matches nested-compositor windows, and the routine hypr-reload
# hook that runs on every theme apply flushes it naturally.
# The `_VGS_PREVIEW_STAGE` Lua global makes registration idempotent: a config reload
# wipes Lua state (rule AND flag together), so re-running this eval before every shot
# re-arms the rule after a mid-run reload without stacking duplicates. Without the
# re-assert, a theme apply during a long `preview --all` would flush the rule and the
# remaining capture sessions would open focused on the user's active monitor.
# Placement uses `workspace = "... silent"` rather than a `monitor = ...`
# directive: a monitor-move at map switches Hyprland's *active monitor* to the
# staging output even though `no_focus` keeps the window unfocusable, landing
# the user's next keystroke on the invisible output. Silent workspace placement
# switches neither keyboard nor monitor focus; the workspace_rule pins the
# named workspace to the staging output. If that binding ever fails, the
# workspace sits inert (never activated) on a physical monitor — still no
# focus disturbance.
PREVIEW_STAGE_ON_LUA = (
    'if not _VGS_PREVIEW_STAGE then _VGS_PREVIEW_STAGE = true '
    'hl.workspace_rule({ workspace = "name:vgspreview", monitor = "' + PREVIEW_OUTPUT + '", default = true }) '
    'hl.window_rule({ match = { class = "^(aquamarine)$" }, '
    'workspace = "name:vgspreview silent", fullscreen_state = 2, '
    'no_initial_focus = true, no_focus = true }) end'
)
PREVIEW_WINDOW_RULE_LEGACY = f"monitor {PREVIEW_OUTPUT}, class:^(aquamarine)$"
PREVIEW_WINDOW_RULE_LEGACY_NOFOCUS = "nofocus, class:^(aquamarine)$"

PREVIEW_SAMPLE_CODE = '''import QtQuick
import Quickshell
import qs.Common
import qs.Services

Item {
    id: root

    readonly property var log: Log.scoped("VGS")
    property bool osdSurfacesLoaded: true
    property int pendingOsdResumeReloads: 0

    function recreateOsdSurfaces() {
        OSDManager.currentOSDsByScreen = ({});
        osdSurfacesLoaded = false;
        osdSurfaceReloadTimer.restart();
    }

    function showSwitchUserModal() {
        switchUserModalLoader.active = true;
        Qt.callLater(() => {
            if (switchUserModalLoader.item)
                switchUserModalLoader.item.showFromPowerMenu();
        });
    }

    Instantiator {
        id: daemonPluginInstantiator
        asynchronous: true
        model: Object.keys(PluginService.pluginDaemonComponents)

        delegate: Loader {
            id: daemonLoader
            property string pluginId: modelData
            sourceComponent: PluginService.pluginDaemonComponents[pluginId]
        }
    }
}
'''

PREVIEW_SHOWCASE_SCRIPT = r'''#!/usr/bin/env bash
r() { printf '\e[%sm%s\e[0m' "$1" "$2"; }
printf '\e[1m%s\e[0m · %s\n\n' "$VGS_PREVIEW_NAME" "$VGS_PREVIEW_MODE"
for i in 0 1 2 3 4 5 6 7; do printf '\e[4%sm   \e[0m' "$i"; done; printf '\n'
for i in 0 1 2 3 4 5 6 7; do printf '\e[10%sm   \e[0m' "$i"; done; printf '\n\n'
printf '%s\n' "$(r 32 '❯') git status"
printf '%s\n' "On branch $(r 36 main)"
printf '%s\n' "Changes not staged for commit:"
printf '%s\n' "  $(r 31 'modified:   Services/ThemeService.qml')"
printf '%s\n' "  $(r 31 'modified:   bin/vshell-helper')"
printf '\n%s\n' "$(r 32 '❯') make check"
printf '%s\n' "$(r 32 '✓') lint      $(r 90 '0.41s')"
printf '%s\n' "$(r 32 '✓') tests     $(r 90 '2.13s')"
printf '%s\n' "$(r 33 '⚠') coverage  $(r 90 '87%')"
printf '\n%s ' "$(r 32 '❯')"
sleep 3600
'''

# @THEME_LUA@ is substituted with the rendered nvim theme table for the previewed
# blueprint, so highlights use the same role colors the real VGS nvim target gets.
# @THEME_COLORSCHEME@/@THEME_RTP@ carry a curated theme's real colorscheme (parsed
# from apps/neovim.lua) so previews match what the nvim bridge activates.
PREVIEW_NVIM_INIT = '''local t = dofile("@THEME_LUA@")

vim.opt.termguicolors = true
vim.opt.number = true
vim.opt.swapfile = false
vim.opt.showmode = false
vim.opt.ruler = false
vim.opt.laststatus = 3
vim.opt.cursorline = true
vim.opt.signcolumn = "no"
vim.opt.fillchars = { eob = " ", vert = "\\u{2502}" }

local spec_colorscheme = "@THEME_COLORSCHEME@"
local spec_applied = false
if spec_colorscheme ~= "" then
    for dir in string.gmatch("@THEME_RTP@", "[^;]+") do
        if (vim.uv or vim.loop).fs_stat(dir) then
            vim.opt.rtp:prepend(dir)
        end
    end
    vim.o.background = "@THEME_MODE@"
    spec_applied = pcall(vim.cmd.colorscheme, spec_colorscheme)
end

local function hi(group, opts)
    -- With a real colorscheme active, only the preview chrome groups (statusline
    -- mock, fake neo-tree) are ours to define; editor groups stay upstream.
    if spec_applied and not (group:match("^Stl") or group:match("^Tree")) then
        return
    end
    vim.api.nvim_set_hl(0, group, opts)
end
hi("Normal", { fg = t.fg, bg = t.bg })
hi("NormalNC", { fg = t.fg, bg = t.bg })
hi("CursorLine", { bg = t.surface_container })
hi("CursorLineNr", { fg = t.accent, bold = true })
hi("LineNr", { fg = t.muted })
hi("EndOfBuffer", { fg = t.bg, bg = t.bg })
hi("WinSeparator", { fg = t.outline_variant, bg = t.bg })
hi("Visual", { fg = t.selection_fg, bg = t.selection_bg })
hi("Comment", { fg = t.muted, italic = true })
hi("String", { fg = t.green })
hi("Character", { fg = t.green })
hi("Number", { fg = t.yellow })
hi("Float", { fg = t.yellow })
hi("Boolean", { fg = t.yellow })
hi("Constant", { fg = t.yellow })
hi("Identifier", { fg = t.fg })
hi("Function", { fg = t.blue })
hi("Statement", { fg = t.magenta })
hi("Keyword", { fg = t.magenta })
hi("Conditional", { fg = t.magenta })
hi("Repeat", { fg = t.magenta })
hi("Operator", { fg = t.cyan })
hi("Type", { fg = t.yellow })
hi("StorageClass", { fg = t.magenta })
hi("Special", { fg = t.cyan })
hi("PreProc", { fg = t.magenta })
hi("Include", { fg = t.magenta })
hi("Delimiter", { fg = t.fg })
hi("MatchParen", { fg = t.accent, bold = true })
hi("Title", { fg = t.blue, bold = true })
hi("Directory", { fg = t.blue })
hi("StatusLine", { fg = t.status_fg, bg = t.status_bg })
hi("StatusLineNC", { fg = t.status_muted, bg = t.status_bg })
hi("StlMode", { fg = t.on_primary, bg = t.accent, bold = true })
hi("StlMeta", { fg = t.status_fg, bg = t.surface_container_high })
hi("StlText", { fg = t.status_muted, bg = t.status_bg })
hi("TreeNormal", { fg = t.fg, bg = t.surface_container_low })
hi("TreeTitle", { fg = t.blue, bold = true })
hi("TreeRoot", { fg = t.fg, bold = true })
hi("TreeDir", { fg = t.blue })
hi("TreeFile", { fg = t.fg })
hi("TreeMuted", { fg = t.muted })

vim.o.statusline = table.concat({
    "%#StlMode#  NORMAL ",
    "%#StlMeta# \\u{e0a0} main \\u{00b7} +19 ",
    "%#StlText# %f %m",
    "%=",
    "%#StlText# utf-8 \\u{2502} %{&filetype} \\u{2502} 1%% ",
    "%#StlMode# %l:%c ",
})

local tree = {
    " Neo-tree",
    "",
    " \\u{f07b}  ~/dev/vgs",
    " \\u{203a} \\u{f07b}  .agents",
    " \\u{203a} \\u{f07b}  .claude",
    " \\u{203a} \\u{f07b}  bin",
    " \\u{203a} \\u{f07b}  config",
    " \\u{203a} \\u{f07b}  docs",
    " \\u{2304} \\u{f07c}  quickshell",
    "  \\u{2304} \\u{f07c}  vshell",
    "   \\u{203a} \\u{f07b}  Common",
    "   \\u{203a} \\u{f07b}  Modals",
    "   \\u{203a} \\u{f07b}  Modules",
    "   \\u{203a} \\u{f07b}  Services",
    "   \\u{203a} \\u{f07b}  Widgets",
    "   \\u{203a} \\u{f07b}  assets",
    "     \\u{f15b}  CODENAME",
    "     \\u{f15b}  LICENSE",
    "     \\u{f15b}  README.md",
    "     \\u{f15b}  VGS.qml",
    "     \\u{f15b}  VGSIPC.qml",
    "     \\u{f15b}  shell.qml",
    " \\u{203a} \\u{f07b}  systemd",
    " \\u{2304} \\u{f07c}  themes",
    "  \\u{203a} \\u{f07b}  blueprints",
    "  \\u{203a} \\u{f07b}  targets",
}

vim.api.nvim_create_autocmd("VimEnter", {
    callback = function()
        vim.cmd("topleft 30vnew")
        local buf = vim.api.nvim_get_current_buf()
        vim.api.nvim_buf_set_lines(buf, 0, -1, false, tree)
        vim.bo[buf].buftype = "nofile"
        vim.bo[buf].modifiable = false
        vim.wo.number = false
        vim.wo.cursorline = false
        vim.wo.winfixwidth = true
        vim.wo.winhighlight = "Normal:TreeNormal,EndOfBuffer:TreeNormal"
        vim.fn.matchadd("TreeTitle", "^ Neo-tree")
        vim.fn.matchadd("TreeRoot", "dev/vgs")
        vim.fn.matchadd("TreeMuted", "[\\u{203a}\\u{2304}]")
        vim.fn.matchadd("TreeDir", "[\\u{f07b}\\u{f07c}].*")
        vim.fn.matchadd("TreeFile", "\\u{f15b}.*")
        vim.cmd("wincmd l")
    end,
})
'''


def theme_previews_dir() -> Path:
    return cache_dir() / "theme-previews"


def blueprint_safe_name(bp: Dict[str, Any]) -> str:
    return re.sub(r"[^A-Za-z0-9_.-]+", "-", str(bp.get("name") or "theme")).strip("-") or "theme"


def preview_wallpaper(bp: Dict[str, Any]) -> str:
    """Wallpaper shown behind the preview: the blueprint's own, else the live one."""
    wp = resolve_path(str(bp.get("palette", {}).get("wallpaper") or ""))
    if wp and Path(wp).exists():
        return wp
    try:
        wp = resolve_path(current_theme().get("wallpaper", ""))
    except Exception:
        wp = ""
    return wp if wp and Path(wp).exists() else ""


def blueprint_preview_hash(bp: Dict[str, Any]) -> str:
    # Curated apps/ file mtimes participate so hand-tuning refreshes screenshots.
    apps_state = {}
    for name, path in sorted((bp.get("apps") or {}).items()):
        with contextlib.suppress(OSError):
            apps_state[name] = int(Path(path).stat().st_mtime)
    payload = json.dumps({"palette": bp.get("palette", {}), "wallpaper": preview_wallpaper(bp), "apps": apps_state, "v": PREVIEW_GENERATOR_VERSION}, sort_keys=True)
    return hashlib.sha256(payload.encode()).hexdigest()[:12]


def blueprint_preview_path(bp: Dict[str, Any]) -> Path:
    return theme_previews_dir() / f"{blueprint_safe_name(bp)}-{blueprint_preview_hash(bp)}.png"


def preview_gtk_theme(mode: str) -> str:
    gtk_theme = "adw-gtk3" if mode == "light" else "adw-gtk3-dark"
    if not (Path("/usr/share/themes") / gtk_theme).exists():
        gtk_theme = "Adwaita" if mode == "light" else "Adwaita-dark"
    return gtk_theme


def preview_file_manager(root: Path) -> List[str] | None:
    """Command prefix for the preview file manager, most preferred first."""
    if shutil.which("dolphin"):
        return ["dolphin", str(root)]
    if shutil.which("nautilus"):
        return ["nautilus", "--new-window", str(root)]
    if shutil.which("thunar"):
        return ["thunar", str(root)]
    return None


# The file-manager pane used to open the real $HOME. Previews are committed to
# the repo and regenerated on user machines, so it browses a synthetic home
# instead: the shot is identical everywhere and never carries someone's
# filenames into a screenshot.
PREVIEW_SAMPLE_HOME = {
    "Desktop": [],
    "Documents": ["notes.md", "invoice.pdf"],
    "Downloads": ["vshell-0.3.0.tar.zst"],
    "Music": [],
    "Pictures": ["wallpaper.jpg"],
    "Projects": ["palette-kit", "shell"],
    "Videos": [],
}


def write_preview_home(tmp: Path) -> Path:
    """Build the synthetic home the preview file manager browses."""
    root = tmp / "home"
    for folder, children in PREVIEW_SAMPLE_HOME.items():
        (root / folder).mkdir(parents=True, exist_ok=True)
        for child in children:
            target = root / folder / child
            if "." in child:
                target.write_text("")
            else:
                target.mkdir(exist_ok=True)
    (root / ".config").mkdir(exist_ok=True)
    (root / "README.md").write_text("")
    return root


def render_target_template(target: str, template: str, roles: Dict[str, str]) -> str:
    return render_template((targets_dir() / target / template).read_text(), roles)


def parse_nvim_spec(spec_path: Path) -> Tuple[str, List[str]]:
    """Best-effort read of a curated neovim.lua lazy spec: the colorscheme name
    and candidate plugin runtime dirs under the user's lazy.nvim data dir."""
    try:
        text = spec_path.read_text()
    except OSError:
        return "", []
    m = re.search(r'colorscheme\s*=\s*"([^"]+)"', text)
    colorscheme = m.group(1) if m else ""
    lazy_root = home() / ".local" / "share" / "nvim" / "lazy"
    dirs: List[str] = []
    for repo in re.findall(r'"([\w.-]+/[\w.-]+)"', text):
        candidates = [repo.split("/", 1)[1]]
        name_m = re.search(r'"%s"\s*,\s*\n?\s*name\s*=\s*"([^"]+)"' % re.escape(repo), text)
        if name_m:
            candidates.insert(0, name_m.group(1))
        for cand in candidates:
            path = lazy_root / cand
            if path.is_dir():
                dirs.append(str(path))
                break
    return colorscheme, dirs


def write_preview_tree(bp: Dict[str, Any], roles: Dict[str, str], tmp: Path) -> Dict[str, Any]:
    """Render the isolated config tree used by the preview session."""
    mode = (roles.get("theme_type") or "dark").lower()
    cfg = tmp / "config"
    (cfg / "gtk-3.0").mkdir(parents=True)
    (cfg / "gtk-4.0").mkdir(parents=True)
    (cfg / "gtk-3.0" / "gtk.css").write_text(render_target_template("gtk3-vgs", "gtk.css", roles))
    (cfg / "gtk-4.0" / "gtk.css").write_text(render_target_template("gtk4-vgs", "gtk.css", roles))
    (cfg / "qt6ct" / "colors").mkdir(parents=True)
    (cfg / "qt6ct" / "colors" / "vgs.conf").write_text(render_target_template("qt6ct-vgs", "vgs.conf", roles))
    (cfg / "qt6ct" / "qt6ct.conf").write_text(
        "[Appearance]\ncolor_scheme_path=" + str(cfg / "qt6ct" / "colors" / "vgs.conf") + "\ncustom_palette=true\n"
    )

    ghostty_conf = tmp / "ghostty.conf"
    ghostty_conf.write_text(
        render_target_template("ghostty-vgs", "ghostty.conf", roles)
        + "\nfont-size = 11\nwindow-padding-x = 14\nwindow-padding-y = 10\nconfirm-close-surface = false\ncursor-style-blink = false\n"
    )

    (tmp / "sample.qml").write_text(PREVIEW_SAMPLE_CODE)
    theme_lua = tmp / "theme.nvim.lua"
    theme_lua.write_text(render_target_template("nvim-vgs", "vgs-theme.lua", roles))
    colorscheme, rtp_dirs = "", []
    nvim_spec = (bp.get("apps") or {}).get("neovim.lua")
    if nvim_spec:
        colorscheme, rtp_dirs = parse_nvim_spec(Path(nvim_spec))
    nvim_init = (PREVIEW_NVIM_INIT
                 .replace("@THEME_LUA@", str(theme_lua))
                 .replace("@THEME_COLORSCHEME@", colorscheme if rtp_dirs else "")
                 .replace("@THEME_RTP@", ";".join(rtp_dirs))
                 .replace("@THEME_MODE@", mode))
    (tmp / "nvim-init.lua").write_text(nvim_init)
    showcase = tmp / "showcase.sh"
    showcase.write_text(PREVIEW_SHOWCASE_SCRIPT)
    showcase.chmod(0o755)

    theme_json = tmp / "theme.json"
    theme_json.write_text(render_target_template("vgs-shell", "vgs-theme.json", roles))

    # App manifest consumed by the in-session capture step, which measures the
    # real output size and places these windows itself.
    name = str(bp.get("name") or "theme")
    ghostty = ["ghostty", "--config-default-files=false", f"--config-file={ghostty_conf}"]
    common_env = {"VGS_PREVIEW_NAME": name, "VGS_PREVIEW_MODE": mode}
    apps = [
        {"class": "vgs.preview.nvim", "slot": "nvim", "env": common_env,
         "cmd": [*ghostty, "--class=vgs.preview.nvim", "-e", "nvim", "-u", str(tmp / "nvim-init.lua"), str(tmp / "sample.qml")]},
        {"class": "vgs.preview.term", "slot": "term", "env": common_env,
         "cmd": [*ghostty, "--class=vgs.preview.term", "-e", "bash", str(showcase)]},
    ]
    sample_home = write_preview_home(tmp)
    fm = preview_file_manager(sample_home)
    if fm:
        fm_cmd = list(fm)
        if shutil.which("dbus-run-session"):
            fm_cmd = ["dbus-run-session", "--", *fm_cmd]
        apps.append({
            "class": "org.gnome.Nautilus" if fm[0] == "nautilus" else fm[0],
            "slot": "files",
            "env": {
                "XDG_CONFIG_HOME": str(cfg),
                "GTK_THEME": preview_gtk_theme(mode),
                "QT_QPA_PLATFORMTHEME": "qt6ct",
                # HOME too, so the sidebar's "Home" and the breadcrumb match the
                # pane instead of pointing back at the real account.
                "HOME": str(sample_home),
                # Drop the udisks volume monitor: without it the sidebar lists
                # this machine's drives by label, which is both machine-specific
                # and more of the user's setup than a shipped screenshot should
                # show. "unix" keeps only mounts the sample home implies.
                "GIO_USE_VOLUME_MONITOR": "unix",
            },
            "cmd": fm_cmd,
        })
    (tmp / "apps.json").write_text(json.dumps(apps, indent=2))

    return {"config": cfg, "ghostty": ghostty_conf, "theme_json": theme_json, "gtk_theme": preview_gtk_theme(mode)}


def preview_layout(width: int, height: int) -> Dict[str, Tuple[int, int, int, int]]:
    """Window rects for the preview mosaic, computed from the real output size."""
    gap = 20
    bar_h = 50
    col_x = width // 2 + gap // 2
    col_w = width - col_x - gap
    left_w = col_x - gap - gap // 2
    top_y = bar_h + gap
    fm_h = (height - top_y - gap) * 52 // 100
    term_y = top_y + fm_h + gap
    return {
        "nvim": (gap, top_y, left_w, height - top_y - gap),
        "files": (col_x, top_y, col_w, fm_h),
        "term": (col_x, term_y, col_w, height - term_y - gap),
    }


def preview_canvas() -> Tuple[int, int]:
    """Size the nested session — and therefore every preview — comes out at.

    Previews ship in the repo, so this is a fixed number rather than whatever
    the staging output happens to leave over: `preview_stage()` grows the
    headless output by the parent's window chrome and reserved area so the
    nested compositor lands on exactly this size on any machine.
    """
    return PREVIEW_SIZE


def _css_box(value: str) -> Tuple[int, int, int, int]:
    """CSS-shorthand edge values (`10`, `10 20`, `10 20 30`, `10 20 30 40`)."""
    try:
        parts = [int(float(v)) for v in value.split()]
    except ValueError:
        return (0, 0, 0, 0)
    if len(parts) == 1:
        return (parts[0],) * 4  # type: ignore[return-value]
    if len(parts) == 2:
        return (parts[0], parts[1], parts[0], parts[1])
    if len(parts) == 3:
        return (parts[0], parts[1], parts[2], parts[1])
    if len(parts) >= 4:
        return (parts[0], parts[1], parts[2], parts[3])
    return (0, 0, 0, 0)


def preview_stage_chrome() -> Tuple[int, int]:
    """Pixels the parent compositor spends on gaps and borders around the
    staged capture window. The nested session never sees them, so the staging
    output has to be that much larger for the capture to be PREVIEW_SIZE."""
    def opt(name: str) -> Dict[str, Any]:
        try:
            return json.loads(run(["hyprctl", "getoption", name, "-j"]).stdout or "{}")
        except Exception:
            return {}
    border = max(0, int(opt("general:border_size").get("int") or 0))
    top, right, bottom, left = _css_box(str(opt("general:gaps_out").get("css") or "0"))
    return left + right + 2 * border, top + bottom + 2 * border


def preview_stage_reserved() -> Tuple[int, int]:
    """Area a shell bar has claimed on the staging output, if it drew one."""
    deadline = time.time() + 3
    while time.time() < deadline:
        try:
            for m in json.loads(run(["hyprctl", "monitors", "-j"]).stdout or "[]"):
                if m.get("name") != PREVIEW_OUTPUT:
                    continue
                res = m.get("reserved") or [0, 0, 0, 0]
                if any(res):
                    return res[0] + res[2], res[1] + res[3]
        except Exception:
            break
        time.sleep(0.3)
    return 0, 0


def preview_hyprland_config(bp: Dict[str, Any], roles: Dict[str, str], tmp: Path, tree: Dict[str, Any], canvas: Tuple[int, int]) -> Path:
    """Write the nested-session native-Lua Hyprland config."""
    width, height = canvas
    accent = roles.get("accent", "#7aa2f7").lstrip("#")
    outline = roles.get("outline", roles.get("bright_black", "#444444")).lstrip("#")
    bg = roles.get("background", "#101010").lstrip("#")
    helper = str(Path(__file__).resolve())
    qs_preview = repo_root() / "quickshell" / "vshell-preview" / "shell.qml"

    lines = [
        "-- Generated transiently by VGS for theme preview capture.",
        "hl.monitor({",
        '  output = "",',
        f'  mode = "{width}x{height}@60",',
        '  position = "0x0",',
        "  scale = 1,",
        "})",
        "",
        "hl.config({",
        "  misc = {",
        "    disable_hyprland_logo = true,",
        "    disable_splash_rendering = true,",
        "    disable_watchdog_warning = true,",
        f'    background_color = "rgb({bg})",',
        "  },",
        "  animations = {",
        "    enabled = false,",
        "  },",
        "  cursor = {",
        "    inactive_timeout = 1,",
        "  },",
        "  decoration = {",
        "    rounding = 10,",
        "    blur = {",
        "      enabled = false,",
        "    },",
        "    shadow = {",
        "      enabled = false,",
        "    },",
        "  },",
        "  general = {",
        "    gaps_in = 10,",
        "    gaps_out = 20,",
        "    border_size = 2,",
        "    col = {",
        f'      active_border = "rgb({accent})",',
        f'      inactive_border = "rgb({outline})",',
        "    },",
        "  },",
        "})",
        "",
        'hl.on("hyprland.start", function()',
    ]
    layout = preview_layout(width, height)
    apps = json.loads((tmp / "apps.json").read_text())
    for app in apps:
        rect = layout.get(app.get("slot", ""))
        if not rect:
            continue
        x, y, w, h = rect
        env_prefix = [f"{key}={value}" for key, value in (app.get("env") or {}).items()]
        command = shlex.join(["env", *env_prefix, *[str(part) for part in app["cmd"]]])
        lines.extend([
            f"  hl.exec_cmd({_lua_string(command)}, {{",
            "    float = true,",
            "    no_anim = true,",
            f"    move = {{{x}, {y}}},",
            f"    size = {{{w}, {h}}},",
            "  })",
        ])
    if shutil.which("qs") and qs_preview.exists():
        wallpaper = preview_wallpaper(bp)
        qs_command = shlex.join([
            "env",
            f"VGS_PREVIEW_THEME={tree['theme_json']}",
            f"VGS_PREVIEW_WALLPAPER={wallpaper}",
            "qs",
            "-p",
            str(qs_preview),
        ])
        lines.append(f"  hl.exec_cmd({_lua_string(qs_command)})")
    capture_command = shlex.join([
        helper,
        "theme",
        "preview-capture",
        "--dir",
        str(tmp),
        "--windows",
        str(len(apps)),
    ])
    lines.extend([
        f"  hl.exec_cmd({_lua_string(capture_command)})",
        "end)",
    ])
    config = tmp / "hyprland.lua"
    config.write_text("\n".join(lines) + "\n")
    return config


def preview_hyprctl_json(*args: str) -> Any:
    try:
        return json.loads(run(["hyprctl", *args, "-j"]).stdout or "[]")
    except Exception:
        return []


def preview_focus_target(monitors: List[Dict[str, Any]]) -> str | None:
    # Prefer the monitor under the cursor — that's where the user actually is.
    try:
        x, y = (run(["hyprctl", "cursorpos"]).stdout or "").strip().split(",")
        cx, cy = int(x.strip()), int(y.strip())
        for m in monitors:
            if m.get("name") == PREVIEW_OUTPUT:
                continue
            scale = m.get("scale") or 1
            mx, my = m.get("x", 0), m.get("y", 0)
            if mx <= cx < mx + m.get("width", 0) / scale and my <= cy < my + m.get("height", 0) / scale:
                return m.get("name")
    except Exception:
        pass
    return next((m.get("name") for m in monitors if m.get("name") != PREVIEW_OUTPUT), None)


def preview_dispatch(lua_call: str, legacy: List[str]) -> None:
    # hl.dsp.* calls only CONSTRUCT a dispatch; hl.dispatch() executes it (a
    # bare hl.dsp call returns "ok" and silently does nothing). On this
    # Hyprland `hyprctl dispatch <verb>` is Lua shorthand and fails on classic
    # syntax, while pre-Lua builds fail `eval` — and both report errors on
    # stdout with exit 0, so match on the text, not the return code.
    res = run(["hyprctl", "eval", f"hl.dispatch({lua_call})"])
    if res.returncode != 0 or "error" in (res.stdout or "").lower():
        run(["hyprctl", "dispatch", *legacy])


def preview_guard_focus() -> None:
    """Keep the invisible staging output from holding the user's workspaces or focus.

    Two live compositor behaviors make a one-shot guard insufficient, so this
    runs on the capture pump cadence, not just at stage time:
    - Recreating VGSPREVIEW looks like a reconnecting monitor; Hyprland hands it
      back (asynchronously, after `output create` returns) every workspace whose
      last monitor was VGSPREVIEW — including real user workspaces from a prior
      run, teleporting their windows onto the invisible output.
    - Mapping a nested-capture window switches the *active monitor* to
      VGSPREVIEW even though `no_focus` keeps the window itself unfocusable, so
      the user's next keystroke lands on the invisible output.
    """
    monitors = preview_hyprctl_json("monitors")
    if not any(m.get("name") == PREVIEW_OUTPUT for m in monitors):
        return
    target = preview_focus_target(monitors)
    if not target:
        return
    preview_ws = {w.get("id"): w for w in preview_hyprctl_json("workspaces") if w.get("monitor") == PREVIEW_OUTPUT}
    hijacked = {
        c["workspace"]["id"]
        for c in preview_hyprctl_json("clients")
        if c.get("workspace") and c["workspace"].get("id") in preview_ws and c.get("class") != "aquamarine"
    }
    for ws_id in hijacked:
        name = preview_ws[ws_id].get("name") or ""
        ws_sel = str(ws_id) if ws_id > 0 else json.dumps(name)
        legacy_sel = str(ws_id) if ws_id > 0 else (name if name.startswith("special:") else f"name:{name}")
        preview_dispatch(
            f'hl.dsp.workspace.move({{ workspace = {ws_sel}, monitor = "{target}" }})',
            ["moveworkspacetomonitor", legacy_sel, target],
        )
    if hijacked:
        monitors = preview_hyprctl_json("monitors")
    focused = next((m.get("name") for m in monitors if m.get("focused")), None)
    if focused == PREVIEW_OUTPUT:
        preview_dispatch(f'hl.dsp.focus({{ monitor = "{target}" }})', ["focusmonitor", target])


def png_size(path: Path) -> Tuple[int, int] | None:
    """Width/height straight out of the PNG IHDR chunk (no PIL dependency)."""
    try:
        with path.open("rb") as fh:
            head = fh.read(24)
        if len(head) < 24 or head[:8] != b"\x89PNG\r\n\x1a\n":
            return None
        return int.from_bytes(head[16:20], "big"), int.from_bytes(head[20:24], "big")
    except OSError:
        return None


def generate_theme_preview(bp: Dict[str, Any], out_path: Path) -> Dict[str, Any]:
    """Capture a preview, retrying once if the staging output handed the nested
    session the wrong geometry — every shipped preview must share one size."""
    result = {}
    for attempt in range(2):
        canvas = preview_canvas()
        result = capture_theme_preview(bp, out_path, canvas)
        if not result.get("success"):
            return result
        size = png_size(out_path)
        if size is None or size == canvas:
            return result
        eprint(f"preview for {bp.get('name')} captured at {size[0]}x{size[1]}, expected {canvas[0]}x{canvas[1]}"
               + ("; retrying" if attempt == 0 else ""))
    return result


def capture_theme_preview(bp: Dict[str, Any], out_path: Path, canvas: Tuple[int, int]) -> Dict[str, Any]:
    missing = [tool for tool in ("Hyprland", "ghostty", "nvim", "grim") if not shutil.which(tool)]
    if missing:
        return {"success": False, "error": f"preview requires: {', '.join(missing)}"}
    tmp = Path(tempfile.mkdtemp(prefix="vgs-preview-"))
    log_path = tmp / "hyprland.log"
    try:
        roles = target_roles(bp)
        tree = write_preview_tree(bp, roles, tmp)
        config = preview_hyprland_config(bp, roles, tmp, tree, canvas)
        env = os.environ.copy()
        env.pop("HYPRLAND_INSTANCE_SIGNATURE", None)
        shot = tmp / "shot.png"
        done = tmp / "done"
        pump_shot = tmp / "pump.png"
        with log_path.open("w") as log:
            proc = subprocess.Popen(["Hyprland", "--config", str(config)], env=env, stdout=log, stderr=log, cwd=tmp)
            deadline = time.time() + 60
            while time.time() < deadline and proc.poll() is None and not done.exists():
                # The parent only renders the headless output on demand; capturing it
                # keeps render callbacks flowing so the nested session never freezes.
                run(["grim", "-o", PREVIEW_OUTPUT, str(pump_shot)])
                preview_guard_focus()
                time.sleep(0.8)
            if proc.poll() is None:
                proc.terminate()
                try:
                    proc.wait(timeout=5)
                except subprocess.TimeoutExpired:
                    proc.kill()
        if not shot.exists():
            tail = "\n".join(log_path.read_text().splitlines()[-6:]) if log_path.exists() else ""
            return {"success": False, "error": f"preview session produced no screenshot ({tail or 'no log'})"}
        out_path.parent.mkdir(parents=True, exist_ok=True)
        # Only this theme's own older hashes. A plain `<name>-*.png` glob also
        # matches every theme whose name extends this one (catppuccin wiping
        # catppuccin-frappe, tokyo-night wiping tokyo-night-moon, …), so the
        # suffix has to be pinned to the hash shape.
        stale_re = re.compile(rf"^{re.escape(blueprint_safe_name(bp))}-[0-9a-f]{{12}}\.png$")
        for stale in out_path.parent.glob("*.png"):
            if stale != out_path and stale_re.match(stale.name):
                stale.unlink(missing_ok=True)
        shutil.move(str(shot), out_path)
        return {"success": True, "preview": str(out_path)}
    finally:
        if os.environ.get("VGS_PREVIEW_KEEP"):
            eprint(f"preview debug artifacts kept at {tmp}")
        else:
            shutil.rmtree(tmp, ignore_errors=True)


@contextlib.contextmanager
def preview_stage():
    """Stage a hidden capture surface on the parent compositor.

    Creates a headless output and a runtime window rule that parks nested
    preview compositors there fullscreen. Yields (staged, reassert); callers
    should invoke reassert() before each capture so a mid-run config reload
    (theme apply hook, manual reload) cannot leave capture windows stealing
    focus on the user's monitors.
    """
    staged = False

    def cursor_pos() -> Tuple[int, int] | None:
        try:
            x, y = (run(["hyprctl", "cursorpos"]).stdout or "").strip().split(",")
            return int(x.strip()), int(y.strip())
        except Exception:
            return None

    def restore_cursor(pos: Tuple[int, int] | None) -> None:
        # Output layout changes warp the cursor; put it back where the user had it.
        if pos:
            run(["hyprctl", "eval", f"hl.dispatch(hl.dsp.cursor.move({{ x = {pos[0]}, y = {pos[1]} }}))"])

    def focused_monitor() -> str | None:
        return next((m.get("name") for m in preview_hyprctl_json("monitors") if m.get("focused")), None)

    def enforce_monitor(name: str | None) -> None:
        # Output add/remove shifts Hyprland's focused monitor as a side effect
        # (cursor warp + follow_mouse); put keyboard focus back where it was.
        if name and name != PREVIEW_OUTPUT and focused_monitor() != name:
            preview_dispatch(f'hl.dsp.focus({{ monitor = "{name}" }})', ["focusmonitor", name])

    legacy_rule = False
    if shutil.which("hyprctl") and os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
        pos = cursor_pos()
        prev_mon = focused_monitor()
        run(["hyprctl", "output", "remove", PREVIEW_OUTPUT])
        restore_cursor(pos)
        pos = cursor_pos()
        # Rules go in BEFORE the output exists: the workspace rule's `default`
        # flag decides which workspace the new output activates, and silent
        # window placement only keeps the nested session rendering (and thus
        # alive) when its target workspace is the staging output's active one.
        hook = run(["hyprctl", "eval", PREVIEW_STAGE_ON_LUA])
        rules_ok = hook.returncode == 0 and "error" not in (hook.stdout or "").lower()
        if not rules_ok:
            # Pre-Lua Hyprland: fall back to a runtime rule; teardown then
            # needs a config reload to clear it.
            rule = run(["hyprctl", "keyword", "windowrulev2", PREVIEW_WINDOW_RULE_LEGACY])
            run(["hyprctl", "keyword", "windowrulev2", PREVIEW_WINDOW_RULE_LEGACY_NOFOCUS])
            rules_ok = legacy_rule = rule.returncode == 0 and "error" not in (rule.stdout or "").lower()
        if rules_ok and run(["hyprctl", "output", "create", "headless", PREVIEW_OUTPUT]).returncode == 0:
            # The output inherits the user's default scale; force scale 1 so the
            # nested session sees the full logical resolution.
            def set_mode(w: int, h: int) -> None:
                mode = run(["hyprctl", "eval", f'hl.monitor({{ output = "{PREVIEW_OUTPUT}", mode = "{w}x{h}@60", position = "auto", scale = 1 }})'])
                if mode.returncode != 0 or "error" in (mode.stdout or "").lower():
                    run(["hyprctl", "keyword", "monitor", f"{PREVIEW_OUTPUT},{w}x{h}@60,auto,1"])

            # Grow the output by whatever the parent spends on window chrome so
            # the staged capture window is exactly PREVIEW_SIZE. Gaps, borders
            # and a bar's exclusive zone are all user config, and previews are
            # committed to the repo — they cannot vary with them.
            width, height = PREVIEW_SIZE
            chrome_w, chrome_h = preview_stage_chrome()
            set_mode(width + chrome_w, height + chrome_h)
            res_w, res_h = preview_stage_reserved()
            if res_w or res_h:
                set_mode(width + chrome_w + res_w, height + chrome_h + res_h)
            staged = True
            preview_guard_focus()
            restore_cursor(pos)
            enforce_monitor(prev_mon)

    def reassert() -> None:
        if staged and not legacy_rule:
            run(["hyprctl", "eval", PREVIEW_STAGE_ON_LUA])
        if staged:
            # Workspace adoption fires asynchronously after `output create`, and
            # a mid-run reload can re-trigger it; heal before every capture, not
            # just at stage creation. The capture pump also heals every 0.8s.
            preview_guard_focus()

    try:
        yield staged, reassert
    finally:
        if staged:
            if legacy_rule:
                run(["hyprctl", "reload"])
            pos = cursor_pos()
            # Honor where the user is focused NOW (not at stage time); if the
            # invisible output somehow holds focus, fall back to the monitor
            # under the cursor.
            end_mon = focused_monitor()
            if end_mon == PREVIEW_OUTPUT:
                end_mon = preview_focus_target(preview_hyprctl_json("monitors"))
            run(["hyprctl", "output", "remove", PREVIEW_OUTPUT])
            restore_cursor(pos)
            enforce_monitor(end_mon)


def cmd_theme_preview(args: argparse.Namespace) -> int:
    with preview_lock() as locked:
        if not locked:
            msg = "another preview generation is already running"
            print(json.dumps({"success": False, "error": msg}, indent=2) if args.json else msg)
            return 1
        if args.all:
            targets = list_themes()
        else:
            name = args.name or current_theme().get("name", "")
            bp = find_theme(name)
            if not bp:
                eprint(f"Blueprint not found: {name}")
                return 1
            targets = [bp]
        results = []
        with preview_stage() as (staged, reassert_stage):
            if not staged:
                eprint("preview staging unavailable; the capture session will be visible")
            for bp in targets:
                out_path = blueprint_preview_path(bp)
                if out_path.exists() and not args.force:
                    results.append({"success": True, "name": bp.get("name"), "preview": str(out_path), "cached": True})
                    continue
                reassert_stage()
                result = generate_theme_preview(bp, out_path)
                result["name"] = bp.get("name")
                results.append(result)
                if not result.get("success"):
                    eprint(f"preview failed for {bp.get('name')}: {result.get('error')}")
    ok = all(r.get("success") for r in results)
    if args.json:
        print(json.dumps({"success": ok, "previews": results}, indent=2))
    else:
        for r in results:
            print(f"{r.get('name')}: {r.get('preview') or r.get('error')}")
    return 0 if ok else 1


@contextlib.contextmanager
def preview_lock():
    ensure_dirs()
    theme_previews_dir().mkdir(parents=True, exist_ok=True)
    lock_path = theme_previews_dir() / ".lock"
    with lock_path.open("a+") as lock:
        try:
            fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        except OSError:
            yield False
            return
        try:
            yield True
        finally:
            fcntl.flock(lock.fileno(), fcntl.LOCK_UN)


def cmd_theme_preview_capture(args: argparse.Namespace) -> int:
    """Runs inside the nested preview compositor: wait for windows, capture, exit."""
    tmp = Path(args.dir)
    deadline = time.time() + args.wait
    while time.time() < deadline:
        try:
            clients = json.loads(run(["hyprctl", "clients", "-j"]).stdout or "[]")
            if len([c for c in clients if c.get("mapped")]) >= args.windows:
                break
        except Exception:
            pass
        time.sleep(0.25)
    # Park the nested pointer in the corner; a fresh session never hides it otherwise.
    preview_dispatch("hl.dsp.cursor.move({ x = 5000, y = 5000 })", ["movecursor", "5000", "5000"])
    time.sleep(args.settle)
    (tmp / "clients.json").write_text(run(["hyprctl", "clients", "-j"]).stdout or "[]")
    (tmp / "monitors.json").write_text(run(["hyprctl", "monitors", "-j"]).stdout or "[]")
    shot = tmp / "shot.png"
    for _ in range(3):
        try:
            result = run(["grim", str(shot)], timeout=10)
        except subprocess.TimeoutExpired:
            continue
        if result.returncode == 0 and shot.exists() and shot.stat().st_size > 0:
            break
        eprint(f"grim attempt failed: {result.stderr}")
        time.sleep(1.0)
    (tmp / "done").touch()
    preview_dispatch("hl.dsp.exit()", ["exit"])
    return 0


def current_theme() -> Dict[str, Any]:
    theme_file = cfg_dir() / "theme.json"
    if theme_file.exists():
        return json.loads(theme_file.read_text())
    bp = find_theme("coppernight") or {"name": "coppernight", "palette": {"colors": DEFAULT_COLORS, "mode": "dark", "extendedColors": {}}}
    apply_theme_obj(bp)
    return json.loads(theme_file.read_text())


def blueprint_from_theme_json(theme: Dict[str, Any], name: str | None = None, mode: str | None = None, wallpaper: str | None = None) -> Dict[str, Any]:
    colors_obj = theme.get("colors", {})
    data = {
        "background": colors_obj.get("background", DEFAULT_COLORS[0]),
        "foreground": colors_obj.get("foreground", DEFAULT_COLORS[7]),
        "accent": colors_obj.get("accent", DEFAULT_COLORS[4]),
        "cursor": colors_obj.get("cursor", colors_obj.get("accent", DEFAULT_COLORS[4])),
        "selection_background": colors_obj.get("selectionBackground", colors_obj.get("accent", DEFAULT_COLORS[4])),
        "selection_foreground": colors_obj.get("selectionForeground", DEFAULT_COLORS[15]),
        "mode": mode or theme.get("mode", "dark"),
    }
    for i, ansi in enumerate(ANSI_NAMES):
        data[f"color{i}"] = colors_obj.get(CAMEL.get(ansi, ansi), colors_obj.get(ansi, DEFAULT_COLORS[i]))
    source = str(theme.get("source") or "generated")
    if mode and mode != theme.get("mode"):
        # Cheap but visible mode transform; real wallpaper extraction should be used for curated palettes.
        source = "generated"
        bg = data["background"]
        fg = data["foreground"]
        data["background"], data["foreground"] = (lighten(fg, 0.10), darken(bg, 0.20)) if mode == "light" else (darken(fg, 0.78), lighten(bg, 0.55))
        data["color0"] = data["background"]
        data["color7"] = blend(data["foreground"], data["background"], 0.25)
        data["color8"] = lighten(data["background"], 0.25) if mode == "dark" else darken(data["background"], 0.15)
        data["color15"] = data["foreground"]
    return palette_from_colors_map(data, name=name or theme.get("name", "vgs-theme"), wallpaper=wallpaper if wallpaper is not None else theme.get("wallpaper", ""), source=source)


def theme_json_from_blueprint(bp: Dict[str, Any]) -> Dict[str, Any]:
    roles = target_roles(bp)
    colors = {"background": roles["background"], "foreground": roles["foreground"], "accent": roles["accent"], "cursor": roles["cursor"], "selectionForeground": roles["selection_foreground"], "selectionBackground": roles["selection_background"]}
    for ansi in ANSI_NAMES:
        colors[CAMEL.get(ansi, ansi)] = roles[ansi]
    return {"name": bp.get("name", "vgs-theme"), "source": blueprint_source(bp), "mode": roles.get("theme_type", "dark"), "wallpaper": roles.get("wallpaper", ""), "colors": colors}


def blueprint_mode_variant(base: Dict[str, Any], mode: str, wallpaper: str) -> Dict[str, Any]:
    return blueprint_from_theme_json(theme_json_from_blueprint(base), name=base.get("name", "vgs-theme"), mode=mode, wallpaper=wallpaper)


def blueprint_from_current_theme(name: str | None = None, mode: str | None = None) -> Dict[str, Any]:
    cur = current_theme()
    return blueprint_from_theme_json(cur, name=name or cur.get("name", "vgs-theme"), mode=mode, wallpaper=cur.get("wallpaper", ""))


def carry_curated_apps(bp: Dict[str, Any]) -> Dict[str, Any]:
    """Graft the active theme's curated apps/ sources and package identity onto a
    palette-only blueprint. A blueprint rebuilt from theme.json carries colors
    only (no `apps`), so re-applying it makes apply_theme_obj fall back to
    template generation for every curated target -- which, for VSCodium,
    repoints workbench.colorTheme from the curated label (e.g. "VGS Tokyo
    Night") to the generic "VGS" template. A wallpaper-only change must leave
    theme settings untouched, so re-attach the current theme's curated apps/ and
    package path (the latter is what bp_app_overrides keys off) before applying.
    Only grafts when names match and the current theme is a real package; a bare
    generated theme has no curated apps and is left as-is. Mutates and returns
    bp."""
    if str(current_theme().get("name") or "") != str(bp.get("name") or ""):
        return bp
    cur = current_theme_obj()
    if not cur.get("package"):
        return bp
    apps = cur.get("apps") or {}
    if apps:
        bp["apps"] = apps
    for key in ("package", "path", "builtin", "userDir"):
        if cur.get(key) is not None and key not in bp:
            bp[key] = cur[key]
    return bp


def current_theme_color_data(mode: str | None = None, wallpaper: str | None = None) -> Dict[str, str]:
    current_bp_path = cfg_dir() / "theme-current.json"
    if current_bp_path.exists():
        try:
            bp = json.loads(current_bp_path.read_text())
            pal = bp.get("palette", {})
            colors = [clean_hex(c, DEFAULT_COLORS[i] if i < len(DEFAULT_COLORS) else "#000000") for i, c in enumerate(pal.get("colors", []))]
            while len(colors) < 16:
                colors.append(DEFAULT_COLORS[len(colors)])
            ext = pal.get("extendedColors") or {}
            data: Dict[str, str] = {
                "background": clean_hex(ext.get("background") or colors[0], colors[0]),
                "foreground": clean_hex(ext.get("foreground") or colors[7], colors[7]),
                "accent": clean_hex(ext.get("accent") or colors[4], colors[4]),
                "cursor": clean_hex(ext.get("cursor") or ext.get("accent") or colors[4], colors[4]),
                "selection_background": clean_hex(ext.get("selection_background") or ext.get("selectionBackground") or colors[4], colors[4]),
                "selection_foreground": clean_hex(ext.get("selection_foreground") or ext.get("selectionForeground") or colors[15], colors[15]),
                "mode": mode or (pal.get("mode") or ("light" if pal.get("lightMode") else "dark") or "dark"),
            }
            if wallpaper is not None:
                data["wallpaper"] = wallpaper
            for i, ansi in enumerate(ANSI_NAMES):
                data[f"color{i}"] = colors[i]
                data[ansi] = colors[i]
            return data
        except Exception as exc:
            eprint(f"theme-current fallback failed: {exc}")

    cur = current_theme()
    colors_obj = cur.get("colors", {})
    data: Dict[str, str] = {
        "background": colors_obj.get("background", DEFAULT_COLORS[0]),
        "foreground": colors_obj.get("foreground", DEFAULT_COLORS[7]),
        "accent": colors_obj.get("accent", DEFAULT_COLORS[4]),
        "cursor": colors_obj.get("cursor", colors_obj.get("accent", DEFAULT_COLORS[4])),
        "selection_background": colors_obj.get("selectionBackground", colors_obj.get("accent", DEFAULT_COLORS[4])),
        "selection_foreground": colors_obj.get("selectionForeground", DEFAULT_COLORS[15]),
        "mode": mode or cur.get("mode", "dark"),
    }
    if wallpaper is not None:
        data["wallpaper"] = wallpaper
    for i, ansi in enumerate(ANSI_NAMES):
        data[f"color{i}"] = colors_obj.get(CAMEL.get(ansi, ansi), colors_obj.get(ansi, DEFAULT_COLORS[i]))
    return data


def parse_color_edits(edits: List[str]) -> Dict[str, str]:
    """Parse `role=hex` edits into a normalized base-color map (colorN + ANSI in sync)."""
    valid_keys = {"background", "foreground", "accent", "cursor", "selection_background", "selection_foreground", *ANSI_NAMES, *{f"color{i}" for i in range(16)}}
    out: Dict[str, str] = {}
    for raw in edits:
        if "=" not in raw:
            raise ValueError(f"invalid color edit: {raw}")
        key, value = raw.split("=", 1)
        key = key.strip().replace("-", "_")
        if key in CAMEL.values():
            reverse = {v: k for k, v in CAMEL.items()}
            key = reverse[key]
        key = key.lower()
        if key in {"selectionbackground", "selection_bg"}:
            key = "selection_background"
        if key in {"selectionforeground", "selection_fg"}:
            key = "selection_foreground"
        if key not in valid_keys:
            raise ValueError(f"unsupported color role: {key}")
        parsed = parse_hex_strict(value, key)
        out[key] = parsed
        if key in ANSI_NAMES:
            out[f"color{ANSI_NAMES.index(key)}"] = parsed
        elif key.startswith("color") and key[5:].isdigit():
            idx = int(key[5:])
            if 0 <= idx < len(ANSI_NAMES):
                out[ANSI_NAMES[idx]] = parsed
    return out


def theme_base_colors(bp: Dict[str, Any]) -> Dict[str, str]:
    """The theme's 22 base colors pre-restyle, as a normalized map for editing."""
    pkg_dir_name = Path(str(bp.get("path"))).name
    files = compose_theme_files(pkg_dir_name)
    base_map: Dict[str, str] = {}
    if "colors.toml" in files:
        with contextlib.suppress(Exception):
            base_map = parse_colors_toml(files["colors.toml"])
    if blueprint_mode(bp) in {"dark", "light"}:
        base_map["mode"] = blueprint_mode(bp)
    base_bp = palette_from_colors_map(base_map, name=str(bp.get("name") or ""), wallpaper="", source=blueprint_source(bp))
    ext = base_bp["palette"]["extendedColors"]
    out: Dict[str, str] = dict(ext)
    for i, c in enumerate(base_bp["palette"]["colors"]):
        out[f"color{i}"] = c
        out[ANSI_NAMES[i]] = c
    return out


def persist_color_edits(edits: List[str], name: str) -> Dict[str, Any]:
    """Merge edits over a theme's base palette, write the overlay colors.toml
    (source preserved, no contrast rewrite), then re-apply."""
    target = None
    if name and name != "manual-theme":
        target = find_theme(name)
    if target is None:
        target = find_theme(str(current_theme().get("name") or ""))
    if not target or not target.get("package"):
        raise ValueError(f"not a theme package: {name or current_theme().get('name', '(current)')}; save it first")
    edit_map = parse_color_edits(edits)
    base = theme_base_colors(target)
    base.update(edit_map)
    pkg_dir_name = Path(str(target.get("path"))).name
    overlay = user_themes_dir() / pkg_dir_name / "colors.toml"
    write_file(overlay, colors_toml_from_map(base))
    refreshed = load_theme_package(pkg_dir_name) or target
    result = apply_theme_obj(refreshed)
    result["persisted"] = str(overlay)
    result["name"] = refreshed.get("name")
    return result


def apply_color_edits(edits: List[str], name: str, mode: str | None = None, wallpaper: str | None = None, save: bool = False) -> Dict[str, Any]:
    data = current_theme_color_data(mode=mode, wallpaper=wallpaper)
    data.update(parse_color_edits(edits))
    bp = palette_from_colors_map(data, name=name or current_theme().get("name", "manual"), wallpaper=wallpaper if wallpaper is not None else current_theme().get("wallpaper", ""))
    result = apply_theme_obj(bp)
    result["saved"] = ""
    if save:
        result["saved"] = str(save_theme_package(bp, name))
    return result


def theme_role_universe(bp: Dict[str, Any]) -> Dict[str, str]:
    """Hex-valued derived roles for a theme — the app-override role namespace."""
    roles = app_target_roles(bp, target_roles(bp))
    return {k: v for k, v in roles.items() if isinstance(v, str) and HEX_RE.match(str(v).strip())}


def app_template_roles(app: str, bp: Dict[str, Any], effective_roles: Dict[str, str]) -> List[str]:
    """Ordered, unique role tokens the app's rendered targets consume for this theme."""
    curated_apps = bp.get("apps") or {}
    skip = {"name", "source", "theme_type", "wallpaper"}
    ordered: List[str] = []
    seen: set[str] = set()
    for cfg_path in sorted(targets_dir().glob("*/config.json")):
        cfg = json.loads(cfg_path.read_text())
        if str(cfg.get("app") or "") != app or not cfg.get("template"):
            continue
        curated_name = str(cfg.get("curatedFile") or "")
        additional = cfg.get("curatedMode") == "additional"
        if curated_name and curated_name in curated_apps and not additional:
            continue  # curated file wins verbatim; nothing generated to override
        template = (cfg_path.parent / cfg["template"]).read_text()
        for match in TEMPLATE_RE.finditer(template):
            token = match.group(1)
            if token in seen or token in skip:
                continue
            value = effective_roles.get(token)
            if isinstance(value, str) and HEX_RE.match(str(value).strip()):
                seen.add(token)
                ordered.append(token)
    return ordered


def app_curated_file(app: str, bp: Dict[str, Any]) -> str:
    """The curated file name this app installs for the current theme, or ""."""
    apps = bp.get("apps") or {}
    for cfg_path in sorted(targets_dir().glob("*/config.json")):
        cfg = json.loads(cfg_path.read_text())
        if str(cfg.get("app") or "") == app and str(cfg.get("curatedFile") or "") in apps:
            return str(cfg.get("curatedFile"))
    return ""


def curated_app_colors(app: str, bp: Dict[str, Any], curated_name: str | None = None) -> List[Dict[str, Any]]:
    """Deduped unique #rrggbb colors in the app's curated file, each with the
    keys that reference it. Powers curated-app palette editing (recolor-all)."""
    apps = bp.get("apps") or {}
    if curated_name is None:
        curated_name = app_curated_file(app, bp)
    if not curated_name or curated_name not in apps:
        return []
    try:
        text = Path(apps[curated_name]).read_text(errors="ignore")
    except OSError:
        return []
    by_hex: Dict[str, List[str]] = {}
    order: List[str] = []
    for line in text.splitlines():
        label = ""
        lm = re.search(r'([A-Za-z0-9_.\[\]-]+)\s*[=:]', line)
        if lm:
            label = lm.group(1)
            b = re.search(r'\[([^\]]+)\]', label)
            if b:
                label = b.group(1)
        for m in re.finditer(r'#[0-9a-fA-F]{6}\b', line):
            hexv = m.group(0).lower()
            if hexv not in by_hex:
                by_hex[hexv] = []
                order.append(hexv)
            if label and label not in by_hex[hexv]:
                by_hex[hexv].append(label)
    return [{"value": h, "keys": by_hex[h]} for h in order]


def app_role_view(app: str, bp: Dict[str, Any]) -> Dict[str, Any]:
    """{roles:[{role,value,overridden}], curated:bool, curatedColors:[...]} —
    editable roles for template apps, or the file's colors for curated apps."""
    effective = theme_role_universe(bp)
    overrides = bp_app_overrides(bp).get(app, {})
    roles: List[Dict[str, Any]] = []
    for role in app_template_roles(app, bp, effective):
        roles.append({
            "role": role,
            "value": overrides.get(role) or effective.get(role, ""),
            "overridden": role in overrides,
        })
    curated = not roles
    curated_name = app_curated_file(app, bp) if curated else ""
    return {
        "roles": roles,
        "curated": curated,
        "curatedColors": curated_app_colors(app, bp, curated_name) if curated else [],
        "curatedFile": curated_name,
    }


def set_theme_adjustments(pkg_dir_name: str, adj: Dict[str, int]) -> Dict[str, int]:
    """Store restyle adjustments in the overlay theme.json (omit when all zero).

    When clearing leaves the overlay metadata identical to the built-in, the
    overlay theme.json is dropped so the theme reads unmodified again.
    """
    adj = normalize_adjustments(adj)
    meta = read_theme_overlay_meta(pkg_dir_name)
    if adjustments_all_zero(adj):
        meta.pop("adjustments", None)
    else:
        meta["adjustments"] = adj
    builtin_meta_path = builtin_themes_dir() / pkg_dir_name / "theme.json"
    user_meta_path = user_themes_dir() / pkg_dir_name / "theme.json"
    if builtin_meta_path.is_file():
        with contextlib.suppress(Exception):
            builtin_meta = json.loads(builtin_meta_path.read_text())
            if meta == builtin_meta:
                if user_meta_path.exists():
                    user_meta_path.unlink()
                return adj
    write_theme_overlay_meta(pkg_dir_name, meta)
    return adj


def load_deps() -> Dict[str, Any]:
    try:
        return json.loads(deps_file().read_text())
    except Exception:
        return {"version": 1, "features": {}}


def command_exists(name: str) -> bool:
    if not name:
        return False
    if "/" in name:
        return Path(resolve_path(name)).exists()
    if shutil.which(name):
        return True
    local = home() / ".local" / "bin" / name
    return local.exists() and os.access(local, os.X_OK)


def _wayland_socket_owner() -> str:
    """Return the compositor owning this process's active Wayland socket."""
    socket_path = os.environ.get("WAYLAND_DISPLAY") or "wayland-0"
    if not os.path.isabs(socket_path):
        runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
        socket_path = os.path.join(runtime_dir, socket_path)
    try:
        inode = ""
        for line in Path("/proc/net/unix").read_text(errors="replace").splitlines():
            fields = line.split()
            if len(fields) >= 8 and fields[-1] == socket_path:
                inode = fields[6]
                break
        if not inode:
            return ""
        target = f"socket:[{inode}]"
        for process in Path("/proc").glob("[0-9]*"):
            for fd in (process / "fd").glob("*"):
                try:
                    if os.readlink(fd) != target:
                        continue
                    name = (process / "comm").read_text().strip().lower()
                    if name in {"hyprland", "niri"}:
                        return name
                except OSError:
                    continue
    except OSError:
        pass
    return ""


def detect_compositor() -> Dict[str, str]:
    owner = _wayland_socket_owner()
    if owner:
        return {"compositor": owner, "source": "wayland-socket-owner"}
    if os.environ.get("NIRI_SOCKET") and command_exists("niri"):
        try:
            if run(["niri", "msg", "version"], timeout=2).returncode == 0:
                return {"compositor": "niri", "source": "live-niri-ipc"}
        except (OSError, subprocess.SubprocessError):
            pass
    if os.environ.get("HYPRLAND_INSTANCE_SIGNATURE") and command_exists("hyprctl"):
        try:
            if run(["hyprctl", "-j", "version"], timeout=2).returncode == 0:
                return {"compositor": "hyprland", "source": "live-hyprland-ipc"}
        except (OSError, subprocess.SubprocessError):
            pass
    return {"compositor": "unknown", "source": "none"}


# Commands VGS needs a CAPABILITY from, not merely presence.
#
# Each entry runs the command and asks it to do the thing VGS actually depends
# on. That is deliberately not a version comparison: a version string is a
# proxy, and a fragile one — the manifest declares 67 commands, each with its
# own `--version` shape, several writing to stderr, and a mis-parse would report
# a working system as broken, which is worse than the gap it closes.
#
# The bar for a new entry is a documented minimum that a REACHABLE system can
# actually violate. Presence-only stays the default.
# See docs/decisions/D005-dependency-version-constraints.md.
CAPABILITY_PROBES: Dict[str, Dict[str, Any]] = {
    "jq": {
        # Regex builtins arrived in jq 1.5 (2015-08-16) — `test`, `match`,
        # `sub`, `gsub`, and `gsub/3` with them; jq 1.4 has none of them. Every
        # jq-using helper in bin/ needs them.
        "argv": ["jq", "-ne", '("a" | test("a")) and (("ab" | gsub("b"; "c"; "i")) == "ac")'],
        "requirement": "needs regex builtins (jq >= 1.5)",
    },
}

# Probing shells out, and a command can appear in several feature groups, so the
# verdict is computed once per process.
_CAPABILITY_PROBE_CACHE: Dict[str, bool] = {}


def capability_probe_ok(command: str) -> bool:
    """True when ``command`` can do what VGS needs of it.

    Fail-SAFE in one direction only: a probe that cannot be run at all (the
    binary vanished between the presence check and here, a timeout) is reported
    as satisfied. Reporting a working system as broken on the strength of a
    probe that never executed is the false negative this whole mechanism exists
    to avoid; a genuinely unusable command still fails the probe by returning
    non-zero.
    """
    probe = CAPABILITY_PROBES.get(command)
    if not probe:
        return True
    if command in _CAPABILITY_PROBE_CACHE:
        return _CAPABILITY_PROBE_CACHE[command]
    try:
        completed = subprocess.run(
            probe["argv"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            timeout=10,
            check=False,
        )
        ok = completed.returncode == 0
    except (OSError, subprocess.SubprocessError):
        ok = True
    _CAPABILITY_PROBE_CACHE[command] = ok
    return ok


def _unusable_commands(commands: Iterable[str]) -> List[str]:
    """Present-but-unusable entries, phrased for the same list as `missing`.

    `missing` is joined into user-facing text, and "installed but unusable" is
    not the same problem as "not installed" — installing the package again does
    not fix it. The entry says which, so the message cannot be acted on wrongly.
    """
    unusable = []
    for command in commands:
        if command not in CAPABILITY_PROBES:
            continue
        if not command_exists(command):
            continue  # already reported as missing; do not say it twice
        if capability_probe_ok(command):
            continue
        unusable.append(f"{command} (installed but unusable: {CAPABILITY_PROBES[command]['requirement']})")
    return unusable


def feature_status() -> Dict[str, Any]:
    data = load_deps()
    compositor = detect_compositor()["compositor"]
    features: Dict[str, Any] = {}
    for feature, spec in (data.get("features") or {}).items():
        commands = list(spec.get("commands") or [])
        compositor_commands = {
            str(name): list(values or [])
            for name, values in (spec.get("compositorCommands") or {}).items()
        }
        selected_compositor = compositor if compositor in compositor_commands else ""
        if selected_compositor:
            commands.extend(compositor_commands[selected_compositor])
        missing = [cmd for cmd in commands if not command_exists(cmd)]
        # A declared minimum that nothing checks is a comment pretending to be a
        # constraint (VGS-89). Present-but-unusable lands in the same list, so
        # every existing consumer — `deps status`, the capture modal's toast —
        # reports it without knowing the mechanism exists.
        unusable = _unusable_commands(commands)
        missing.extend(unusable)
        alternatives = [list(group) for group in (spec.get("anyCommands") or [])]
        missing_alternatives = [
            group for group in alternatives
            if not any(command_exists(command) for command in group)
        ]
        missing.extend("|".join(group) for group in missing_alternatives)
        if compositor_commands and not selected_compositor:
            branch_missing = {
                name: [command for command in branch if not command_exists(command)]
                for name, branch in compositor_commands.items()
            }
            if not any(len(values) == 0 for values in branch_missing.values()):
                missing.append("|".join(
                    f"{name}:{','.join(values)}" for name, values in branch_missing.items()
                ))
        features[feature] = {
            "available": len(missing) == 0,
            "required": bool(spec.get("required", False)),
            "commands": commands,
            "anyCommands": alternatives,
            "compositorCommands": compositor_commands,
            "compositor": selected_compositor or compositor,
            "missing": missing,
            # Also broken out, so a machine consumer can tell "reinstall this"
            # from "upgrade this" without parsing the sentence.
            "unusable": unusable,
            "requiresFeatures": list(spec.get("requiresFeatures") or []),
        }
    # Feature-to-feature requirements, resolved once every group is known. This
    # is what lets one group own a command list (`terminal`) while the groups
    # that need it still report unavailable, instead of restating the list.
    for feature, entry in features.items():
        for required in entry["requiresFeatures"]:
            dependency = features.get(required)
            if dependency and not dependency["available"]:
                entry["missing"].append(f"@{required}")
                entry["available"] = False
    return {"version": data.get("version", 1), "compositor": compositor, "features": features}


QS_BINARIES = ("qs", "quickshell")
# Sorts after every real ISO launch time, so an entry whose launch time the
# registry did not report is treated as the youngest instance and yields.
QS_UNKNOWN_LAUNCH_TIME = "~"


def _proc_root() -> Path:
    return Path(os.environ.get("VSHELL_PROC_ROOT", "/proc"))


def _pid_alive(pid: int) -> bool:
    if pid <= 0:
        return False
    return (_proc_root() / str(pid)).exists()


def _proc_stat_fields(pid: int) -> List[str]:
    """/proc/<pid>/stat from field 3 onward, or [] when unreadable.

    comm (field 2) is parenthesised and may contain spaces, so everything after
    the final ')' is parsed positionally: index 0 is field 3 (state).
    """
    if pid <= 0:
        return []
    try:
        data = (_proc_root() / str(pid) / "stat").read_text(encoding="utf-8", errors="replace")
    except OSError:
        return []
    return data.rpartition(")")[2].split()


def _vgs_peer_alive(pid: int) -> bool:
    """True when ``pid`` is a live Quickshell process *right now*.

    A registry entry only records the pid a shell had. After a hard kill the
    entry can outlive the process and the number can be reused by something
    unrelated, and a zombie keeps a readable /proc entry while owning no
    surfaces. Either would make the session shell rule itself the duplicate and
    terminate itself, so a peer is confirmed against the process actually
    running under that pid.
    """
    fields = _proc_stat_fields(pid)
    if not fields:
        return False
    if fields[0] == "Z":  # exited, not yet reaped: owns no surfaces
        return False
    proc = _proc_root() / str(pid)
    try:
        executable = os.path.basename(os.path.realpath(proc / "exe"))
    except OSError:
        executable = ""
    if executable in QS_BINARIES:
        return True
    # /proc/<pid>/exe is readable only for our own processes; comm is not, and
    # is enough to reject a process that merely inherited the number.
    try:
        comm = (proc / "comm").read_text(encoding="utf-8", errors="replace").strip()
    except OSError:
        return False
    return comm in QS_BINARIES


def vgs_shell_entry() -> Path:
    """Path of the QML entrypoint this checkout would launch."""
    root = os.environ.get("VSHELL_ROOT", "").strip()
    base = Path(root) if root else repo_root()
    return base / "quickshell" / "vshell" / "shell.qml"


def _resolve_path(value: str) -> str:
    try:
        return str(Path(value).resolve())
    except OSError:
        return value


def qs_list_instances() -> Dict[str, Any]:
    """Read the Quickshell instance registry for the current XDG_RUNTIME_DIR.

    A sandboxed smoke run has its own runtime dir, so its instances are
    intentionally invisible here.
    """
    binary = ""
    for name in QS_BINARIES:
        binary = shutil.which(name) or ""
        if binary:
            break
    if not binary:
        # Distinct from a failed read: there is no registry to consult here at
        # all, so a caller can skip rather than report an unverified session.
        return {"ok": False, "error": "quickshell CLI (qs) not found",
                "cliMissing": True, "instances": []}
    try:
        proc = subprocess.run(
            [binary, "list", "--all", "--json"],
            text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5,
        )
    except (OSError, subprocess.SubprocessError) as exc:
        return {"ok": False, "error": str(exc), "instances": []}
    if proc.returncode != 0:
        detail = (proc.stderr or "").strip() or f"qs list exited {proc.returncode}"
        return {"ok": False, "error": detail, "instances": []}
    try:
        data = json.loads(proc.stdout or "[]")
    except json.JSONDecodeError as exc:
        return {"ok": False, "error": f"unparsable qs list output: {exc}", "instances": []}
    if not isinstance(data, list):
        return {"ok": False, "error": "unexpected qs list output", "instances": []}
    return {"ok": True, "instances": [entry for entry in data if isinstance(entry, dict)]}


def _vgs_instance_entry(entry: Dict[str, Any]) -> Dict[str, Any]:
    try:
        pid = int(entry.get("pid") or 0)
    except (TypeError, ValueError):
        pid = 0
    return {
        "pid": pid,
        "id": str(entry.get("id") or ""),
        "shellId": str(entry.get("shell_id") or ""),
        "configPath": str(entry.get("config_path") or ""),
        "launchTime": str(entry.get("launch_time") or ""),
    }


def _vgs_instance_matches(entry: Dict[str, Any], shell_path: str, shell_id: str) -> bool:
    if shell_id and entry["shellId"] == shell_id:
        return True
    if not entry["configPath"]:
        return False
    return _resolve_path(entry["configPath"]) == shell_path


def _vgs_instance_order(entry: Dict[str, Any]) -> Tuple[str, int]:
    return (entry["launchTime"] or QS_UNKNOWN_LAUNCH_TIME, entry["pid"])


def _proc_start_ticks(pid: int) -> Any:
    """Process start time in clock ticks since boot, or None when unreadable.

    Monotonic within a boot and independent of the Quickshell registry's
    metadata, so it is the authoritative age comparison whenever both processes
    are readable.
    """
    # starttime is field 22, i.e. index 19 once field 3 is index 0.
    fields = _proc_stat_fields(pid)
    if len(fields) < 20:
        return None
    try:
        return int(fields[19])
    except ValueError:
        return None


def _vgs_instance_precedes(peer: Dict[str, Any], own: Dict[str, Any], own_registered: bool) -> bool:
    """True only when ``peer`` is *provably* older than ``own``.

    Kernel start times decide it whenever both processes are readable. The
    registry's launch time is only a fallback, and a *missing* launch time is
    never treated as proof: ranking a peer above a registered shell whose launch
    time the registry did not report would let a hand-run duplicate unseat the
    session shell, the exact outcome this guard exists to prevent.
    """
    peer_start = _proc_start_ticks(peer["pid"])
    own_start = _proc_start_ticks(own["pid"])
    if peer_start is not None and own_start is not None:
        if peer_start != own_start:
            return peer_start < own_start
        return peer["pid"] < own["pid"]
    if not peer["launchTime"]:
        return False
    if not own["launchTime"]:
        # No entry of our own yet: we started moments ago, so anything already
        # registered with a real launch time predates us. A *registered* shell
        # with no launch time is a degraded registry, not a young shell.
        return not own_registered
    if peer["launchTime"] != own["launchTime"]:
        return peer["launchTime"] < own["launchTime"]
    return peer["pid"] < own["pid"]


def vgs_instance_report(pid: int = 0, shell_id: str = "", config_path: str = "") -> Dict[str, Any]:
    """Inventory of live VGS shells, plus a duplicate verdict for ``pid``.

    The verdict is deliberately fail-open: when the registry cannot be read, or
    when no peer can be *proven* older, the caller keeps running.  Only a shell
    that a live peer demonstrably predates ever sees itself as the duplicate.
    """
    shell_path = _resolve_path(config_path) if config_path else _resolve_path(str(vgs_shell_entry()))
    listing = qs_list_instances()
    report: Dict[str, Any] = {
        "ok": bool(listing["ok"]),
        "supported": bool(listing["ok"]),
        "shellPath": shell_path,
        "shellId": shell_id,
        "instances": [],
        "self": None,
        "owner": None,
        "duplicate": False,
        "reason": "",
    }
    if not listing["ok"]:
        report["error"] = listing.get("error", "")
        report["cliMissing"] = bool(listing.get("cliMissing"))
        report["reason"] = "instance registry unavailable"
        return report

    matches = []
    for raw in listing["instances"]:
        entry = _vgs_instance_entry(raw)
        if not _vgs_instance_matches(entry, shell_path, shell_id):
            continue
        if not _vgs_peer_alive(entry["pid"]):
            continue
        matches.append(entry)
    matches.sort(key=_vgs_instance_order)
    report["instances"] = matches

    if pid <= 0:
        report["reason"] = "listing only"
        return report

    own = next((entry for entry in matches if entry["pid"] == pid), None)
    own_registered = own is not None
    if own is None:
        own = {"pid": pid, "id": "", "shellId": shell_id, "configPath": shell_path,
               "launchTime": ""}
    report["self"] = own

    peers = [entry for entry in matches if entry["pid"] != pid]
    older = [entry for entry in peers if _vgs_instance_precedes(entry, own, own_registered)]
    if older:
        owner = min(older, key=_vgs_instance_order)
        report["owner"] = owner
        report["duplicate"] = True
        report["reason"] = (
            f"another VGS shell (pid {owner['pid']}) already owns this session; "
            "use scripts/qml-smoke.sh for validation"
        )
        return report

    report["owner"] = own
    if any(not entry["launchTime"] for entry in peers):
        report["reason"] = "peer launch time unknown; keeping this shell"
    elif peers:
        report["reason"] = "oldest instance"
    else:
        report["reason"] = "sole instance"
    return report


def cmd_instances(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell instances")
    sub = parser.add_subparsers(dest="cmd")
    p_list = sub.add_parser("list", help="list live VGS Quickshell instances")
    p_list.add_argument("--json", action="store_true")
    p_list.add_argument("--config-path", default="")
    p_guard = sub.add_parser("guard", help="duplicate-shell verdict for a pid")
    p_guard.add_argument("--pid", type=int, required=True)
    p_guard.add_argument("--shell-id", default="")
    p_guard.add_argument("--config-path", default="")
    p_guard.add_argument("--json", action="store_true")
    if not argv or argv[0].startswith("-"):
        argv = ["list", *(argv or [])]
    args = parser.parse_args(argv)

    if args.cmd == "guard":
        report = vgs_instance_report(args.pid, args.shell_id, args.config_path)
        print(json.dumps(report))
        return 0

    report = vgs_instance_report(0, "", getattr(args, "config_path", ""))
    if args.json:
        print(json.dumps(report))
    if not report["ok"]:
        # 2 == no registry to consult here; 1 == the read failed. Callers that
        # assert "validation disturbed nothing" must tell those apart.
        if not args.json:
            eprint(f"vshell instances: {report.get('error', 'registry unavailable')}")
        return 2 if report.get("cliMissing") else 1
    if args.json:
        return 0
    if not report["instances"]:
        print("no live VGS Quickshell instances")
        return 0
    for entry in report["instances"]:
        print(f"{entry['pid']}\t{entry['id']}\t{entry['launchTime']}\t{entry['configPath']}")
    return 0


def cmd_compositor(argv: List[str]) -> int:
    if not argv or argv[0] != "current":
        eprint("Usage: vshell compositor current [--json]")
        return 2
    result = detect_compositor()
    if "--json" in argv:
        print(json.dumps(result))
    else:
        print(result["compositor"])
    return 0


def cmd_deps(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell deps")
    sub = parser.add_subparsers(dest="cmd", required=True)
    p_status = sub.add_parser("status")
    p_status.add_argument("--json", action="store_true")
    p_check = sub.add_parser("check")
    p_check.add_argument("feature")
    p_check.add_argument("--json", action="store_true")
    args = parser.parse_args(argv)
    status = feature_status()
    if args.cmd == "status":
        # Notification ownership is not a missing-command problem, so it is not
        # a feature group -- but a lost D-Bus race disables the shell's most
        # visible subsystem, and this is where a user looks for that.
        notifications = notification_status()
        if args.json:
            print(json.dumps({**status, "notifications": notifications}, indent=2))
        else:
            for name, info in status.get("features", {}).items():
                marker = "ok" if info.get("available") else "missing: " + ", ".join(info.get("missing") or [])
                print(f"{name}: {marker}")
            _print_notification_status(notifications)
        return 0
    if args.cmd == "check":
        info = (status.get("features") or {}).get(args.feature)
        if not info:
            if args.json:
                print(json.dumps({"feature": args.feature, "available": False, "missing": ["unknown feature"]}))
            else:
                print("missing: unknown feature")
            return 1
        if args.json:
            out = {"feature": args.feature, **info}
            print(json.dumps(out, indent=2))
        else:
            print("ok" if info.get("available") else "missing: " + ", ".join(info.get("missing") or []))
        return 0 if info.get("available") else 1
    return 2


def asdcontrol_path() -> Path | None:
    bundled = repo_root() / "bin" / "vshell-asdcontrol"
    if bundled.exists() and os.access(bundled, os.X_OK):
        probe = run([str(bundled), "--list-all"])
        if probe.returncode == 0:
            return bundled
    src = repo_root() / "third_party" / "asdcontrol" / "asdcontrol.cpp"
    compiler = shutil.which("g++") or shutil.which("c++")
    if not src.exists() or not compiler:
        return None
    out = cache_dir() / "bin" / "asdcontrol"
    try:
        out.parent.mkdir(parents=True, exist_ok=True)
        if not out.exists() or out.stat().st_mtime < src.stat().st_mtime:
            built = run([compiler, "-std=c++17", "-O2", str(src), "-o", str(out)])
            if built.returncode != 0:
                return None
            out.chmod(0o755)
        return out if os.access(out, os.X_OK) else None
    except Exception:
        return None


# --- small IO helpers -------------------------------------------------------

def _read_text(path: "Path | str") -> str | None:
    try:
        return Path(path).read_text(errors="ignore").strip()
    except OSError:
        return None


def _read_bytes(path: "Path | str") -> bytes:
    try:
        with open(path, "rb") as fh:
            return fh.read()
    except OSError:
        return b""


def _run_timeout(cmd: List[str], timeout: float = 6.0) -> subprocess.CompletedProcess[str]:
    try:
        return subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
    except subprocess.TimeoutExpired:
        return subprocess.CompletedProcess(cmd, 124, "", f"timed out after {timeout:g}s")
    except FileNotFoundError:
        return subprocess.CompletedProcess(cmd, 127, "", "command not found")


def _percent_from_raw(raw: int, min_value: int = 400, max_value: int = 60000) -> int:
    if max_value <= min_value:
        return 0
    return max(0, min(100, round(((raw - min_value) / (max_value - min_value)) * 100)))


def _raw_from_percent(percent: int, min_value: int = 400, max_value: int = 60000) -> int:
    percent = max(0, min(100, int(percent)))
    return round(min_value + (max_value - min_value) * (percent / 100.0))


# --- USB / HID device identity (sysfs first, udevadm fallback) --------------

_IFACE_RE = re.compile(r"^\d+-[\d.]+:\d+\.\d+$")


def _usb_identity_from_syspath(path: Path) -> Dict[str, Any] | None:
    """Walk a sysfs device path up to its USB device and read stable identity.

    Returns vendor/product (lowercase hex, no 0x), serial, human USB product
    name, the owning HID interface number, and the USB device path. Interface
    number is *discovered*, never assumed.
    """
    interface = ""
    for node in [path, *path.parents]:
        if not interface and _IFACE_RE.match(node.name):
            interface = node.name
        if (node / "idVendor").exists():
            vendor = _read_text(node / "idVendor")
            product = _read_text(node / "idProduct")
            if not vendor or not product:
                return None
            ifnum = ""
            if interface:
                m = re.search(r":\d+\.(\d+)$", interface)
                if m:
                    ifnum = str(int(m.group(1)))
            return {
                "vendor": vendor.lower(),
                "product": product.lower(),
                "serial": _read_text(node / "serial") or "",
                "usbName": _read_text(node / "product") or "",
                "interface": ifnum,
                "usbPath": node.name,
            }
    return None


def _class_device_identity(subsystem: str, name: str) -> Dict[str, Any] | None:
    link = Path("/sys/class") / subsystem / name
    if not link.exists():
        return None
    try:
        real = link.resolve()
    except OSError:
        return None
    return _usb_identity_from_syspath(real)


def _udev_identity_fallback(node: str) -> Dict[str, Any] | None:
    """Last-resort identity via `udevadm info -a` when sysfs walking fails."""
    if not command_exists("udevadm"):
        return None
    cp = run(["udevadm", "info", "-a", "-n", node])
    fields = {"vendor": "", "product": "", "serial": "", "usbName": "", "interface": ""}
    attr_map = {
        "idVendor": "vendor",
        "idProduct": "product",
        "serial": "serial",
        "product": "usbName",
        "bInterfaceNumber": "interface",
    }
    for line in (cp.stdout or "").splitlines():
        for attr, key in attr_map.items():
            m = re.search(r'ATTRS\{' + attr + r'\}=="([^"]+)"', line)
            if m and not fields[key]:
                fields[key] = m.group(1)
    if not fields["vendor"] or not fields["product"]:
        return None
    ifnum = fields["interface"]
    if ifnum:
        try:
            ifnum = str(int(ifnum))
        except ValueError:
            pass
    return {
        "vendor": fields["vendor"].lower(),
        "product": fields["product"].lower(),
        "serial": fields["serial"],
        "usbName": fields["usbName"],
        "interface": ifnum,
        "usbPath": "",
    }


def _hiddev_nodes() -> List[Dict[str, Any]]:
    """All /dev/usb/hiddev* + /dev/hiddev* nodes with owning-USB identity."""
    out: List[Dict[str, Any]] = []
    seen: set[str] = set()
    for node in sorted(set(glob.glob("/dev/usb/hiddev*") + glob.glob("/dev/hiddev*"))):
        real = os.path.realpath(node)
        if real in seen:
            continue
        seen.add(real)
        ident = _class_device_identity("usbmisc", os.path.basename(node)) or _udev_identity_fallback(node)
        if ident is None:
            continue
        ident["node"] = node
        out.append(ident)
    return out


def _hidraw_nodes() -> List[Dict[str, Any]]:
    """All /dev/hidraw* nodes with owning-USB identity + HID report descriptor.

    hidraw is created by the HID core for every HID device, so it works even
    when CONFIG_USB_HIDDEV is disabled (unlike hiddev / asdcontrol).
    """
    out: List[Dict[str, Any]] = []
    for sysdir in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
        name = os.path.basename(sysdir)
        node = f"/dev/{name}"
        if not os.path.exists(node):
            continue
        devlink = os.path.join(sysdir, "device")
        ident = None
        if os.path.exists(devlink):
            ident = _usb_identity_from_syspath(Path(os.path.realpath(devlink)))
        if ident is None:
            ident = _hid_id_from_uevent(os.path.join(devlink, "uevent"))
        if ident is None:
            continue
        ident["node"] = node
        ident["hidraw"] = name
        ident["descriptor"] = _read_bytes(os.path.join(devlink, "report_descriptor"))
        out.append(ident)
    return out


def _hid_id_from_uevent(uevent_path: str) -> Dict[str, Any] | None:
    text = _read_text(uevent_path)
    if not text:
        return None
    m = re.search(r"HID_ID=[0-9a-fA-F]+:0*([0-9a-fA-F]{1,4}):0*([0-9a-fA-F]{1,4})", text)
    if not m:
        return None
    return {
        "vendor": m.group(1).lower().zfill(4),
        "product": m.group(2).lower().zfill(4),
        "serial": "",
        "usbName": "",
        "interface": "",
        "usbPath": "",
    }


# --- Apple HID feature-report backend (native hidraw + asdcontrol fallback) --

APPLE_REPORT_ID = 1
APPLE_REPORT_LEN = 7


def _hidioc_get_feature(length: int) -> int:
    # _IOC(_IOC_READ|_IOC_WRITE, 'H', 0x07, length)
    return (3 << 30) | (length << 16) | (ord("H") << 8) | 0x07


def _hidioc_set_feature(length: int) -> int:
    # _IOC(_IOC_READ|_IOC_WRITE, 'H', 0x06, length)
    return (3 << 30) | (length << 16) | (ord("H") << 8) | 0x06


def _apple_encode(raw: int) -> bytearray:
    buf = bytearray(APPLE_REPORT_LEN)
    buf[0] = APPLE_REPORT_ID
    buf[1] = raw & 0xFF
    buf[2] = (raw >> 8) & 0xFF
    buf[3] = (raw >> 16) & 0xFF
    buf[4] = (raw >> 24) & 0xFF
    return buf


def _apple_decode(buf: "bytes | bytearray") -> int:
    return buf[1] | (buf[2] << 8) | (buf[3] << 16) | (buf[4] << 24)


def _apple_hidraw_read(node: str) -> int | None:
    fd = None
    for mode in (os.O_RDONLY, os.O_RDWR):
        try:
            fd = os.open(node, mode)
            break
        except OSError:
            fd = None
    if fd is None:
        return None
    try:
        buf = _apple_encode(0)
        try:
            n = fcntl.ioctl(fd, _hidioc_get_feature(APPLE_REPORT_LEN), buf, True)
        except OSError:
            return None
        if n < 5:
            return None
        return _apple_decode(buf)
    finally:
        os.close(fd)


def _apple_hidraw_write(node: str, raw: int) -> None:
    fd = os.open(node, os.O_RDWR)
    try:
        buf = _apple_encode(raw)
        fcntl.ioctl(fd, _hidioc_set_feature(APPLE_REPORT_LEN), buf, True)
    finally:
        os.close(fd)


def _apple_descriptor_is_monitor(desc: bytes) -> bool:
    """True if the HID report descriptor declares a Monitor/VESA usage page.

    Usage Page (Monitor) = 0x80, VESA Virtual Controls = 0x82, in 1- or 2-byte
    item forms. Unknown descriptors return True so the in-range value probe
    still gets a chance (never wrongly excludes the real control interface).
    """
    if not desc:
        return True
    return (
        b"\x05\x80" in desc
        or b"\x05\x82" in desc
        or b"\x06\x80\x00" in desc
        or b"\x06\x82\x00" in desc
    )


def _apple_raw_in_range(raw: int, spec: Dict[str, Any]) -> bool:
    lo = max(0, int(spec["min"]) // 2)
    hi = max(int(spec["max"]), APPLE_RAW_PROBE_CEILING)
    hi += hi // 10
    return lo <= raw <= hi


def _apple_control_channels() -> Dict[Tuple[str, str], Dict[str, List[Dict[str, Any]]]]:
    """Group Apple-display HID candidates by (product, serial)."""
    groups: Dict[Tuple[str, str], Dict[str, List[Dict[str, Any]]]] = {}
    for node in _hidraw_nodes():
        if node.get("vendor") != APPLE_VENDOR or node.get("product") not in APPLE_DISPLAYS:
            continue
        key = (node["product"], node.get("serial", ""))
        groups.setdefault(key, {"hidraw": [], "hiddev": []})["hidraw"].append(node)
    for node in _hiddev_nodes():
        if node.get("vendor") != APPLE_VENDOR or node.get("product") not in APPLE_DISPLAYS:
            continue
        key = (node["product"], node.get("serial", ""))
        groups.setdefault(key, {"hidraw": [], "hiddev": []})["hiddev"].append(node)
    return groups


def _apple_devices(include_unavailable: bool = False) -> Tuple[List[Dict[str, Any]], List[str]]:
    devices: List[Dict[str, Any]] = []
    errors: List[str] = []
    groups = _apple_control_channels()
    if not groups:
        return devices, errors

    product_index: Dict[str, int] = {}
    asd_cache: Dict[str, Any] = {"built": False, "path": None}

    def get_asd() -> Path | None:
        if not asd_cache["built"]:
            asd_cache["path"] = asdcontrol_path()
            asd_cache["built"] = True
        return asd_cache["path"]

    for (product, serial), chans in sorted(groups.items(), key=lambda kv: (kv[0][0], kv[0][1])):
        spec = APPLE_DISPLAYS[product]
        idx = product_index.get(product, 0)
        product_index[product] = idx + 1
        serial_suffix = re.sub(r"[^A-Za-z0-9]", "", serial)[-8:].lower()
        if idx == 0:
            dev_id = spec["alias"]
        elif serial_suffix:
            dev_id = f"{spec['alias']}-{serial_suffix}"
        else:
            dev_id = f"{spec['alias']}-{idx + 1}"

        method = ""
        node = ""
        raw: int | None = None

        # 1) Native hidraw feature report (no kernel CONFIG_USB_HIDDEV needed).
        # The in-range read probe is the actual proof an interface is the
        # brightness control; a Monitor/VESA report descriptor is only a
        # tie-breaker preference (never an exclusion), so an unusual descriptor
        # can't hide a control interface that answers a valid brightness read.
        hidraw_hits: List[Tuple[Dict[str, Any], int]] = []
        for cand in sorted(chans["hidraw"], key=lambda c: c.get("interface", "")):
            if not os.access(cand["node"], os.R_OK):
                continue
            val = _apple_hidraw_read(cand["node"])
            if val is None or not _apple_raw_in_range(val, spec):
                continue
            hidraw_hits.append((cand, val))
        if hidraw_hits:
            cand, val = next(
                ((c, v) for c, v in hidraw_hits if _apple_descriptor_is_monitor(c.get("descriptor", b""))),
                hidraw_hits[0],
            )
            method, node, raw = "hidraw", cand["node"], val

        # 2) hiddev via vendored asdcontrol.
        if not method:
            asd = get_asd()
            if asd:
                for cand in sorted(chans["hiddev"], key=lambda c: c.get("interface", "")):
                    if not os.access(cand["node"], os.R_OK):
                        continue
                    cp = run([str(asd), "--silent", cand["node"]])
                    if cp.returncode != 0:
                        continue
                    m = re.search(r"BRIGHTNESS\s*=\s*(\d+)", cp.stdout or "")
                    if not m:
                        continue
                    val = int(m.group(1))
                    if not _apple_raw_in_range(val, spec):
                        continue
                    method, node, raw = "asdcontrol", cand["node"], val
                    break

        all_nodes = chans["hidraw"] + chans["hiddev"]
        writable = bool(node) and os.access(node, os.W_OK)
        available = bool(method) and writable
        reason = ""

        if not method:
            sample = all_nodes[0]["node"] if all_nodes else ""
            any_readable = any(os.access(c["node"], os.R_OK) for c in all_nodes)
            if all_nodes and not any_readable:
                # The rule being present but the ACL missing means the nodes enumerated
                # before udev could apply uaccess (early boot, or before the seat went
                # active) — a retrigger fixes that without reinstalling anything.
                if _apple_udev_rule_state() == "installed":
                    fix = ("Run: sudo udevadm trigger --action=change "
                           "--subsystem-match=hidraw")
                else:
                    fix = "Run: sudo vshell brightness install-udev"
                reason = (
                    f"{spec['label']}: HID control node is not accessible to this user "
                    f"(no uaccess). {fix}"
                )
            elif not chans["hidraw"] and not get_asd():
                reason = (
                    f"{spec['label']}: no readable hidraw node and asdcontrol could not be built "
                    f"(install g++/c++ or ship bin/vshell-asdcontrol)"
                )
            else:
                reason = (
                    f"{spec['label']}: HID interfaces did not answer a brightness read. "
                    f"Run `sudo vshell brightness install-udev` and reconnect the display"
                )
            errors.append(reason)
            node = sample
            raw = _raw_from_percent(50, spec["min"], spec["max"])
        elif not writable:
            reason = (
                f"{spec['label']}: control node {node} is readable but not writable. "
                f"Run: sudo vshell brightness install-udev"
            )
            errors.append(reason)

        if not available and not include_unavailable:
            continue

        percent = _percent_from_raw(int(raw), spec["min"], spec["max"])
        devices.append({
            "id": dev_id,
            "name": dev_id,
            "label": spec["label"],
            "class": "apple",
            "backend": method or "hidraw",
            "method": method or "hidraw",
            "path": node,
            "current": int(raw),
            "currentPercent": percent,
            "max": 100,
            "rawMin": int(spec["min"]),
            "rawMax": int(spec["max"]),
            "serial": serial,
            "product": product,
            "connector": "",
            "monitorName": spec["label"],
            "available": available,
            "reason": reason,
        })
    return devices, errors


def _set_apple(dev: Dict[str, Any], raw: int) -> None:
    raw = max(int(dev["rawMin"]), min(int(dev["rawMax"]), int(raw)))
    method = dev.get("method") or "hidraw"
    node = str(dev["path"])
    if method == "hidraw":
        try:
            _apple_hidraw_write(node, raw)
        except OSError as exc:
            raise RuntimeError(f"hidraw write to {node} failed: {exc}") from exc
    else:
        asd = asdcontrol_path()
        if not asd:
            raise FileNotFoundError("asdcontrol unavailable")
        cp = run([str(asd), "--silent", "--brief", node, str(raw)])
        if cp.returncode != 0:
            raise RuntimeError((cp.stderr or cp.stdout or "asdcontrol failed").strip())


# --- DDC/CI backend (ddcutil, VESA MCCS feature 0x10) -----------------------

DDC_BRIGHTNESS_VCP = "10"


def _parse_ddc_detect(text: str) -> List[Dict[str, Any]]:
    displays: List[Dict[str, Any]] = []
    cur: Dict[str, Any] | None = None
    for raw_line in text.splitlines():
        if not raw_line.strip():
            continue
        indented = raw_line[0].isspace()
        line = raw_line.strip()
        if not indented:
            m = re.match(r"^Display\s+(\d+)", line)
            inv = re.match(r"^(Invalid display|Display .*not accessible)", line)
            if m or inv:
                if cur:
                    displays.append(cur)
                cur = {
                    "index": int(m.group(1)) if m else None,
                    "invalid": bool(inv),
                    "bus": None,
                    "connector": "",
                    "mfg": "",
                    "model": "",
                    "serial": "",
                    "product_code": "",
                    "unsupported": False,
                }
                continue
        if cur is None:
            continue
        bus_m = re.search(r"/dev/i2c-(\d+)", line)
        if bus_m and cur["bus"] is None:
            cur["bus"] = int(bus_m.group(1))
        if line.startswith("DRM connector:"):
            conn = line.split(":", 1)[1].strip()
            cur["connector"] = re.sub(r"^card\d+-", "", conn)
        elif line.startswith("Mfg id:"):
            rest = line.split(":", 1)[1].strip()
            cur["mfg"] = rest.split()[0] if rest else ""
        elif line.startswith("Model:"):
            cur["model"] = line.split(":", 1)[1].strip()
        elif line.startswith("Serial number:"):
            cur["serial"] = line.split(":", 1)[1].strip()
        elif line.startswith("Product code:"):
            rest = line.split(":", 1)[1].strip()
            cur["product_code"] = rest.split()[0] if rest else ""
        elif "does not support DDC" in line or "DDC communication failed" in line:
            cur["unsupported"] = True
    if cur:
        displays.append(cur)
    return displays


def _parse_ddc_getvcp(text: str) -> Tuple[int, int] | None:
    m = re.search(r"VCP\s+10\s+C\s+(\d+)\s+(\d+)", text)
    if m:
        return int(m.group(1)), int(m.group(2))
    m2 = re.search(r"current value\s*=\s*(\d+).*?max value\s*=\s*(\d+)", text, re.DOTALL)
    if m2:
        return int(m2.group(1)), int(m2.group(2))
    return None


def _ddc_stable_id(d: Dict[str, Any]) -> str:
    parts = [d.get("mfg") or "", d.get("model") or "", d.get("serial") or ""]
    key = "-".join(p for p in parts if p)
    if not d.get("serial"):
        # Without an EDID serial, two identical models would collide on id, and
        # QML keys every device by id -- so disambiguate by the (reasonably
        # stable) connector, falling back to the i2c bus.
        extra = d.get("connector") or (f"bus{d.get('bus')}" if d.get("bus") is not None else "")
        key = "-".join(x for x in (key, extra) if x)
    slug = re.sub(r"[^A-Za-z0-9]+", "-", key).strip("-").lower()
    return "ddc-" + (slug or "display")


_DDC_DETECT_MEMO: List[Dict[str, Any]] | None = None
_DDC_DETECT_TTL = 30.0


def _ddc_detect(force: bool = False) -> List[Dict[str, Any]]:
    """`ddcutil detect` topology, cached in-process and on disk.

    detect is slow (seconds) and its bus<->display mapping is stable across a
    cabling generation, so it is cached with a short TTL to keep the periodic
    device poll and per-keypress `set` off the ddcutil hot path (a fresh detect
    on every call could blow the backend/CLI timeout budgets on a multi-monitor
    rig). Live brightness values are still read fresh via getvcp; only the
    topology is cached.
    """
    global _DDC_DETECT_MEMO
    if _DDC_DETECT_MEMO is not None and not force:
        return _DDC_DETECT_MEMO
    cache = cache_dir() / "ddc-detect.json"
    if not force and cache.exists():
        try:
            if (time.time() - cache.stat().st_mtime) < _DDC_DETECT_TTL:
                _DDC_DETECT_MEMO = json.loads(cache.read_text())
                return _DDC_DETECT_MEMO
        except Exception:
            pass
    cp = _run_timeout(["ddcutil", "detect"], timeout=10.0)
    data = _parse_ddc_detect(cp.stdout or "")
    try:
        cache.parent.mkdir(parents=True, exist_ok=True)
        cache.write_text(json.dumps(data))
    except OSError:
        pass
    _DDC_DETECT_MEMO = data
    return data


def _ddc_invalidate() -> None:
    """Drop the cached detect topology so the next scan re-runs `ddcutil detect`
    (call after a setvcp fails -- the monitor may have moved i2c bus)."""
    global _DDC_DETECT_MEMO
    _DDC_DETECT_MEMO = None
    try:
        (cache_dir() / "ddc-detect.json").unlink()
    except OSError:
        pass


def _ddc_read(bus: int) -> Tuple[int, int] | None:
    cp = _run_timeout(["ddcutil", "--bus", str(bus), "getvcp", DDC_BRIGHTNESS_VCP, "--brief"], timeout=6.0)
    if cp.returncode != 0:
        return None
    return _parse_ddc_getvcp(cp.stdout or "")


def _ddc_devices(include_unavailable: bool = False) -> Tuple[List[Dict[str, Any]], List[str]]:
    devices: List[Dict[str, Any]] = []
    errors: List[str] = []
    if not command_exists("ddcutil"):
        return devices, errors
    for d in _ddc_detect():
        if d.get("invalid"):
            continue
        bus = d.get("bus")
        dev_id = _ddc_stable_id(d)
        label = d.get("model") or d.get("connector") or dev_id
        base = {
            "id": dev_id,
            "name": dev_id,
            "label": label,
            "class": "ddc",
            "backend": "ddcutil",
            "method": "ddcutil",
            "bus": bus,
            "connector": d.get("connector", ""),
            "monitorName": d.get("model", ""),
            "serial": d.get("serial", ""),
            "max": 100,
        }
        if d.get("unsupported") or bus is None:
            reason = f"{label}: does not support DDC/CI brightness control"
            errors.append(reason)
            if include_unavailable:
                devices.append({**base, "current": 0, "currentPercent": 0, "available": False, "reason": reason})
            continue
        read = _ddc_read(bus)
        if read is None:
            reason = (
                f"{label} on /dev/i2c-{bus}: DDC read of VCP 0x10 failed "
                f"(monitor may not implement brightness, or i2c-dev permissions are missing)"
            )
            errors.append(reason)
            if include_unavailable:
                devices.append({**base, "current": 0, "currentPercent": 0, "available": False, "reason": reason})
            continue
        cur, maxv = read
        percent = round(cur / maxv * 100) if maxv else 0
        devices.append({
            **base,
            "current": cur,
            "currentPercent": max(0, min(100, percent)),
            "vcpMax": maxv,
            "available": True,
            "reason": "",
        })
    return devices, errors


def _ddc_set(dev: Dict[str, Any], percent: int) -> None:
    bus = dev.get("bus")
    if bus is None:
        raise ValueError(f"{dev.get('id')}: no i2c bus resolved for DDC write")
    maxv = int(dev.get("vcpMax") or 100)
    value = max(0, min(maxv, round(percent / 100.0 * maxv)))
    cp = _run_timeout(["ddcutil", "--bus", str(bus), "setvcp", DDC_BRIGHTNESS_VCP, str(value)], timeout=6.0)
    if cp.returncode != 0:
        _ddc_invalidate()
        raise RuntimeError((cp.stderr or cp.stdout or "ddcutil setvcp failed").strip())


# --- backlight backend (/sys/class/backlight via brightnessctl) -------------

def _brightnessctl_devices() -> Tuple[List[Dict[str, Any]], List[str]]:
    if not command_exists("brightnessctl"):
        return [], []
    cp = run(["brightnessctl", "-m", "-c", "backlight"])
    if cp.returncode != 0:
        return [], []
    devices: List[Dict[str, Any]] = []
    for line in (cp.stdout or "").splitlines():
        parts = [p.strip() for p in line.split(",")]
        if len(parts) < 5:
            continue
        name = parts[0]
        try:
            current = int(re.sub(r"\D", "", parts[2]) or "0")
            max_value = int(re.sub(r"\D", "", parts[4]) or "100")
        except ValueError:
            continue
        if max_value <= 0:
            continue
        pct_match = re.search(r"(\d+)%", parts[3])
        pct = int(pct_match.group(1)) if pct_match else (round(current / max_value * 100) if max_value > 0 else 0)
        devices.append({
            "id": name,
            "name": name,
            "label": name.replace("_", " ").title(),
            "class": "backlight",
            "backend": "brightnessctl",
            "method": "brightnessctl",
            "current": current,
            "currentPercent": max(0, min(100, pct)),
            "max": max_value,
            "displayMax": 100,
            "connector": "",
            "monitorName": "",
            "serial": "",
            "available": True,
            "reason": "",
        })
    return devices, []


# --- video-side topology (DRM + Thunderbolt) for doctor + roles -------------

def _parse_edid(data: bytes) -> Dict[str, Any]:
    if len(data) < 128:
        return {}
    mfg_raw = (data[8] << 8) | data[9]
    mfg = "".join(chr(((mfg_raw >> shift) & 0x1F) + ord("A") - 1) for shift in (10, 5, 0))
    if not re.fullmatch(r"[A-Z]{3}", mfg):
        mfg = ""
    product = data[10] | (data[11] << 8)
    serial_num = data[12] | (data[13] << 8) | (data[14] << 16) | (data[15] << 24)
    name = ""
    serial_str = ""
    for off in (54, 72, 90, 108):
        block = data[off:off + 18]
        if len(block) < 18 or block[0] != 0 or block[1] != 0 or block[2] != 0:
            continue
        tag = block[3]
        text = block[5:18].split(b"\n")[0].split(b"\x00")[0].decode("ascii", "ignore").strip()
        if tag == 0xFC:
            name = text
        elif tag == 0xFF:
            serial_str = text
    return {"mfg": mfg, "product": product, "serialNum": serial_num, "serial": serial_str, "name": name}


def _drm_monitors() -> List[Dict[str, Any]]:
    out: List[Dict[str, Any]] = []
    for card in sorted(glob.glob("/sys/class/drm/card*-*")):
        if _read_text(Path(card) / "status") != "connected":
            continue
        sysconnector = os.path.basename(card)
        connector = re.sub(r"^card\d+-", "", sysconnector)
        out.append({
            "connector": connector,
            "sysconnector": sysconnector,
            "edid": _parse_edid(_read_bytes(os.path.join(card, "edid"))),
        })
    return out


def _thunderbolt_apple() -> List[Dict[str, Any]]:
    out: List[Dict[str, Any]] = []
    for d in sorted(glob.glob("/sys/bus/thunderbolt/devices/*-*")):
        name = _read_text(Path(d) / "device_name") or ""
        vendor = _read_text(Path(d) / "vendor_name") or ""
        if not name:
            continue
        product = APPLE_TB_NAMES.get(name.strip().lower())
        if product and "apple" in vendor.lower():
            out.append({"path": os.path.basename(d), "name": name, "vendor": vendor, "product": product})
    return out


def _primary_connector() -> str:
    if os.environ.get("NIRI_SOCKET") and command_exists("niri"):
        focused = run(["niri", "msg", "-j", "focused-output"])
        try:
            name = str((json.loads(focused.stdout or "{}") or {}).get("name") or "")
            if name:
                return name
        except Exception:
            pass
        outputs = run(["niri", "msg", "-j", "outputs"])
        try:
            data = json.loads(outputs.stdout or "{}")
            origin = next((name for name, output in data.items()
                           if (output.get("logical") or {}).get("x") == 0
                           and (output.get("logical") or {}).get("y") == 0), "")
            selected = origin or next(iter(data), "")
            if selected:
                return selected
        except Exception:
            pass
    if not command_exists("hyprctl"):
        return ""
    cp = run(["hyprctl", "-j", "monitors"])
    try:
        mons = json.loads(cp.stdout or "[]")
    except Exception:
        return ""
    if not isinstance(mons, list) or not mons:
        return ""
    focused = next((m for m in mons if m.get("focused")), None)
    if focused:
        return str(focused.get("name", ""))
    origin = next((m for m in mons if m.get("x") == 0 and m.get("y") == 0), None)
    if origin:
        return str(origin.get("name", ""))
    return str(mons[0].get("name", ""))


# --- merge / dedup / role annotation / resolution ---------------------------

_BACKEND_PRIORITY = {"backlight": 0, "ddc": 1, "apple": 2}


def _dedup_devices(devices: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """Collapse one physical display seen on multiple backends by EDID serial,
    keeping the highest-priority backend (backlight > ddc > apple)."""
    by_serial: Dict[str, int] = {}
    result: List[Dict[str, Any]] = []
    for dev in devices:
        serial = (dev.get("serial") or "").strip().lower()
        if not serial:
            result.append(dev)
            continue
        if serial not in by_serial:
            by_serial[serial] = len(result)
            result.append(dev)
            continue
        i = by_serial[serial]
        if _BACKEND_PRIORITY.get(dev.get("class"), 9) < _BACKEND_PRIORITY.get(result[i].get("class"), 9):
            result[i] = dev
    return result


def _annotate_roles(devices: List[Dict[str, Any]]) -> None:
    drm = _drm_monitors()
    for dev in devices:
        if dev.get("connector"):
            continue
        serial = (dev.get("serial") or "").strip()
        if not serial:
            continue
        for m in drm:
            edid = m.get("edid") or {}
            if edid.get("serial") and edid["serial"] == serial:
                dev["connector"] = m["connector"]
                break
    primary = _primary_connector()
    for dev in devices:
        dev["role"] = "primary" if (primary and dev.get("connector") == primary) else ""
    if devices and not any(dev.get("role") == "primary" for dev in devices):
        for dev in devices:
            if dev.get("class") == "backlight":
                dev["role"] = "primary"
                break
        else:
            devices[0]["role"] = "primary"


def brightness_state(include_unavailable: bool = False) -> Dict[str, Any]:
    devices: List[Dict[str, Any]] = []
    errors: List[str] = []
    backlights, backlight_errors = _brightnessctl_devices()
    ddc, ddc_errors = _ddc_devices(include_unavailable=include_unavailable)
    apple, apple_errors = _apple_devices(include_unavailable=include_unavailable)
    for group in (backlights, ddc, apple):
        devices.extend(group)
    errors.extend([e for e in (backlight_errors + ddc_errors + apple_errors) if e])
    devices = _dedup_devices(devices)
    alias_order = {spec["alias"]: i for i, spec in enumerate(APPLE_DISPLAYS.values())}
    devices.sort(key=lambda d: (
        _BACKEND_PRIORITY.get(d.get("class"), 9),
        alias_order.get(str(d.get("id")), 99),
        str(d.get("id")),
    ))
    _annotate_roles(devices)
    return {"devices": devices, "errors": errors}


def _primary_device(devices: List[Dict[str, Any]]) -> Dict[str, Any] | None:
    for dev in devices:
        if dev.get("role") == "primary":
            return dev
    for dev in devices:
        if dev.get("class") == "backlight":
            return dev
    return devices[0] if devices else None


def _resolve_targets(target: str, devices: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """Resolve a target to one or more devices.

    Accepts: stable id / name, legacy alias (apple-xdr/apple-studio),
    connector (DP-1), role (primary/all/default), or a monitor-name substring.
    Empty target resolves to the primary display.
    """
    t = (target or "").strip()
    low = t.lower()
    if not t or low in ("primary", "default"):
        dev = _primary_device(devices)
        return [dev] if dev else []
    if low == "all":
        controllable = [d for d in devices if d.get("available")]
        return controllable or devices
    for dev in devices:
        if dev.get("id") == t or dev.get("name") == t:
            return [dev]
    for dev in devices:
        if dev.get("class") == "apple" and APPLE_DISPLAYS.get(dev.get("product"), {}).get("alias") == low:
            return [dev]
    for dev in devices:
        if (dev.get("connector") or "").lower() == low:
            return [dev]
    for dev in devices:
        haystack = (dev.get("monitorName") or dev.get("label") or "").lower()
        if low and low in haystack:
            return [dev]
    return []


def _find_brightness_device(device_id: str) -> Dict[str, Any] | None:
    devices = brightness_state(include_unavailable=True).get("devices", [])
    matches = _resolve_targets(device_id, devices)
    return matches[0] if matches else None


def _refresh_device_current(dev: Dict[str, Any]) -> None:
    """Re-read just this device's current value in place after a write, instead
    of re-scanning every backend (a second full state scan would re-run
    `ddcutil detect`/getvcp on unrelated displays)."""
    klass = dev.get("class")
    try:
        if klass == "apple":
            val = None
            if (dev.get("method") or "hidraw") == "hidraw":
                val = _apple_hidraw_read(str(dev.get("path", "")))
            else:
                asd = asdcontrol_path()
                if asd:
                    cp = run([str(asd), "--silent", str(dev.get("path", ""))])
                    m = re.search(r"BRIGHTNESS\s*=\s*(\d+)", cp.stdout or "")
                    if m:
                        val = int(m.group(1))
            if val is not None:
                dev["current"] = val
                dev["currentPercent"] = _percent_from_raw(val, int(dev["rawMin"]), int(dev["rawMax"]))
        elif klass == "ddc":
            bus = dev.get("bus")
            read = _ddc_read(int(bus)) if bus is not None else None
            if read:
                cur, maxv = read
                dev["current"] = cur
                dev["vcpMax"] = maxv
                dev["currentPercent"] = max(0, min(100, round(cur / maxv * 100) if maxv else 0))
        elif klass == "backlight":
            for d in _brightnessctl_devices()[0]:
                if d.get("id") == dev.get("id"):
                    dev["current"] = d["current"]
                    dev["currentPercent"] = d["currentPercent"]
                    break
    except Exception:
        pass


def _set_brightness_device(target: str, value: str, relative: bool = False) -> Dict[str, Any]:
    t = (target or "").strip()
    low = t.lower()
    matches: List[Dict[str, Any]] | None = None
    # Fast path: an exact id/alias for a non-DDC display resolves against the
    # cheap backends (backlight + Apple HID) first, so an Apple/backlight
    # keypress never triggers a ddcutil scan.
    if t and low not in ("primary", "default", "all"):
        cheap = _apple_devices(include_unavailable=True)[0] + _brightnessctl_devices()[0]
        cand = _resolve_targets(t, cheap)
        if cand:
            c = cand[0]
            if c.get("id") == t or c.get("name") == t or APPLE_DISPLAYS.get(c.get("product"), {}).get("alias") == low:
                matches = cand
    if matches is None:
        matches = _resolve_targets(t, brightness_state(include_unavailable=True).get("devices", []))
    if not matches:
        raise ValueError(f"brightness device not found: {target or '(default)'}")
    results: List[Dict[str, Any]] = []
    for dev in matches:
        if not dev.get("available"):
            raise PermissionError(dev.get("reason") or f"{dev.get('label', dev.get('id'))} is not controllable")
        current = int(dev.get("currentPercent", 0))
        if relative:
            try:
                target_pct = current + int(str(value))
            except ValueError:
                raise ValueError(f"invalid brightness delta: {value}")
        else:
            try:
                target_pct = int(str(value).rstrip("%"))
            except ValueError:
                raise ValueError(f"invalid brightness value: {value}")
        target_pct = max(0, min(100, target_pct))
        klass = dev.get("class")
        if klass == "apple":
            _set_apple(dev, _raw_from_percent(target_pct, dev["rawMin"], dev["rawMax"]))
        elif klass == "ddc":
            _ddc_set(dev, target_pct)
        elif klass == "backlight":
            cp = run(["brightnessctl", "-d", str(dev["id"]), "set", f"{target_pct}%"])
            if cp.returncode != 0:
                raise RuntimeError((cp.stderr or cp.stdout or "brightnessctl failed").strip())
        else:
            raise ValueError(f"unsupported brightness backend for {dev.get('id')}: {klass}")
        _refresh_device_current(dev)
        results.append(dev)
    return {"device": results[0], "devices": results}


# --- doctor -----------------------------------------------------------------

def _asd_available_hint() -> bool:
    bundled = repo_root() / "bin" / "vshell-asdcontrol"
    if bundled.exists() and os.access(bundled, os.X_OK):
        return True
    cached = cache_dir() / "bin" / "asdcontrol"
    if cached.exists() and os.access(cached, os.X_OK):
        return True
    return bool(shutil.which("g++") or shutil.which("c++"))


def _drm_uncontrollable_reason(edid: Dict[str, Any]) -> str:
    if not command_exists("ddcutil"):
        return (
            "Connected as a video output with no control backend. Install `ddcutil` to try "
            "DDC/CI brightness over the video link, then re-run `vshell brightness doctor`."
        )
    return (
        "Connected as a video output but no brightness control channel responded "
        "(DDC/CI unsupported or blocked; check the i2c-dev module and i2c permissions)."
    )


def brightness_doctor() -> Dict[str, Any]:
    devices = brightness_state(include_unavailable=True).get("devices", [])
    drm = _drm_monitors()
    tb = _thunderbolt_apple()
    apple_usb_products = {
        n["product"] for n in (_hidraw_nodes() + _hiddev_nodes())
        if n.get("vendor") == APPLE_VENDOR and n.get("product") in APPLE_DISPLAYS
    }
    entries: List[Dict[str, Any]] = []
    matched_serials = {(d.get("serial") or "").lower() for d in devices if d.get("serial")}
    matched_connectors = {(d.get("connector") or "").lower() for d in devices if d.get("connector")}

    for dev in devices:
        detail = ""
        if dev.get("available"):
            detail = f"{dev.get('currentPercent')}% via {dev.get('backend')}"
        entries.append({
            "id": dev.get("id"),
            "label": dev.get("label"),
            "class": dev.get("class"),
            "backend": dev.get("backend", ""),
            "connector": dev.get("connector", ""),
            "serial": dev.get("serial", ""),
            "role": dev.get("role", ""),
            "controllable": bool(dev.get("available")),
            "detail": detail,
            "reason": dev.get("reason", ""),
        })

    for t in tb:
        if t["product"] in apple_usb_products:
            continue
        spec = APPLE_DISPLAYS.get(t["product"], {})
        entries.append({
            "id": spec.get("alias", t["name"]),
            "label": t["name"],
            "class": "apple",
            "backend": "",
            "connector": "",
            "serial": "",
            "role": "",
            "controllable": False,
            "detail": "",
            "reason": (
                f"{t['name']} is connected over Thunderbolt (video/DisplayPort tunnel is up) but its USB "
                f"control interface ({APPLE_VENDOR}:{t['product']}) is not enumerated -- the host is not "
                f"tunneling USB to the display, so no backend can reach its brightness. This is a "
                f"Thunderbolt/USB-tunnel limitation, not a permissions problem. Get the display's USB to "
                f"enumerate (BIOS/firmware Thunderbolt USB tunneling, or a cabling path that carries USB); "
                f"HDR/EDR 'brightness upscaling' to higher nits is a separate GPU/compositor feature, not "
                f"this USB control channel."
            ),
        })

    for m in drm:
        conn = m["connector"]
        edid = m.get("edid") or {}
        if conn.lower() in matched_connectors:
            continue
        if edid.get("serial") and edid["serial"].lower() in matched_serials:
            continue
        if edid.get("mfg") == "APP" and tb:
            continue  # already reported as a Thunderbolt Apple display above
        entries.append({
            "id": conn,
            "label": edid.get("name") or conn,
            "class": "",
            "backend": "",
            "connector": conn,
            "serial": edid.get("serial", ""),
            "role": "",
            "controllable": False,
            "detail": "",
            "reason": _drm_uncontrollable_reason(edid),
        })

    controllable = sum(1 for e in entries if e["controllable"])
    return {
        "displays": entries,
        "controllable": controllable,
        "total": len(entries),
        "backends": {
            "brightnessctl": command_exists("brightnessctl"),
            "ddcutil": command_exists("ddcutil"),
            "asdcontrol": _asd_available_hint(),
            "i2c_dev": bool(glob.glob("/dev/i2c-*")),
            "udev_rule_state": _apple_udev_rule_state(),
            "udev_rule_installed": _apple_udev_rule_state() == "installed",
        },
    }


# --- udev rule generation + install -----------------------------------------

APPLE_UDEV_RULE_PATH = "/etc/udev/rules.d/60-vshell-apple-displays.rules"


def _apple_udev_rule_state() -> str:
    """installed | symlink | missing.

    `symlink` matters: udevd reads its rules before /home is mounted, so a rule
    symlinked into a home-directory dotfiles repo is invisible for devices that
    enumerate early in boot, and those nodes never get the uaccess ACL.
    """
    dest = Path(APPLE_UDEV_RULE_PATH)
    if dest.is_symlink():
        return "symlink"
    return "installed" if dest.exists() else "missing"


def _apple_udev_rules_text() -> str:
    lines = [
        "# VGS Apple display brightness access.",
        "# Generated by `vshell brightness install-udev` from bin/vshell-helper; do not hand-edit.",
        "#",
        "# Matched by USB product id only (no interface-path / DEVPATH pinning), so re-cabling the",
        '# display to any DP/USB/Thunderbolt port keeps working. TAG+="uaccess" grants the active-seat',
        "# user access to every HID control endpoint the display exposes; VGS probes them at runtime to",
        "# find the brightness-control interface, so the interface number never has to be hardcoded.",
    ]
    # NB: udev does not accept trailing inline comments on a rule line, so each
    # display's label goes on its own comment line above its rules.
    for product, spec in sorted(APPLE_DISPLAYS.items()):
        lines.append("")
        lines.append(f"# {spec['label']} ({APPLE_VENDOR}:{product})")
        lines.append(
            f'SUBSYSTEM=="usbmisc", KERNEL=="hiddev*", '
            f'ATTRS{{idVendor}}=="{APPLE_VENDOR}", ATTRS{{idProduct}}=="{product}", '
            f'TAG+="uaccess", MODE="0660", GROUP="users"'
        )
        lines.append(
            f'SUBSYSTEM=="hidraw", '
            f'ATTRS{{idVendor}}=="{APPLE_VENDOR}", ATTRS{{idProduct}}=="{product}", '
            f'TAG+="uaccess", MODE="0660", GROUP="users"'
        )
    return "\n".join(lines) + "\n"


def _install_apple_udev(dry_run: bool = False) -> Dict[str, Any]:
    rule_text = _apple_udev_rules_text()
    dest = Path(APPLE_UDEV_RULE_PATH)
    if dry_run:
        return {"ok": True, "dryRun": True, "path": str(dest), "rule": rule_text}
    if os.geteuid() != 0:
        return {"ok": False, "error": "run as root: sudo vshell brightness install-udev"}
    # A symlink here (e.g. into a dotfiles repo under /home) is unreadable when udevd
    # starts, because /home is often a separate mount that is not up yet. Devices
    # enumerated in that window silently miss uaccess. Always land a real file.
    was_symlink = dest.is_symlink()
    existing = None if was_symlink else (_read_text(dest) if dest.exists() else None)
    unchanged = existing is not None and existing.strip() == rule_text.strip()
    if not unchanged:
        dest.parent.mkdir(parents=True, exist_ok=True)
        if was_symlink:
            dest.unlink()
        dest.write_text(rule_text)
    if command_exists("udevadm"):
        run(["udevadm", "control", "--reload-rules"])
        # Re-apply uaccess/permissions to already-present nodes.
        run(["udevadm", "trigger", "--subsystem-match=usbmisc", "--action=add"])
        run(["udevadm", "trigger", "--subsystem-match=hidraw", "--action=add"])
    return {
        "ok": True,
        "path": str(dest),
        "changed": not unchanged,
        "replacedSymlink": was_symlink,
        "status": "unchanged" if unchanged else "installed",
    }


def cmd_brightness(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell brightness")
    sub = parser.add_subparsers(dest="cmd", required=True)
    p_list = sub.add_parser("list")
    p_list.add_argument("--json", action="store_true")
    p_list.add_argument("--include-unavailable", action="store_true")
    p_set = sub.add_parser("set")
    p_set.add_argument("device")
    p_set.add_argument("percent")
    p_set.add_argument("--json", action="store_true")
    p_adjust = sub.add_parser("adjust")
    p_adjust.add_argument("device")
    p_adjust.add_argument("delta")
    p_adjust.add_argument("--json", action="store_true")
    p_inc = sub.add_parser("increment")
    p_inc.add_argument("device")
    p_inc.add_argument("step", nargs="?", default="5")
    p_inc.add_argument("--json", action="store_true")
    p_dec = sub.add_parser("decrement")
    p_dec.add_argument("device")
    p_dec.add_argument("step", nargs="?", default="5")
    p_dec.add_argument("--json", action="store_true")
    p_doctor = sub.add_parser("doctor")
    p_doctor.add_argument("--json", action="store_true")
    p_install = sub.add_parser("install-udev")
    p_install.add_argument("--json", action="store_true")
    p_install.add_argument("--print", dest="print_rule", action="store_true", help="print the generated rule without installing")
    args = parser.parse_args(argv)

    if args.cmd == "list":
        state = brightness_state(include_unavailable=args.include_unavailable)
        if args.json:
            print(json.dumps(state, indent=2))
        else:
            for dev in state.get("devices", []):
                label = dev.get("label") or dev.get("id")
                flags = []
                if dev.get("role") == "primary":
                    flags.append("primary")
                if dev.get("connector"):
                    flags.append(dev["connector"])
                if not dev.get("available"):
                    flags.append("unavailable")
                suffix = f" ({', '.join(flags)})" if flags else ""
                print(f"{dev.get('id')}: {label} {dev.get('currentPercent')}% [{dev.get('backend')}]{suffix}")
            for err in state.get("errors", []):
                eprint(err)
        return 0 if state.get("devices") else 1

    if args.cmd in {"set", "adjust", "increment", "decrement"}:
        try:
            if args.cmd == "set":
                result = _set_brightness_device(args.device, args.percent, relative=False)
            elif args.cmd == "adjust":
                delta = args.delta
                result = _set_brightness_device(args.device, delta if delta.startswith(("+", "-")) else f"+{delta}", relative=True)
            elif args.cmd == "increment":
                result = _set_brightness_device(args.device, f"+{args.step}", relative=True)
            else:
                result = _set_brightness_device(args.device, f"-{args.step}", relative=True)
        except Exception as exc:
            if getattr(args, "json", False):
                print(json.dumps({"error": str(exc)}))
            else:
                eprint(str(exc))
            return 1
        if getattr(args, "json", False):
            print(json.dumps(result, indent=2))
        else:
            for dev in result.get("devices", [result.get("device", {})]):
                print(f"{dev.get('id', args.device)}: {dev.get('currentPercent', '?')}%")
        return 0

    if args.cmd == "doctor":
        report = brightness_doctor()
        if args.json:
            print(json.dumps(report, indent=2))
            return 0
        b = report["backends"]
        print("Backends:")
        print(f"  brightnessctl (backlight): {'yes' if b['brightnessctl'] else 'no'}")
        print(f"  ddcutil (DDC/CI):          {'yes' if b['ddcutil'] else 'no'}"
              + ("" if b["ddcutil"] else "  -> install `ddcutil` for standard external monitors"))
        print(f"  asdcontrol/hidraw (Apple): {'yes' if b['asdcontrol'] else 'no'}")
        print(f"  i2c-dev nodes present:     {'yes' if b['i2c_dev'] else 'no'}")
        rule_state = b.get("udev_rule_state", "missing")
        rule_note = {
            "installed": "yes",
            "symlink": "yes, but symlinked outside /etc -> unreadable at early boot; "
                       "run `sudo vshell brightness install-udev` to land a real file",
            "missing": "no",
        }[rule_state]
        print(f"  Apple udev rule installed: {rule_note}")
        print(f"\nDisplays ({report['controllable']}/{report['total']} controllable):")
        for e in report["displays"]:
            head = e.get("label") or e.get("id")
            tag = f" [{e['connector']}]" if e.get("connector") else ""
            role = " *primary" if e.get("role") == "primary" else ""
            if e["controllable"]:
                print(f"  ✓ {head}{tag}{role}: {e['detail']}")
            else:
                print(f"  ✗ {head}{tag}{role}: {e['reason']}")
        return 0

    if args.cmd == "install-udev":
        if getattr(args, "print_rule", False):
            print(_apple_udev_rules_text(), end="")
            return 0
        result = _install_apple_udev()
        if args.json:
            print(json.dumps(result, indent=2))
        elif result.get("ok"):
            print(f"{result.get('status', 'installed')}: {result['path']}")
        else:
            eprint(result.get("error", "install-udev failed"))
        return 0 if result.get("ok") else 1

    return 2


def update_count() -> Dict[str, Any]:
    if not command_exists("pacman"):
        return {"ok": False, "error": "pacman not found", "repo": 0, "aur": 0, "packages": [], "orphanCount": 0, "orphans": []}
    if not command_exists("checkupdates"):
        return {"ok": False, "error": "checkupdates not found", "repo": 0, "aur": 0, "packages": [], "orphanCount": 0, "orphans": []}
    lock = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "vshell-update-count.lock"
    script = r'''
set -u
exec 9>"$1"
flock -w 120 9 2>/dev/null || true
repo_lines=""
aur_lines=""
if command -v checkupdates >/dev/null 2>&1; then repo_lines=$(checkupdates 2>/dev/null || true); fi
if command -v paru >/dev/null 2>&1; then aur_lines=$(paru -Qua 2>/dev/null || true); fi
repo=$(printf '%s' "$repo_lines" | grep -c . || true)
aur=$(printf '%s' "$aur_lines" | grep -c . || true)
emit() { src="$1"; while read -r name old arrow new rest; do [ -n "$name" ] || continue; jq -cn --arg name "$name" --arg old "$old" --arg new "$new" --arg src "$src" '{name:$name,old:$old,new:$new,src:$src}'; done; }
pkgs=$( { printf '%s\n' "$repo_lines" | emit repo; printf '%s\n' "$aur_lines" | emit aur; } | jq -s . )
orphan_lines=$(pacman -Qtd 2>/dev/null || true)
orphan=$(printf '%s' "$orphan_lines" | grep -c . || true)
orphans=$(printf '%s\n' "$orphan_lines" | while read -r name ver rest; do [ -n "$name" ] || continue; jq -cn --arg name "$name" --arg ver "$ver" '{name:$name,ver:$ver}'; done | jq -s .)
jq -cn --argjson repo "${repo:-0}" --argjson aur "${aur:-0}" --argjson packages "$pkgs" --argjson orphanCount "${orphan:-0}" --argjson orphans "$orphans" '{ok:true,repo:$repo,aur:$aur,packages:$packages,orphanCount:$orphanCount,orphans:$orphans}'
'''
    proc = subprocess.run(["bash", "-lc", script, "bash", str(lock)], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    # Metadata surfaced to the UI so it can show data provenance/freshness without
    # opening code. Backward-compatible additions; existing keys stay unchanged.
    meta = {"source": {"repo": "checkupdates", "aur": "paru -Qua"}, "checkedAt": int(time.time())}
    if proc.returncode != 0:
        return {"ok": False, "error": proc.stderr.strip() or "update count failed", "repo": 0, "aur": 0, "packages": [], "orphanCount": 0, "orphans": [], **meta}
    try:
        data = json.loads(proc.stdout or "{}")
    except Exception:
        return {"ok": False, "error": "parse error", "repo": 0, "aur": 0, "packages": [], "orphanCount": 0, "orphans": [], **meta}
    if isinstance(data, dict):
        data.setdefault("source", meta["source"])
        data.setdefault("checkedAt", meta["checkedAt"])
    return data


def cmd_update(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell update")
    sub = parser.add_subparsers(dest="cmd", required=True)
    p_count = sub.add_parser("count")
    p_count.add_argument("--json", action="store_true")
    p_run = sub.add_parser("run")
    p_run.add_argument("mode", choices=["system", "aur", "all"])
    args = parser.parse_args(argv)
    if args.cmd == "count":
        data = update_count()
        print(json.dumps(data, indent=2) if args.json else str((data.get("repo") or 0) + (data.get("aur") or 0)))
        return 0 if data.get("ok", True) else 1
    if args.cmd == "run":
        failures = 0
        if args.mode in {"system", "all"}:
            if not command_exists("pacman"):
                eprint("pacman not found")
                return 1
            print(":: Updating repo packages")
            failures += 0 if subprocess.run(["sudo", "pacman", "-Syu"], check=False).returncode == 0 else 1
        if args.mode in {"aur", "all"}:
            if not command_exists("paru"):
                eprint("paru not found")
                return 1
            print(":: Updating AUR packages")
            failures += 0 if subprocess.run(["paru", "-Sua"], check=False).returncode == 0 else 1
        try:
            input(("Failed — press Enter to close…" if failures else "Done — press Enter to close…"))
        except EOFError:
            pass
        return 1 if failures else 0
    return 2


def cmd_ai_usage(argv: List[str]) -> int:
    provider = argv[0] if argv else "claude"

    def emit(payload: Dict[str, Any]) -> int:
        # The widget files every payload under the provider the payload itself
        # names and discards anything unstamped, so this wrapper's own errors
        # carry the stamp too — and so does a backend's payload that lacks one,
        # since `ai-usage` may be a third-party engine from PATH that predates
        # the field. An unstamped payload would otherwise be dropped and the
        # real cause replaced by "provider mismatch" (VGS-118).
        payload.setdefault("provider", provider)
        print(json.dumps(payload))
        return 0

    cmd_env = os.environ.get("VSHELL_AI_USAGE_CMD", "").strip()
    candidates = []
    if cmd_env:
        candidates.append(cmd_env)
    candidates.extend([str(repo_root() / "bin" / "vshell-ai-usage"), str(home() / ".local" / "bin" / "ai-usage"), "ai-usage"])
    runner = next((c for c in candidates if ("/" in c and Path(c).exists()) or ("/" not in c and shutil.which(c))), "")
    if not runner:
        return emit({"ok": False, "error": "ai-usage backend not found"})
    proc = subprocess.run([runner, provider], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    if proc.returncode != 0:
        return emit({"ok": False, "error": proc.stderr.strip() or "ai-usage failed"})
    out = proc.stdout.strip()
    try:
        parsed = json.loads(out) if out else None
    except ValueError:
        parsed = None
    if isinstance(parsed, dict):
        return emit(parsed)
    return emit({"ok": False, "error": "ai-usage returned no data" if not out else "ai-usage returned unreadable output"})


def cmd_fonts(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell fonts")
    sub = parser.add_subparsers(dest="cmd", required=True)
    p_status = sub.add_parser("status")
    p_status.add_argument("--json", action="store_true")
    p_apply = sub.add_parser("apply")
    p_apply.add_argument("--json", action="store_true")
    p_reset = sub.add_parser("reset")
    p_reset.add_argument("--json", action="store_true")
    args = parser.parse_args(argv)

    if args.cmd == "status":
        result = normalized_system_font_settings()
        print(json.dumps(result, indent=2) if args.json else f"managed={result.get('managed')} session={result.get('environment', {}).get('sessionType')}")
        return 0
    if args.cmd == "apply":
        result = apply_system_fonts(reset=False)
        print(json.dumps(result, indent=2) if args.json else ("System fonts applied" if result.get("success") else "System fonts partially applied"))
        return 0 if result.get("success") or result.get("partial") else 1
    if args.cmd == "reset":
        set_settings_value("systemFontsManaged", False)
        result = apply_system_fonts(reset=True)
        print(json.dumps(result, indent=2) if args.json else "VGS system font overrides removed")
        return 0 if result.get("success") or result.get("partial") else 1
    return 2


def cmd_capture(argv: List[str]) -> int:
    if not argv:
        eprint("Usage: vshell capture screenshot|screenrecording|text [...]")
        return 2
    kind, rest = argv[0], argv[1:]
    scripts = {
        "screenshot": repo_root() / "bin" / "vshell-capture-screenshot",
        "screenrecording": repo_root() / "bin" / "vshell-capture-screenrecording",
        "recording": repo_root() / "bin" / "vshell-capture-screenrecording",
        "text": repo_root() / "bin" / "vshell-capture-text-extraction",
        "ocr": repo_root() / "bin" / "vshell-capture-text-extraction",
    }
    script = scripts.get(kind)
    if not script:
        eprint("Usage: vshell capture screenshot|screenrecording|text [...]")
        return 2
    os.execv(str(script), [str(script), *rest])
    return 127


_THEME_MUTATING_COMMANDS = {
    "apply",
    "apply-blueprint",
    "apply-colors",
    "app-colors",
    "app-curated-recolor",
    "clear-wallpaper",
    "chromium-policy",
    "delete",
    "duplicate",
    "edit-app",
    "import-colors",
    "migrate",
    "mode",
    "regenerate",
    "reset-app",
    "restyle",
    "revert",
    "save-current",
    "set-pair",
    "set-wallpaper",
    "toggle",
    "wallpaper-add",
    "wallpaper-default",
    "wallpaper-remove",
}


def _theme_command_mutates(argv: List[str]) -> bool:
    """Whether a theme CLI invocation can alter theme or generated state."""
    if not argv:
        return False
    command = argv[0]
    if command in _THEME_MUTATING_COMMANDS:
        return True
    if command == "extract-wallpaper":
        return "--apply" in argv or "--save" in argv
    if command == "apps":
        return "--enable" in argv or "--disable" in argv
    # `catalog` is deliberately absent: its downloads are minutes to hours of
    # network transfer that mutate nothing, and it takes the lock itself around
    # the one step that does (the directory swap). Wrapping the whole command
    # would block theme applies, the light/dark keybinding, wallpaper changes
    # and restyles for the length of a 1.1 GiB `install --all`.
    return False


def cmd_theme(argv: List[str]) -> int:
    if _theme_command_mutates(argv):
        with theme_mutation_lock():
            return _cmd_theme_unlocked(argv)
    return _cmd_theme_unlocked(argv)


def _cmd_theme_unlocked(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell theme")
    sub = parser.add_subparsers(dest="cmd", required=True)
    sub.add_parser("current").add_argument("--json", action="store_true")
    sub.add_parser("get-mode").add_argument("--json", action="store_true")
    sub.add_parser("list").add_argument("--json", action="store_true")
    sub.add_parser("list-blueprints").add_argument("--json", action="store_true")
    p_apply = sub.add_parser("apply")
    p_apply.add_argument("name")
    p_apply.add_argument("--json", action="store_true")
    p_apply_bp = sub.add_parser("apply-blueprint")
    p_apply_bp.add_argument("name")
    p_apply_bp.add_argument("--json", action="store_true")
    p_import = sub.add_parser("import-colors")
    p_import.add_argument("path")
    p_import.add_argument("--name", default="imported")
    p_import.add_argument("--wallpaper", default="")
    p_import.add_argument("--apply", action="store_true")
    p_import.add_argument("--json", action="store_true")
    p_extract = sub.add_parser("extract-wallpaper")
    p_extract.add_argument("path")
    p_extract.add_argument("--name", default="wallpaper")
    p_extract.add_argument("--scheme", default="scheme-tonal-spot", choices=sorted(MATUGEN_SCHEMES))
    p_extract.add_argument("--contrast", type=float, default=0.0)
    p_extract.add_argument("--mode", default="auto", choices=sorted(THEME_MODES))
    p_extract.add_argument("--apply", action="store_true")
    p_extract.add_argument("--save", action="store_true")
    p_extract.add_argument("--json", action="store_true")
    p_save = sub.add_parser("save-current")
    p_save.add_argument("--name", required=True)
    p_save.add_argument("--json", action="store_true")
    p_apply_colors = sub.add_parser("apply-colors")
    p_apply_colors.add_argument("--name", default="manual-theme")
    p_apply_colors.add_argument("--mode", default="", choices=["", "dark", "light"])
    p_apply_colors.add_argument("--wallpaper", default=None)
    p_apply_colors.add_argument("--set", dest="edits", action="append", default=[])
    p_apply_colors.add_argument("--save", action="store_true")
    p_apply_colors.add_argument("--persist", action="store_true", help="write the merged palette into the current theme's overlay colors.toml")
    p_apply_colors.add_argument("--json", action="store_true")
    p_revert = sub.add_parser("revert", help="drop a built-in theme's user overlay (reset to repo defaults)")
    p_revert.add_argument("name")
    p_revert.add_argument("--json", action="store_true")
    p_restyle = sub.add_parser("restyle", help="non-destructive perceptual whole-palette adjustments")
    p_restyle.add_argument("--name", default="", help="theme name (default: current)")
    p_restyle.add_argument("--brightness", type=int)
    p_restyle.add_argument("--vibrancy", type=int)
    p_restyle.add_argument("--contrast", type=int)
    p_restyle.add_argument("--hue", type=int)
    p_restyle.add_argument("--temperature", type=int)
    p_restyle.add_argument("--reset", action="store_true", help="clear all adjustments")
    p_restyle.add_argument("--preview", action="store_true",
                           help="render only the live VGS shell palette without persisting")
    p_restyle.add_argument("--json", action="store_true")
    p_app_roles = sub.add_parser("app-roles", help="list the roles an app's target consumes with resolved values")
    p_app_roles.add_argument("app")
    p_app_roles.add_argument("--theme", default="", help="theme name (default: current)")
    p_app_roles.add_argument("--json", action="store_true")
    p_app_colors = sub.add_parser("app-colors", help="edit per-app color overrides stored in the theme")
    p_app_colors.add_argument("app")
    p_app_colors.add_argument("--theme", default="", help="theme name (default: current)")
    p_app_colors.add_argument("--set", dest="edits", action="append", default=[], metavar="ROLE=HEX")
    p_app_colors.add_argument("--reset", action="store_true", help="clear this app's overrides")
    p_app_colors.add_argument("--json", action="store_true")
    p_setwp = sub.add_parser("set-wallpaper")
    p_setwp.add_argument("path")
    p_setwp.add_argument("--extract", action="store_true")
    p_setwp.add_argument("--name", default="")
    p_setwp.add_argument("--scheme", default="scheme-tonal-spot", choices=sorted(MATUGEN_SCHEMES))
    p_setwp.add_argument("--contrast", type=float, default=0.0)
    p_setwp.add_argument("--mode", default="auto", choices=sorted(THEME_MODES))
    p_setwp.add_argument("--save", action="store_true")
    p_setwp.add_argument("--json", action="store_true")
    p_clearwp = sub.add_parser("clear-wallpaper")
    p_clearwp.add_argument("--json", action="store_true")
    p_wps = sub.add_parser("wallpapers")
    p_wps.add_argument("name", nargs="?", default="", help="theme name (default: current)")
    p_wps.add_argument("--json", action="store_true")
    p_wp_add = sub.add_parser("wallpaper-add")
    p_wp_add.add_argument("path")
    p_wp_add.add_argument("--theme", default="", help="theme name (default: current)")
    p_wp_add.add_argument("--json", action="store_true")
    p_wp_rm = sub.add_parser("wallpaper-remove")
    p_wp_rm.add_argument("file")
    p_wp_rm.add_argument("--theme", default="", help="theme name (default: current)")
    p_wp_rm.add_argument("--json", action="store_true")
    p_wp_def = sub.add_parser("wallpaper-default")
    p_wp_def.add_argument("file")
    p_wp_def.add_argument("--theme", default="", help="theme name (default: current)")
    p_wp_def.add_argument("--json", action="store_true")
    p_mode = sub.add_parser("mode")
    p_mode.add_argument("value", choices=["light", "dark", "toggle"])
    p_mode.add_argument("--transform", action="store_true", help="derive a lossy mode variant from the current palette instead of switching to a paired blueprint")
    p_mode.add_argument("--json", action="store_true")
    p_toggle = sub.add_parser("toggle")
    p_toggle.add_argument("--transform", action="store_true")
    p_toggle.add_argument("--json", action="store_true")
    p_pick = sub.add_parser("pick")
    p_pick.add_argument("mode", nargs="?", default="all", choices=["all", "dark", "light"])
    p_pick.add_argument("--json", action="store_true")
    p_preview = sub.add_parser("preview")
    p_preview.add_argument("name", nargs="?", default="")
    p_preview.add_argument("--force", action="store_true")
    p_preview.add_argument("--all", action="store_true", help="generate previews for every blueprint")
    p_preview.add_argument("--json", action="store_true")
    p_capture = sub.add_parser("preview-capture")
    p_capture.add_argument("--dir", required=True)
    p_capture.add_argument("--windows", type=int, default=3)
    p_capture.add_argument("--wait", type=float, default=20.0)
    p_capture.add_argument("--settle", type=float, default=4.0)
    p_chrom = sub.add_parser("chromium-policy")
    p_chrom.add_argument("--json", action="store_true")
    p_lint = sub.add_parser("lint")
    p_lint.add_argument("name", nargs="?", default="")
    p_lint.add_argument("--json", action="store_true")
    p_migrate = sub.add_parser("migrate")
    p_migrate.add_argument("name", nargs="?", default="")
    p_migrate.add_argument("--all", action="store_true", help="convert every v1 blueprint into a theme package")
    p_migrate.add_argument("--keep-blueprint", action="store_true")
    p_migrate.add_argument("--json", action="store_true")
    p_apps = sub.add_parser("apps")
    p_apps.add_argument("--enable", default="", metavar="APP")
    p_apps.add_argument("--disable", default="", metavar="APP")
    p_apps.add_argument("--json", action="store_true")
    p_regen = sub.add_parser("regenerate")
    p_regen.add_argument("name")
    p_regen.add_argument("--app", default="", help="regenerate a single app file (curated file name or app id)")
    p_regen.add_argument("--yes", action="store_true", help="skip the overwrite confirmation")
    p_regen.add_argument("--json", action="store_true")
    p_edit = sub.add_parser("edit-app")
    p_edit.add_argument("app")
    p_edit.add_argument("--theme", default="", help="theme name (default: current)")
    p_edit.add_argument("--json", action="store_true")
    p_reset = sub.add_parser("reset-app")
    p_reset.add_argument("app")
    p_reset.add_argument("--theme", default="", help="theme name (default: current)")
    p_reset.add_argument("--json", action="store_true")
    p_pair = sub.add_parser("set-pair")
    p_pair.add_argument("name")
    p_pair.add_argument("pair", help="counterpart theme name; empty string clears")
    p_pair.add_argument("--json", action="store_true")
    p_del = sub.add_parser("delete")
    p_del.add_argument("name")
    p_del.add_argument("--json", action="store_true")
    p_dup = sub.add_parser("duplicate")
    p_dup.add_argument("name")
    p_dup.add_argument("--as", dest="new_name", default="", help="name for the copy (default: <name>-copy)")
    p_dup.add_argument("--json", action="store_true")
    p_catalog = sub.add_parser("catalog", help="browse and download themes that are not installed")
    catalog_sub = p_catalog.add_subparsers(dest="catalog_cmd", required=True)
    p_cat_list = catalog_sub.add_parser("list", help="every published theme with installed state")
    p_cat_list.add_argument("--json", action="store_true")
    p_cat_install = catalog_sub.add_parser("install", help="download themes into ~/.config/vshell/themes")
    p_cat_install.add_argument("names", nargs="*", default=[])
    p_cat_install.add_argument("--all", action="store_true", help="download every theme that is not installed")
    p_cat_install.add_argument("--force", action="store_true", help="re-download a previously downloaded theme")
    p_cat_install.add_argument("--json", action="store_true")
    p_cat_remove = catalog_sub.add_parser("remove", help="remove a downloaded theme")
    p_cat_remove.add_argument("names", nargs="+")
    p_cat_remove.add_argument("--json", action="store_true")
    p_icons = sub.add_parser("icons", help="list installed icon themes and the current theme's icon set")
    p_icons.add_argument("--json", action="store_true")
    p_recolor = sub.add_parser("app-curated-recolor", help="recolor a curated app file (replace all uses of a hex)")
    p_recolor.add_argument("app")
    p_recolor.add_argument("--set", dest="edits", action="append", default=[], metavar="OLDHEX=NEWHEX")
    p_recolor.add_argument("--theme", default="", help="theme name (default: current)")
    p_recolor.add_argument("--json", action="store_true")
    args = parser.parse_args(argv)

    if args.cmd == "current":
        data = current_theme()
        if args.json:
            bp = find_theme(str(data.get("name") or ""))
            augmented = dict(data)
            augmented["adjustments"] = normalize_adjustments(bp.get("adjustments") if bp else None)
            augmented["modified"] = bool(bp and bp.get("modified"))
            augmented["builtin"] = bool(bp and bp.get("builtin"))
            print(json.dumps(augmented, indent=2))
        else:
            print(data.get("name", "vgs-theme"))
        return 0
    if args.cmd == "get-mode":
        mode = current_theme().get("mode", "dark")
        if args.json:
            print(json.dumps({"mode": mode}, indent=2))
        else:
            print(mode)
        return 0
    if args.cmd in {"list", "list-blueprints"}:
        bps = list_themes()
        if args.json:
            entries = []
            for b in bps:
                pal = b.get("palette", {})
                ext = pal.get("extendedColors") or {}
                preview = blueprint_preview_path(b)
                packaged_preview = b.get("packagedPreview", "")
                # A committed preview.png is what makes a fresh install show
                # thumbnails without regenerating anything. It stops being
                # truthful once the user restyles or overlays the theme, so in
                # that case drop back to "missing" and let the generator run.
                if packaged_preview and not preview.exists() and (
                    b.get("modified") or not adjustments_all_zero(normalize_adjustments(b.get("adjustments")))
                ):
                    packaged_preview = ""
                entries.append({
                    "name": b.get("name"),
                    "source": blueprint_source(b),
                    "package": bool(b.get("package")),
                    "apps": sorted((b.get("apps") or {}).keys()),
                    "colors": pal.get("colors", []),
                    "background": ext.get("background", ""),
                    "foreground": ext.get("foreground", ""),
                    "accent": ext.get("accent", ""),
                    "wallpaper": pal.get("wallpaper", ""),
                    "backgrounds": b.get("backgrounds", []),
                    "defaultWallpaper": pal.get("wallpaper", ""),
                    "mode": blueprint_mode(b),
                    "pair": b.get("pair", ""),
                    "builtin": b.get("builtin", False),
                    "preview": str(preview) if preview.exists() else packaged_preview,
                    "modified": bool(b.get("modified")),
                    "adjustments": normalize_adjustments(b.get("adjustments")),
                    "appOverrides": b.get("appOverrides") or {},
                    "timestamp": b.get("timestamp", 0),
                })
            print(json.dumps({"blueprints": entries, "count": len(entries)}, indent=2))
        else:
            print("\n".join(str(b.get("name")) for b in bps))
        return 0
    if args.cmd in {"apply", "apply-blueprint"}:
        bp = find_theme(args.name)
        if not bp:
            eprint(f"Blueprint not found: {args.name}")
            return 1
        result = apply_theme_obj(bp)
        print(json.dumps(result, indent=2) if args.json else f"Applied {bp.get('name')}")
        return 0
    if args.cmd == "import-colors":
        path = Path(resolve_path(args.path))
        try:
            colors = parse_colors_toml(path)
        except Exception as exc:
            eprint(str(exc))
            if args.json:
                print(json.dumps({"success": False, "error": str(exc)}, indent=2))
            return 1
        bp = palette_from_colors_map(colors, name=args.name, wallpaper=resolve_path(args.wallpaper))
        saved = save_theme_package(bp, args.name)
        result = {"success": True, "saved": str(saved), "blueprint": bp}
        if args.apply:
            result["apply"] = apply_theme_obj(bp)
        print(json.dumps(result, indent=2) if args.json else str(saved))
        return 0
    if args.cmd == "extract-wallpaper":
        path = Path(resolve_path(args.path)).expanduser()
        if not path.is_absolute():
            path = (Path.cwd() / path).resolve()
        bp = blueprint_from_wallpaper(path, name=args.name, scheme=args.scheme, contrast=args.contrast, mode=args.mode)
        result = {"success": True, "blueprint": bp, "saved": ""}
        if args.apply:
            result["apply"] = apply_theme_obj(bp)
        if args.save:
            result["saved"] = str(save_theme_package(bp, args.name))
        print(json.dumps(result, indent=2) if args.json else (result.get("saved") or json.dumps(bp)))
        return 0
    if args.cmd == "set-wallpaper":
        path = Path(resolve_path(args.path)).expanduser()
        if not path.is_absolute():
            path = (Path.cwd() / path).resolve()
        if args.extract:
            bp = blueprint_from_wallpaper(path, name=args.name or current_theme().get("name", "wallpaper"), scheme=args.scheme, contrast=args.contrast, mode=args.mode)
        else:
            bp = carry_curated_apps(blueprint_from_current_theme(name=args.name or current_theme().get("name", "vgs-theme")))
            pal = dict(bp.get("palette", {}))
            pal["wallpaper"] = str(path)
            bp["palette"] = pal
        result = apply_theme_obj(bp)
        result["saved"] = ""
        if args.save:
            result["saved"] = str(save_theme_package(bp, bp.get("name", args.name)))
        print(json.dumps(result, indent=2) if args.json else f"Wallpaper set: {path}")
        return 0
    if args.cmd == "clear-wallpaper":
        bp = carry_curated_apps(blueprint_from_current_theme(name=current_theme().get("name", "vgs-theme")))
        pal = dict(bp.get("palette", {}))
        pal["wallpaper"] = ""
        bp["palette"] = pal
        result = apply_theme_obj(bp)
        result["saved"] = ""
        print(json.dumps(result, indent=2) if args.json else "Wallpaper cleared")
        return 0
    if args.cmd == "wallpapers":
        bp = resolve_theme_package(args.name)
        if not bp:
            eprint(f"Theme package not found: {args.name or current_theme().get('name', '')}")
            return 1
        entries = theme_wallpaper_entries(bp)
        if args.json:
            print(json.dumps({"theme": bp.get("name"), "wallpapers": entries, "count": len(entries)}, indent=2))
        else:
            for e in entries:
                print(f"{'*' if e['default'] else ' '} {e['origin']:<7} {e['file']}")
        return 0
    if args.cmd == "wallpaper-add":
        bp = resolve_theme_package(args.theme)
        if not bp:
            eprint(f"Theme package not found: {args.theme or current_theme().get('name', '')}")
            return 1
        src = Path(args.path).expanduser()
        if not src.is_file():
            eprint(f"Not a file: {args.path}")
            return 1
        pkg_dir_name = Path(str(bp.get("path"))).name
        dest_dir = user_themes_dir() / pkg_dir_name / "backgrounds"
        dest_dir.mkdir(parents=True, exist_ok=True)
        dest = dest_dir / src.name
        counter = 1
        while dest.exists():
            dest = dest_dir / f"{src.stem}-{counter}{src.suffix}"
            counter += 1
        shutil.copy2(src, dest)
        meta = read_theme_overlay_meta(pkg_dir_name)
        hidden = [h for h in (meta.get("hiddenBackgrounds") or []) if isinstance(h, str)]
        if dest.name in hidden:
            meta["hiddenBackgrounds"] = [h for h in hidden if h != dest.name]
            write_theme_overlay_meta(pkg_dir_name, meta)
        result = {"success": True, "theme": bp.get("name"), "file": dest.name, "path": str(dest)}
        print(json.dumps(result, indent=2) if args.json else f"Added {dest.name} to {bp.get('name')}")
        return 0
    if args.cmd == "wallpaper-remove":
        bp = resolve_theme_package(args.theme)
        if not bp:
            eprint(f"Theme package not found: {args.theme or current_theme().get('name', '')}")
            return 1
        fname = Path(args.file).name
        pkg_dir_name = Path(str(bp.get("path"))).name
        user_file = user_themes_dir() / pkg_dir_name / "backgrounds" / fname
        builtin_file = builtin_themes_dir() / pkg_dir_name / "backgrounds" / fname
        result: Dict[str, Any] = {"success": False, "theme": bp.get("name"), "file": fname}
        meta = read_theme_overlay_meta(pkg_dir_name)
        meta_dirty = False
        if user_file.is_file():
            user_file.unlink()
            result["removed"] = str(user_file)
            result["success"] = True
        if builtin_file.is_file():
            hidden = [h for h in (meta.get("hiddenBackgrounds") or []) if isinstance(h, str)]
            if fname not in hidden:
                hidden.append(fname)
                meta["hiddenBackgrounds"] = sorted(hidden)
                meta_dirty = True
            result["hidden"] = fname
            result["success"] = True
        if not result["success"]:
            eprint(f"Wallpaper not found in {bp.get('name')}: {fname}")
            return 1
        if str(meta.get("wallpaper") or "") == fname:
            meta.pop("wallpaper", None)
            meta_dirty = True
        if meta_dirty:
            write_theme_overlay_meta(pkg_dir_name, meta)
        print(json.dumps(result, indent=2) if args.json else f"Removed {fname} from {bp.get('name')}")
        return 0
    if args.cmd == "wallpaper-default":
        bp = resolve_theme_package(args.theme)
        if not bp:
            eprint(f"Theme package not found: {args.theme or current_theme().get('name', '')}")
            return 1
        fname = Path(args.file).name
        entries = theme_wallpaper_entries(bp)
        if fname not in {e["file"] for e in entries}:
            eprint(f"Wallpaper not in {bp.get('name')}'s set: {fname}")
            return 1
        pkg_dir_name = Path(str(bp.get("path"))).name
        meta = read_theme_overlay_meta(pkg_dir_name)
        meta["wallpaper"] = fname
        write_theme_overlay_meta(pkg_dir_name, meta)
        result = {"success": True, "theme": bp.get("name"), "default": fname}
        print(json.dumps(result, indent=2) if args.json else f"{bp.get('name')} default wallpaper: {fname}")
        return 0
    if args.cmd == "save-current":
        bp = blueprint_from_current_theme(name=args.name)
        saved = save_theme_package(bp, args.name)
        print(json.dumps({"success": True, "saved": str(saved)}, indent=2) if args.json else str(saved))
        return 0
    if args.cmd == "apply-colors":
        try:
            if args.persist:
                result = persist_color_edits(args.edits, args.name)
            else:
                result = apply_color_edits(args.edits, args.name, mode=args.mode or None, wallpaper=args.wallpaper, save=args.save)
        except Exception as exc:
            eprint(str(exc))
            if args.json:
                print(json.dumps({"success": False, "error": str(exc)}, indent=2))
            return 1
        print(json.dumps(result, indent=2) if args.json else f"Applied {result.get('name') or args.name}")
        return 0
    if args.cmd == "revert":
        bp = find_theme(args.name)
        if not bp:
            eprint(f"Theme not found: {args.name}")
            return 1
        if not bp.get("builtin"):
            eprint(f"{bp.get('name')} is a user theme; use `vshell theme delete {args.name}` instead")
            if args.json:
                print(json.dumps({"reverted": False, "name": bp.get("name"), "applied": False, "error": "not a built-in theme"}, indent=2))
            return 1
        user_dir = user_themes_dir() / Path(str(bp.get("path"))).name
        if user_dir.is_dir():
            shutil.rmtree(user_dir, ignore_errors=True)
        applied = False
        if str(bp.get("name")) == str(current_theme().get("name")):
            reverted = find_theme(args.name)
            if reverted:
                apply_theme_obj(reverted)
                applied = True
        result = {"reverted": True, "name": bp.get("name"), "applied": applied}
        print(json.dumps(result, indent=2) if args.json else f"Reverted {bp.get('name')} to defaults")
        return 0
    if args.cmd == "restyle":
        target = find_theme(args.name) if args.name else current_theme_obj()
        if not target or not target.get("package"):
            msg = f"not a theme package: {args.name or current_theme().get('name', '(current)')}; save it first"
            eprint(msg)
            if args.json:
                print(json.dumps({"success": False, "error": msg}, indent=2))
            return 1
        pkg_dir_name = Path(str(target.get("path"))).name
        if args.reset:
            adj = normalize_adjustments({})
        else:
            adj = normalize_adjustments(target.get("adjustments"))
            for key in ADJUST_KEYS:
                value = getattr(args, key)
                if value is not None:
                    adj[key] = value
            adj = normalize_adjustments(adj)
        if args.preview:
            preview_target = dict(target)
            preview_target["adjustments"] = adj
            applied = False
            if str(target.get("name")) == str(current_theme().get("name")):
                apply_theme_obj(
                    preview_target,
                    only_target="vgs-shell",
                    run_hooks=False,
                )
                applied = True
            result = {
                "success": True,
                "preview": True,
                "name": target.get("name"),
                "adjustments": adj,
                "applied": applied,
            }
            print(json.dumps(result, indent=2) if args.json else f"Previewed {target.get('name')}: {adj}")
            return 0
        stored = set_theme_adjustments(pkg_dir_name, adj)
        applied = False
        if str(target.get("name")) == str(current_theme().get("name")):
            refreshed = load_theme_package(pkg_dir_name)
            if refreshed:
                apply_theme_obj(refreshed)
                applied = True
        result = {"success": True, "name": target.get("name"), "adjustments": stored, "applied": applied}
        print(json.dumps(result, indent=2) if args.json else f"Restyled {target.get('name')}: {stored}")
        return 0
    if args.cmd == "app-roles":
        bp = find_theme(args.theme) if args.theme else current_theme_obj()
        if not bp:
            eprint(f"Theme not found: {args.theme or '(current)'}")
            return 1
        view = app_role_view(args.app, bp)
        if args.json:
            print(json.dumps(view, indent=2))
        else:
            if not view["roles"]:
                print(f"{args.app}: no editable roles" + (" (curated)" if view["curated"] else ""))
            for role in view["roles"]:
                mark = "*" if role["overridden"] else " "
                print(f"{mark} {role['role']}: {role['value']}")
        return 0
    if args.cmd == "app-colors":
        # The app id becomes a `[section]` header in app-colors.toml; a stray
        # newline/']'/'#' would corrupt the file and silently drop every
        # override on the next read, so constrain it to safe token chars.
        if not re.match(r"^[A-Za-z0-9._-]+$", args.app or ""):
            msg = f"invalid app id: {args.app!r}"
            eprint(msg)
            if args.json:
                print(json.dumps({"success": False, "error": msg}, indent=2))
            return 1
        bp = find_theme(args.theme) if args.theme else current_theme_obj()
        if not bp:
            eprint(f"Theme not found: {args.theme or '(current)'}")
            return 1
        if not bp.get("package"):
            eprint(f"{bp.get('name')} is a legacy blueprint; run `vshell theme migrate {bp.get('name')}` first")
            return 1
        pkg_dir_name = Path(str(bp.get("path"))).name
        universe = theme_role_universe(bp)
        overrides = read_user_app_overrides(pkg_dir_name)
        app_roles = dict(overrides.get(args.app, {}))
        try:
            if args.reset:
                app_roles = {}
            for raw in args.edits:
                if "=" not in raw:
                    raise ValueError(f"invalid override: {raw}")
                role, value = raw.split("=", 1)
                role = role.strip()
                if role not in universe:
                    raise ValueError(f"unknown role: {role}")
                app_roles[role] = parse_hex_strict(value, role)
        except ValueError as exc:
            eprint(str(exc))
            if args.json:
                print(json.dumps({"success": False, "error": str(exc)}, indent=2))
            return 1
        if app_roles:
            overrides[args.app] = app_roles
        else:
            overrides.pop(args.app, None)
        write_user_app_overrides(pkg_dir_name, overrides)
        applied = None
        refreshed = load_theme_package(pkg_dir_name) or bp
        if str(refreshed.get("name")) == str(current_theme().get("name")):
            applied = apply_theme_obj(refreshed, only_app=args.app)
        result = {"success": True, "app": args.app, "theme": bp.get("name"), "overrides": app_roles, "applied": applied}
        print(json.dumps(result, indent=2) if args.json else (f"{args.app}: {len(app_roles)} override(s)" if app_roles else f"{args.app}: overrides cleared"))
        return 0
    if args.cmd == "app-curated-recolor":
        if not re.match(r"^[A-Za-z0-9._-]+$", args.app or ""):
            msg = f"invalid app id: {args.app!r}"
            eprint(msg)
            if args.json:
                print(json.dumps({"success": False, "error": msg}, indent=2))
            return 1
        bp = find_theme(args.theme) if args.theme else current_theme_obj()
        if not bp or not bp.get("package"):
            msg = f"not an editable theme package: {args.theme or '(current)'}"
            eprint(msg)
            if args.json:
                print(json.dumps({"success": False, "error": msg}, indent=2))
            return 1
        pkg_dir_name = Path(str(bp.get("path"))).name
        curated_name = app_curated_file(args.app, bp)
        apps = bp.get("apps") or {}
        if not curated_name or curated_name not in apps:
            msg = f"{args.app} has no curated file for this theme"
            eprint(msg)
            if args.json:
                print(json.dumps({"success": False, "error": msg}, indent=2))
            return 1
        try:
            pairs = []
            for raw in args.edits:
                if "=" not in raw:
                    raise ValueError(f"invalid recolor: {raw}")
                old, new = raw.split("=", 1)
                pairs.append((parse_hex_strict(old, "from"), parse_hex_strict(new, "to")))
        except ValueError as exc:
            eprint(str(exc))
            if args.json:
                print(json.dumps({"success": False, "error": str(exc)}, indent=2))
            return 1
        # Seed the user overlay curated file from the composed source, then
        # replace every occurrence of each old hex (case-insensitive) with new.
        overlay = user_themes_dir() / pkg_dir_name / "apps" / curated_name
        source_text = Path(apps[curated_name]).read_text(errors="ignore")
        text = overlay.read_text(errors="ignore") if overlay.exists() else source_text
        # Single simultaneous pass over full-token #rrggbb matches: the boundary
        # guard `(?![0-9a-fA-F])` avoids clipping 8-digit #rrggbbaa colors, and the
        # dict lookup means an A=B,B=C batch can't cascade A into C.
        mapping = {old.lower(): new for old, new in pairs}
        changed = 0

        def _recolor(match: "re.Match[str]") -> str:
            nonlocal changed
            replacement = mapping.get(match.group(0).lower())
            if replacement is None:
                return match.group(0)
            changed += 1
            return replacement

        text = re.sub(r'#[0-9a-fA-F]{6}(?![0-9a-fA-F])', _recolor, text)
        overlay.parent.mkdir(parents=True, exist_ok=True)
        write_file(overlay, text)
        applied = None
        refreshed = load_theme_package(pkg_dir_name) or bp
        if str(refreshed.get("name")) == str(current_theme().get("name")):
            applied = apply_theme_obj(refreshed, only_app=args.app)
        result = {"success": True, "app": args.app, "theme": bp.get("name"), "replaced": changed, "applied": applied}
        print(json.dumps(result, indent=2) if args.json else f"{args.app}: recolored {changed} occurrence(s)")
        return 0
    if args.cmd in {"mode", "toggle"}:
        cur = current_theme()
        cur_mode = "light" if (cur.get("mode") or "dark") == "light" else "dark"
        target = args.value if args.cmd == "mode" else "toggle"
        if target == "toggle":
            target = "dark" if cur_mode == "light" else "light"
        cur_name = cur.get("name", "")
        if args.transform:
            base = find_theme(cur_name)
            bp = blueprint_mode_variant(base, target, cur.get("wallpaper", "")) if base else blueprint_from_current_theme(name=cur_name or "vgs-theme", mode=target)
            result = apply_theme_obj(bp)
            print(json.dumps(result, indent=2) if args.json else f"Mode set: {target}")
            return 0
        if target == cur_mode:
            result = {"success": True, "unchanged": True, "mode": target, "name": cur_name}
            print(json.dumps(result, indent=2) if args.json else f"Already {target}: {cur_name}")
            return 0
        base = find_theme(cur_name)
        pair = paired_blueprint(base, target) if base else None
        if pair:
            result = apply_theme_obj(pair)
            result["mode"] = target
            result["pairedFrom"] = cur_name
            print(json.dumps(result, indent=2) if args.json else f"Applied {pair.get('name')} ({target} pair of {cur_name})")
            return 0
        # No curated counterpart: never invent one silently — hand the choice to the user.
        picker = open_theme_picker(target)
        opened = bool(picker.get("ok"))
        hint = f"No {target} pair for '{cur_name}'. Run: vshell theme pick {target}"
        result = {"success": True, "action": "pick", "mode": target, "pickerOpened": opened}
        if not opened:
            result["hint"] = hint
        print(json.dumps(result, indent=2) if args.json else (f"No {target} pair for '{cur_name}'; opened theme picker" if opened else hint))
        return 0
    if args.cmd == "pick":
        picker = open_theme_picker("" if args.mode == "all" else args.mode)
        opened = bool(picker.get("ok"))
        result = {"success": opened, "mode": args.mode, "pickerOpened": opened}
        if not opened:
            result["error"] = picker.get("stderr") or picker.get("error") or "theme picker IPC unavailable"
        print(json.dumps(result, indent=2) if args.json else ("Theme picker opened" if opened else str(result["error"])))
        return 0 if opened else 1
    if args.cmd == "preview":
        return cmd_theme_preview(args)
    if args.cmd == "preview-capture":
        return cmd_theme_preview_capture(args)
    if args.cmd == "chromium-policy":
        roles = target_roles(blueprint_from_current_theme())
        ok = write_chromium_policy(roles, allow_prompt=sys.stdin.isatty())
        print(json.dumps({"success": ok}, indent=2) if args.json else ("ok" if ok else "failed"))
        return 0 if ok else 1
    if args.cmd == "lint":
        if args.name:
            bp = find_theme(args.name)
        else:
            # Default to the last applied blueprint so lint sees raw palette data.
            current_bp_path = cfg_dir() / "theme-current.json"
            bp = json.loads(current_bp_path.read_text()) if current_bp_path.exists() else find_theme(current_theme().get("name", ""))
        if not bp:
            eprint(f"Theme not found: {args.name or '(current)'}")
            return 1
        warnings = lint_blueprint(bp)
        payload = {"name": bp.get("name"), "source": blueprint_source(bp), "warnings": warnings, "count": len(warnings)}
        if args.json:
            print(json.dumps(payload, indent=2))
        elif warnings:
            print(f"{bp.get('name')} ({payload['source']}): {len(warnings)} warning(s)")
            for w in warnings:
                print(f"  - {w['message']}")
        else:
            print(f"{bp.get('name')} ({payload['source']}): no warnings")
        return 0
    if args.cmd == "migrate":
        if not args.name and not args.all:
            eprint("Usage: vshell theme migrate <name> | --all")
            return 2
        candidates = [b for b in list_themes() if not b.get("package")]
        if args.name:
            match = find_theme(args.name)
            if not match:
                eprint(f"Theme not found: {args.name}")
                return 1
            if match.get("package"):
                eprint(f"{match.get('name')} is already a theme package")
                return 1
            candidates = [match]
        migrated = []
        for bp in candidates:
            root = materialize_theme_package(bp, user=not bp.get("builtin"))
            old_path = bp.get("path", "")
            if old_path and not args.keep_blueprint and not bp.get("builtin"):
                with contextlib.suppress(OSError):
                    Path(old_path).unlink()
            migrated.append({"name": bp.get("name"), "package": str(root)})
        if args.json:
            print(json.dumps({"success": True, "migrated": migrated, "count": len(migrated)}, indent=2))
        else:
            for entry in migrated:
                print(f"{entry['name']} -> {entry['package']}")
            if not migrated:
                print("Nothing to migrate: all themes are packages already")
        return 0
    if args.cmd == "apps":
        if args.enable and args.disable:
            eprint("Use either --enable or --disable, not both")
            return 2
        toggled = args.enable or args.disable
        result: Dict[str, Any] = {}
        if toggled:
            known = {entry["app"] for entry in theme_apps_inventory()}
            if toggled not in known:
                eprint(f"Unknown app: {toggled} (known: {', '.join(sorted(known))})")
                return 1
            set_theme_app_enabled(toggled, bool(args.enable))
            if args.enable:
                result["applied"] = apply_theme_obj(current_theme_obj(), only_app=toggled)
        inventory = theme_apps_inventory()
        if args.json:
            print(json.dumps({"apps": inventory, "count": len(inventory), **result}, indent=2))
        else:
            for entry in inventory:
                state = "always on" if entry["always"] else ("on" if entry["enabled"] else "off")
                origin = "" if entry["always"] else (" (setting)" if entry["configured"] else " (auto)")
                detected = "" if entry["detected"] or entry["always"] else " — not installed"
                curated = " [curated]" if entry["curated"] else ""
                print(f"{entry['app']}: {state}{origin}{detected}{curated}")
        return 0
    if args.cmd == "regenerate":
        bp = find_theme(args.name)
        if not bp:
            eprint(f"Theme not found: {args.name}")
            return 1
        if not bp.get("package"):
            eprint(f"{bp.get('name')} is a legacy blueprint; run `vshell theme migrate {bp.get('name')}` first")
            return 1
        rendered_apps = rendered_apps_for(bp)
        if args.app:
            # Accept either the curated file name (ghostty.conf) or the app id (ghostty).
            match = {k: v for k, v in rendered_apps.items() if k == args.app or k.split(".")[0] == args.app}
            if not match:
                eprint(f"No regenerable app file matches: {args.app} (have: {', '.join(sorted(rendered_apps))})")
                return 1
            rendered_apps = match
        existing = set((bp.get("apps") or {}).keys()) & set(rendered_apps.keys())
        if existing and not args.yes:
            warning = f"Overwrites hand-edits in apps/: {', '.join(sorted(existing))}"
            if sys.stdin.isatty():
                reply = input(f"{warning}. Continue? [y/N] ").strip().lower()
                if reply not in {"y", "yes"}:
                    print("Aborted")
                    return 1
            else:
                eprint(f"{warning}. Re-run with --yes to confirm.")
                return 1
        if blueprint_source(bp) == "curated":
            eprint(f"note: {bp.get('name')} is curated; regenerating replaces curated files with palette renders")
        root = materialize_theme_package(bp, apps=rendered_apps)
        result = {"success": True, "package": str(root), "regenerated": sorted(rendered_apps.keys())}
        print(json.dumps(result, indent=2) if args.json else "\n".join(f"regenerated apps/{n}" for n in sorted(rendered_apps)))
        return 0
    if args.cmd in {"edit-app", "reset-app"}:
        bp = find_theme(args.theme) if args.theme else current_theme_obj()
        if not bp:
            eprint(f"Theme not found: {args.theme or '(current)'}")
            return 1
        if not bp.get("package"):
            eprint(f"{bp.get('name')} is a legacy blueprint; run `vshell theme migrate {bp.get('name')}` first")
            return 1
        cfg = None
        for cfg_path in sorted(targets_dir().glob("*/config.json")):
            candidate = json.loads(cfg_path.read_text())
            if str(candidate.get("app") or "") == args.app and candidate.get("curatedFile"):
                cfg = candidate
                cfg_dir_path = cfg_path.parent
                break
        if not cfg:
            eprint(f"App has no curated file support: {args.app}")
            return 1
        filename = str(cfg["curatedFile"])
        pkg_dir_name = Path(str(bp.get("path"))).name
        user_path = user_themes_dir() / pkg_dir_name / "apps" / filename
        if args.cmd == "edit-app":
            created = False
            if not user_path.exists():
                # First edit: seed from the curated file if the theme ships one,
                # else from the current palette render.
                existing = (bp.get("apps") or {}).get(filename)
                if existing:
                    content = Path(existing).read_text()
                elif cfg.get("template"):
                    content = render_template((cfg_dir_path / cfg["template"]).read_text(), target_roles(bp))
                else:
                    eprint(f"{args.app} has no template to seed from; create {user_path} by hand")
                    return 1
                write_file(user_path, content)
                created = True
            result = {"success": True, "path": str(user_path), "created": created, "app": args.app, "theme": bp.get("name")}
            print(json.dumps(result, indent=2) if args.json else str(user_path))
            return 0
        removed = user_path.exists()
        with contextlib.suppress(OSError):
            user_path.unlink()
        refreshed = load_theme_package(pkg_dir_name) or bp
        applied = None
        if refreshed.get("name") == current_theme().get("name"):
            applied = apply_theme_obj(refreshed, only_app=args.app)
        result = {"success": True, "removed": removed, "app": args.app, "theme": bp.get("name"), "applied": applied}
        print(json.dumps(result, indent=2) if args.json else (f"reset {args.app}" if removed else f"nothing to reset for {args.app}"))
        return 0
    if args.cmd == "set-pair":
        bp = find_theme(args.name)
        if not bp or not bp.get("package"):
            eprint(f"Theme package not found: {args.name}")
            return 1
        pkg_dir_name = Path(str(bp.get("path"))).name
        meta = read_theme_overlay_meta(pkg_dir_name)
        meta["pair"] = args.pair
        write_theme_overlay_meta(pkg_dir_name, meta)
        result = {"success": True, "name": bp.get("name"), "pair": args.pair}
        print(json.dumps(result, indent=2) if args.json else f"{bp.get('name')} pairs with {args.pair or '(none)'}")
        return 0
    if args.cmd == "delete":
        bp = find_theme(args.name)
        if not bp:
            eprint(f"Theme not found: {args.name}")
            return 1
        if bp.get("builtin"):
            eprint(f"{bp.get('name')} is built-in; only its user overlay can be removed")
            if bp.get("userDir"):
                shutil.rmtree(bp["userDir"], ignore_errors=True)
                print(json.dumps({"success": True, "removedOverlay": bp["userDir"]}, indent=2) if args.json else f"Removed user overlay: {bp['userDir']}")
                return 0
            return 1
        removed = []
        if bp.get("package"):
            shutil.rmtree(bp["path"], ignore_errors=True)
            removed.append(bp["path"])
        elif bp.get("path"):
            with contextlib.suppress(OSError):
                Path(bp["path"]).unlink()
                removed.append(bp["path"])
        result = {"success": bool(removed), "removed": removed}
        print(json.dumps(result, indent=2) if args.json else "\n".join(f"deleted {p}" for p in removed))
        return 0 if removed else 1
    if args.cmd == "duplicate":
        bp = find_theme(args.name)
        if not bp:
            eprint(f"Theme not found: {args.name}")
            return 1
        new_name = args.new_name or f"{bp.get('name')}-copy"
        safe_new = re.sub(r"[^A-Za-z0-9_.-]+", "-", new_name).strip("-") or "theme-copy"
        dest = user_themes_dir() / safe_new
        if dest.exists():
            eprint(f"Theme already exists: {safe_new}")
            return 1
        if bp.get("package"):
            files = compose_theme_files(Path(str(bp.get("path"))).name)
            for rel, src in files.items():
                target_path = dest / rel
                target_path.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(src, target_path)
            meta = json.loads((dest / "theme.json").read_text())
            meta["name"] = new_name
            write_file(dest / "theme.json", json.dumps(meta, indent=2) + "\n")
            # A copy is the user's own theme, never something the catalog may
            # remove later. `catalog_owns()` rejects an inherited marker anyway;
            # dropping it here keeps the copy honest on disk too.
            with contextlib.suppress(OSError):
                (dest / CATALOG_MARKER).unlink()
        else:
            bp = dict(bp)
            bp["name"] = new_name
            materialize_theme_package(bp)
        result = {"success": True, "name": new_name, "package": str(dest)}
        print(json.dumps(result, indent=2) if args.json else f"{bp.get('name')} duplicated to {dest}")
        return 0
    if args.cmd == "catalog":
        catalog = load_theme_catalog()
        if args.catalog_cmd == "list":
            entries = catalog_entries()
            if args.json:
                source = catalog.get("source") or {}
                print(json.dumps({
                    "themes": entries,
                    "count": len(entries),
                    "installedCount": sum(1 for e in entries if e["installed"]),
                    "totalSize": sum(e["size"] for e in entries),
                    "ref": source.get("ref", ""),
                    "repo": source.get("repo", ""),
                }, indent=2))
            else:
                for entry in entries:
                    print(f"{'*' if entry['installed'] else ' '} {entry['name']}")
            return 0

        base_urls, allow_local = theme_catalog_base_urls(catalog)
        if args.catalog_cmd == "install":
            names = list(args.names)
            if args.all:
                names = [e["name"] for e in catalog_entries() if not e["installed"]]
            if not names:
                eprint("Nothing to download (pass theme names or --all)")
                return 2
            results: List[Dict[str, Any]] = []
            failures = 0
            for name in names:
                entry = catalog_theme_entry(catalog, name)
                if not entry:
                    results.append({"name": name, "status": "failed", "error": "not in the theme catalog"})
                    failures += 1
                    continue
                try:
                    results.append(catalog_download_theme(entry, base_urls, allow_local, force=args.force))
                except Exception as exc:
                    results.append({"name": name, "status": "failed", "error": str(exc)})
                    failures += 1
            installed_now = [r["name"] for r in results if r.get("status") == "installed"]
            payload = {
                "success": failures == 0,
                "results": results,
                "installed": installed_now,
                "bytes": sum(int(r.get("bytes") or 0) for r in results),
            }
            if args.json:
                print(json.dumps(payload, indent=2))
            else:
                for result in results:
                    detail = result.get("error") or result.get("reason") or result.get("path", "")
                    print(f"{result['name']}: {result['status']}" + (f" ({detail})" if detail else ""))
            return 0 if failures == 0 else 1
        if args.catalog_cmd == "remove":
            results = []
            failures = 0
            for name in args.names:
                try:
                    results.append(catalog_remove_theme(name))
                except Exception as exc:
                    results.append({"name": name, "status": "failed", "error": str(exc)})
                    failures += 1
            if args.json:
                print(json.dumps({"success": failures == 0, "results": results}, indent=2))
            else:
                for result in results:
                    print(f"{result['name']}: {result['status']}" + (
                        f" ({result['error']})" if result.get("error") else ""))
            return 0 if failures == 0 else 1
        return 2
    if args.cmd == "icons":
        installed = list_installed_icon_themes()
        theme_icon = ""
        pointer = generated_dir() / "icons.theme"
        if pointer.exists():
            theme_icon = pointer.read_text().strip()
        result = {
            "installed": installed,
            "themeIcon": theme_icon,
            "themeIconInstalled": theme_icon in installed,
        }
        print(json.dumps(result, indent=2) if args.json else "\n".join(installed))
        return 0
    return 1


def clipboard_state_file() -> Path:
    return state_dir() / "clipboard-history.json"


def clipboard_images_dir() -> Path:
    return state_dir() / "clipboard-images"


@contextlib.contextmanager
def clipboard_state_lock():
    ensure_dirs()
    lock_path = state_dir() / "clipboard-history.lock"
    with lock_path.open("a+") as lock:
        fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
        try:
            yield
        finally:
            fcntl.flock(lock.fileno(), fcntl.LOCK_UN)


def load_clipboard_state() -> Dict[str, Any]:
    ensure_dirs()
    path = clipboard_state_file()
    if not path.exists():
        return {"nextId": 1, "entries": []}
    try:
        data = json.loads(path.read_text())
        data.setdefault("nextId", 1)
        data.setdefault("entries", [])
        return data
    except Exception as exc:
        corrupt = path.with_suffix(path.suffix + f".corrupt-{int(time.time())}")
        try:
            path.replace(corrupt)
            msg = f"clipboard history was corrupt; moved to {corrupt}: {exc}"
            eprint(msg)
            return {"nextId": 1, "entries": [], "_warning": msg}
        except Exception:
            msg = f"clipboard history was corrupt and could not be moved: {exc}"
            eprint(msg)
            return {"nextId": 1, "entries": [], "_warning": msg}


def clipboard_entry_recency(entry: Dict[str, Any]) -> Tuple[int, int]:
    return (int(entry.get("timestamp") or 0), int(entry.get("id") or 0))


def save_clipboard_state(data: Dict[str, Any]) -> None:
    ensure_dirs()
    # Keep history bounded; pinned entries plus newest unpinned entries.
    # Inline base64 "data" is a legacy field (image blobs live on disk, text is
    # stored in "text"), so drop it on every save to migrate old state files.
    entries = [{k: v for k, v in e.items() if k != "data"} for e in (data.get("entries") or [])]
    pinned = [e for e in entries if e.get("pinned")]
    unpinned = [e for e in entries if not e.get("pinned")]
    unpinned = sorted(unpinned, key=clipboard_entry_recency, reverse=True)[:100]
    data = dict(data)
    data.pop("_warning", None)
    data["entries"] = pinned + unpinned
    write_file(clipboard_state_file(), json.dumps(data, indent=2) + "\n")


def clipboard_entry_public(entry: Dict[str, Any]) -> Dict[str, Any]:
    text = entry.get("text") or ""
    preview = entry.get("preview") or ("Image" if entry.get("isImage") else text[:240])
    mime = entry.get("mime") or ("image/png" if entry.get("isImage") else "text/plain")
    return {
        "id": entry.get("id"),
        "hash": entry.get("hash") or "",
        "preview": preview,
        "text": text if len(text) <= 500 else text[:500],
        "size": int(entry.get("size") or len(text)),
        "isImage": bool(entry.get("isImage")),
        "mime": mime,
        "mimeType": mime,
        "pinned": bool(entry.get("pinned")),
        "timestamp": entry.get("timestamp") or 0,
        "path": entry.get("path") or "",
    }


def _wl_paste_text() -> str | None:
    if not shutil.which("wl-paste"):
        return None
    proc = subprocess.run(["wl-paste", "--no-newline", "--type", "text"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=2)
    if proc.returncode != 0 or not proc.stdout:
        return None
    try:
        return proc.stdout.decode("utf-8", errors="replace")
    except Exception:
        return None


def _wl_paste_image() -> Tuple[str, bytes] | None:
    if not shutil.which("wl-paste"):
        return None
    types = subprocess.run(["wl-paste", "--list-types"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=2)
    if types.returncode != 0:
        return None
    mime = next((line.strip() for line in types.stdout.splitlines() if line.strip().startswith("image/")), "")
    if not mime:
        return None
    proc = subprocess.run(["wl-paste", "--type", mime], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=5)
    if proc.returncode != 0 or not proc.stdout:
        return None
    return mime, proc.stdout


def clipboard_poll_current() -> None:
    state = load_clipboard_state()
    entries: List[Dict[str, Any]] = list(state.get("entries") or [])
    text = _wl_paste_text()
    new_entry: Dict[str, Any] | None = None
    if text is not None and text != "":
        digest = hashlib.sha256(("text\0" + text).encode("utf-8", errors="replace")).hexdigest()
        new_entry = {
            "hash": digest,
            "text": text,
            "preview": text.replace("\n", " ")[:240],
            "size": len(text),
            "isImage": False,
            "mime": "text/plain",
        }
    else:
        image = _wl_paste_image()
        if image:
            mime, blob = image
            digest = hashlib.sha256(b"image\0" + blob).hexdigest()
            img_dir = clipboard_images_dir()
            img_dir.mkdir(parents=True, exist_ok=True)
            suffix = "png" if mime == "image/png" else mime.split("/")[-1].replace("+xml", "")
            image_path = img_dir / f"{digest}.{suffix}"
            if not image_path.exists():
                image_path.write_bytes(blob)
            new_entry = {
                "hash": digest,
                "text": "",
                "preview": f"Image ({mime})",
                "size": len(blob),
                "isImage": True,
                "mime": mime,
                "path": str(image_path),
            }
    if not new_entry:
        return
    existing = next((e for e in entries if e.get("hash") == new_entry["hash"]), None)
    if existing:
        # Re-copied content bumps to the top by recency instead of keeping
        # its original position.
        existing["timestamp"] = int(time.time() * 1000)
        existing["id"] = int(existing.get("id") or state.get("nextId") or 1)
    else:
        new_entry["id"] = int(state.get("nextId") or 1)
        state["nextId"] = int(new_entry["id"]) + 1
        new_entry["timestamp"] = int(time.time() * 1000)
        new_entry["pinned"] = False
        entries.insert(0, new_entry)
    state["entries"] = sorted(entries, key=clipboard_entry_recency, reverse=True)
    save_clipboard_state(state)


def clipboard_find_entry(state: Dict[str, Any], entry_id: Any) -> Dict[str, Any] | None:
    try:
        wanted = int(entry_id)
    except Exception:
        return None
    return next((e for e in state.get("entries") or [] if int(e.get("id") or -1) == wanted), None)


def wl_copy_bytes(blob: bytes, mime: str | None = None) -> Tuple[bool, str]:
    if not shutil.which("wl-copy"):
        return False, "wl-copy not found"
    cmd = ["wl-copy"]
    if mime:
        cmd.extend(["--type", mime])
    try:
        proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, start_new_session=True)
        assert proc.stdin is not None
        proc.stdin.write(blob)
        proc.stdin.close()
        try:
            code = proc.wait(timeout=0.6)
            if code != 0:
                err = (proc.stderr.read() if proc.stderr else b"").decode(errors="replace").strip()
                return False, err or "wl-copy failed"
        except subprocess.TimeoutExpired:
            # wl-copy may stay foreground to serve the Wayland clipboard; that is success.
            pass
        return True, ""
    except Exception as exc:
        return False, str(exc)


def wl_copy_text(text: str) -> Tuple[bool, str]:
    return wl_copy_bytes(text.encode("utf-8", errors="replace"), None)


def clipboard_copy_entry(entry: Dict[str, Any]) -> bool:
    if entry.get("isImage"):
        path = entry.get("path") or ""
        if not path or not Path(path).exists():
            return False
        ok, _err = wl_copy_bytes(Path(path).read_bytes(), entry.get("mime") or "image/png")
        return ok
    text = entry.get("text") or ""
    ok, _err = wl_copy_text(text)
    return ok


def clipboard_rpc(method: str, params: Dict[str, Any] | None) -> Dict[str, Any]:
    # Reads serve the state file as-is: the watcher (backend daemon, or
    # `clipboard watch` as fallback) is the single owner keeping it fresh.
    # Polling here turned every history read into a full clipboard transfer.
    params = params or {}
    state = load_clipboard_state()
    warning = state.pop("_warning", "")
    entries = list(state.get("entries") or [])
    entries.sort(key=lambda e: (not bool(e.get("pinned")),) + tuple(-v for v in clipboard_entry_recency(e)))

    def respond(payload: Dict[str, Any]) -> Dict[str, Any]:
        if warning:
            payload["warning"] = warning
        return payload

    if method == "clipboard.getHistory":
        return respond({"result": [clipboard_entry_public(e) for e in entries]})
    if method == "clipboard.search":
        query = str(params.get("query") or "").lower()
        limit = int(params.get("limit") or 20)
        matches = [e for e in entries if query in (e.get("preview") or e.get("text") or "").lower()]
        return respond({"result": {"entries": [clipboard_entry_public(e) for e in matches[:limit]]}})
    if method == "clipboard.getEntry":
        entry = clipboard_find_entry(state, params.get("id"))
        if not entry:
            return respond({"result": None})
        data = ""
        if entry.get("isImage"):
            if entry.get("path") and Path(entry["path"]).exists():
                blob = Path(entry["path"]).read_bytes()
                if len(blob) <= 2_000_000:
                    data = base64.b64encode(blob).decode("ascii")
        else:
            data = base64.b64encode((entry.get("text") or "").encode("utf-8", errors="replace")).decode("ascii")
        return respond({"result": {**clipboard_entry_public(entry), "data": data}})
    if method == "clipboard.copyEntry":
        entry = clipboard_find_entry(state, params.get("id"))
        if not entry:
            return respond({"error": "entry not found"})
        if not clipboard_copy_entry(entry):
            return respond({"error": "failed to copy entry"})
        return respond({"result": True})
    if method == "clipboard.copy":
        text = str(params.get("text") or "")
        ok, err = wl_copy_text(text)
        if not ok:
            return respond({"error": err or "wl-copy failed"})
        clipboard_poll_current()
        return respond({"result": True})
    if method == "clipboard.deleteEntry":
        entry = clipboard_find_entry(state, params.get("id"))
        if not entry:
            return respond({"result": True})
        state["entries"] = [e for e in state.get("entries") or [] if int(e.get("id") or -1) != int(entry.get("id") or -1)]
        save_clipboard_state(state)
        return respond({"result": True})
    if method == "clipboard.getPinnedCount":
        return respond({"result": {"count": len([e for e in entries if e.get("pinned")])}})
    if method in {"clipboard.pinEntry", "clipboard.unpinEntry"}:
        entry = clipboard_find_entry(state, params.get("id"))
        if not entry:
            return respond({"error": "entry not found"})
        entry["pinned"] = method == "clipboard.pinEntry"
        save_clipboard_state(state)
        return respond({"result": True})
    if method == "clipboard.clearHistory":
        state["entries"] = [e for e in state.get("entries") or [] if e.get("pinned")]
        save_clipboard_state(state)
        return respond({"result": True})
    if method == "clipboard.getConfig":
        return respond({"result": {}})
    if method == "clipboard.setConfig":
        return respond({"result": True})
    return respond({"error": f"unsupported VGS clipboard method: {method}"})


def cmd_clipboard(argv: List[str]) -> int:
    if len(argv) >= 1 and argv[0] == "rpc":
        parser = argparse.ArgumentParser(prog="vshell clipboard rpc")
        parser.add_argument("method")
        parser.add_argument("params", nargs="?", default="{}")
        parser.add_argument("--json", action="store_true")
        args = parser.parse_args(argv[1:])
        try:
            params = json.loads(args.params or "{}")
            with clipboard_state_lock():
                response = clipboard_rpc(args.method, params)
        except Exception as exc:
            response = {"error": str(exc)}
        print(json.dumps(response, indent=2) if args.json else json.dumps(response))
        return 1 if response.get("error") else 0
    if len(argv) >= 1 and argv[0] == "poll":
        with clipboard_state_lock():
            clipboard_poll_current()
        return 0
    if len(argv) >= 1 and argv[0] == "watch":
        if not shutil.which("wl-paste"):
            eprint("wl-paste not found")
            return 1
        # Singleton guard: a second watcher is a bug amplifier (every clipboard
        # change would poll N times), so bail out if one already holds the lock.
        ensure_dirs()
        lock = (state_dir() / "clipboard-watch.lock").open("a+")
        try:
            fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        except OSError:
            eprint("clipboard watcher already running")
            return 0
        # exec (not spawn): whoever supervises this command must own wl-paste
        # itself — a python intermediary turns its child into an unkillable
        # orphan when the supervisor dies or reloads. The lock fd is made
        # inheritable so the flock lives exactly as long as wl-paste does.
        os.set_inheritable(lock.fileno(), True)
        os.execvp("wl-paste", ["wl-paste", "--watch", resolve_vshell_cli(), "clipboard", "poll"])
    if len(argv) >= 1 and argv[0] == "history":
        with clipboard_state_lock():
            response = clipboard_rpc("clipboard.getHistory", {})
        print(json.dumps(response.get("result", []), indent=2))
        return 0
    if len(argv) >= 1 and argv[0] == "copy":
        text = argv[1] if len(argv) >= 2 else sys.stdin.read()
        if shutil.which("wl-copy"):
            ok, err = wl_copy_text(text)
            if not ok:
                eprint(err or "wl-copy failed")
                return 1
            with clipboard_state_lock():
                clipboard_poll_current()
            return 0
        if shutil.which("xclip"):
            proc = subprocess.run(["xclip", "-selection", "clipboard"], input=text, text=True, stderr=subprocess.PIPE)
            if proc.returncode != 0:
                eprint(proc.stderr.strip() or "xclip failed")
                return proc.returncode or 1
            return 0
        eprint("No clipboard tool found")
        return 1
    eprint("Usage: vshell clipboard rpc|history|poll|watch|copy")
    return 2


def cmd_download(argv: List[str]) -> int:
    timeout = "30"
    curl_args = ["curl", "-fsSL"]
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in {"-4", "-6"}:
            curl_args.append(a)
        elif a == "--timeout" and i + 1 < len(argv):
            timeout = argv[i + 1]
            i += 1
        else:
            curl_args.extend(["--max-time", timeout, a])
        i += 1
    return subprocess.run(curl_args).returncode


def cmd_trash(argv: List[str]) -> int:
    if not argv:
        eprint("Usage: vshell trash count|put|empty")
        return 2
    if argv[0] == "count":
        proc = run(["gio", "trash", "--list"]) if shutil.which("gio") else run(["find", str(home() / ".local/share/Trash/files"), "-mindepth", "1", "-maxdepth", "1"])
        print(len([l for l in proc.stdout.splitlines() if l.strip()]))
        return 0
    if argv[0] == "put" and len(argv) >= 2:
        return subprocess.run(["gio", "trash", argv[1]] if shutil.which("gio") else ["rm", "-rf", argv[1]]).returncode
    if argv[0] == "empty":
        return subprocess.run(["gio", "trash", "--empty"] if shutil.which("gio") else ["rm", "-rf", str(home() / ".local/share/Trash/files")]).returncode
    return 2


def cmd_color(argv: List[str]) -> int:
    if len(argv) >= 1 and argv[0] == "pick":
        want_json = "--json" in argv
        fmt = "hex"
        if "--rgb" in argv:
            fmt = "rgb"
        if "--hsv" in argv:
            fmt = "hsv"
        niri_session = bool(
            os.environ.get("NIRI_SOCKET")
            and shutil.which("niri")
            and run(["niri", "msg", "version"]).returncode == 0
        )
        if niri_session and shutil.which("slurp") and shutil.which("grim"):
            selection = run(["slurp", "-p", "-f", "%x,%y 1x1"])
            if selection.returncode != 0 or not selection.stdout.strip():
                eprint(selection.stderr.strip())
                return selection.returncode or 1
            image = subprocess.run(
                ["grim", "-g", selection.stdout.strip(), "-t", "ppm", "-"],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                timeout=10,
            )
            if image.returncode != 0:
                eprint(image.stderr.decode(errors="replace").strip())
                return image.returncode
            try:
                r, g, b = ppm_first_pixel(image.stdout)
                hx = f"#{r:02x}{g:02x}{b:02x}"
            except ValueError as exc:
                eprint(str(exc))
                return 1
        elif shutil.which("hyprpicker"):
            proc = run(["hyprpicker", "-q", "-r", "-l", "-f", "hex"])
            if proc.returncode != 0:
                eprint(proc.stderr.strip())
                return proc.returncode
            hx = clean_hex(proc.stdout.strip())
        else:
            eprint("color picking requires hyprpicker, or grim + slurp in a Niri session")
            return 1
        r, g, b = rgb(hx)
        h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
        if want_json:
            print(json.dumps({"hex": hx, "rgb": {"r": r, "g": g, "b": b}, "hsv": {"h": round(h * 360), "s": round(s * 100), "v": round(v * 100)}}))
        elif fmt == "rgb":
            print(f"{r} {g} {b}")
        elif fmt == "hsv":
            print(f"{round(h*360)} {round(s*100)} {round(v*100)}")
        else:
            print(hx)
        return 0
    return 2


def ppm_first_pixel(data: bytes) -> Tuple[int, int, int]:
    if not data.startswith(b"P6"):
        raise ValueError("grim returned an unsupported image format")
    index = 2
    tokens: List[bytes] = []
    while len(tokens) < 3:
        while index < len(data) and data[index:index + 1].isspace():
            index += 1
        if index < len(data) and data[index:index + 1] == b"#":
            newline = data.find(b"\n", index)
            if newline < 0:
                raise ValueError("invalid PPM header")
            index = newline + 1
            continue
        start = index
        while index < len(data) and not data[index:index + 1].isspace():
            index += 1
        if start == index:
            raise ValueError("invalid PPM header")
        tokens.append(data[start:index])
    if index >= len(data) or not data[index:index + 1].isspace():
        raise ValueError("invalid PPM header")
    if data[index:index + 2] == b"\r\n":
        index += 2
    else:
        index += 1
    try:
        width, height, maximum = (int(token) for token in tokens)
    except ValueError as exc:
        raise ValueError("invalid PPM header") from exc
    if width < 1 or height < 1 or maximum != 255 or len(data) < index + 3:
        raise ValueError("invalid PPM pixel data")
    return data[index], data[index + 1], data[index + 2]


def hypr_binds_json() -> Dict[str, Any]:
    proc = run(["hyprctl", "binds", "-j"]) if shutil.which("hyprctl") else subprocess.CompletedProcess([], 1, "", "")
    binds: Dict[str, List[Dict[str, Any]]] = {"Window": [], "Workspace": [], "System": [], "Execute": [], "Other": []}
    try:
        data = json.loads(proc.stdout or "[]")
    except Exception:
        data = []
    for b in data:
        mods = b.get("modmask", 0)
        key = b.get("key") or (f"code:{b.get('keycode')}" if b.get("keycode") else "")
        mod_names = []
        # Hyprland bitmask: shift=1 caps=2 ctrl=4 alt=8 mod2=16 mod3=32 super=64 mod5=128
        if mods & 64: mod_names.append("Super")
        if mods & 4: mod_names.append("Ctrl")
        if mods & 8: mod_names.append("Alt")
        if mods & 1: mod_names.append("Shift")
        combo = "+".join(mod_names + ([key] if key else []))
        dispatcher = b.get("dispatcher", "")
        arg = b.get("arg", "")
        desc = b.get("description") or arg or dispatcher
        action = (dispatcher + (" " + arg if arg else "")).strip()
        cat = "Other"
        if "workspace" in action:
            cat = "Workspace"
        elif dispatcher in {"exec", "spawn"}:
            cat = "Execute"
        elif any(x in action for x in ["window", "move", "resize", "focus", "group", "killactive"]):
            cat = "Window"
        elif any(x in action for x in ["dpms", "exit", "lock", "night", "reload"]):
            cat = "System"
        binds.setdefault(cat, []).append({"key": combo, "desc": desc, "action": action, "source": "hyprland"})
    return {"provider": "hyprland", "modKey": "Super", "vgsBindsIncluded": True, "vgsStatus": {"exists": True, "included": True, "readOnly": True, "configFormat": "lua", "statusMessage": "VGS reads live Hyprland binds read-only; change them in your Hyprland config"}, "binds": binds}
























def cmd_keybinds(argv: List[str]) -> int:
    if len(argv) >= 2 and argv[0] == "show":
        provider = argv[1]
        if provider == "hyprland":
            print(json.dumps(hypr_binds_json()))
            return 0
        if provider == "niri":
            print(json.dumps(_niri().niri_binds_json()))
            return 0
        print(json.dumps({"provider": provider, "binds": {}}))
        return 0
    if len(argv) >= 3 and argv[0] == "set" and argv[1] == "niri":
        parser = argparse.ArgumentParser(prog="vshell keybinds set niri")
        parser.add_argument("key")
        parser.add_argument("action")
        parser.add_argument("--desc", default="")
        parser.add_argument("--replace-key", default="")
        parser.add_argument("--cooldown-ms", type=int, default=0)
        parser.add_argument("--allow-when-locked", action="store_true")
        parser.add_argument("--no-repeat", action="store_true")
        parser.add_argument("--no-inhibiting", action="store_true")
        parser.add_argument("--flags", default="")
        args = parser.parse_args(argv[2:])
        try:
            with _niri().niri_config_lock():
                binds = [bind for bind in _niri()._load_vgs_niri_binds()
                         if bind.get("key") not in {args.key, args.replace_key}]
                binds.append({
                    "key": args.key,
                    "action": args.action,
                    "desc": args.desc,
                    "cooldownMs": max(0, args.cooldown_ms),
                    "allowWhenLocked": args.allow_when_locked,
                    "allowInhibiting": not args.no_inhibiting,
                    "repeat": not args.no_repeat,
                })
                _niri()._write_vgs_niri_binds(binds)
                reload_result = _niri()._reload_niri()
        except ValueError as exc:
            eprint(str(exc))
            return 2
        if reload_result.get("attempted") and not reload_result.get("ok"):
            eprint(reload_result.get("stderr") or reload_result.get("stdout") or "Niri config reload failed")
            return 1
        return 0
    if len(argv) >= 3 and argv[0] in {"remove", "reset"} and argv[1] == "niri":
        key = argv[2]
        with _niri().niri_config_lock():
            _niri()._write_vgs_niri_binds([
                bind for bind in _niri()._load_vgs_niri_binds()
                if bind.get("key") != key
            ])
            reload_result = _niri()._reload_niri()
        if reload_result.get("attempted") and not reload_result.get("ok"):
            eprint(reload_result.get("stderr") or reload_result.get("stdout") or "Niri config reload failed")
            return 1
        return 0
    if argv and argv[0] in {"set", "remove", "reset"}:
        eprint("VGS keybind editing is not implemented for this config; edit your Hyprland keybind config directly")
        return 1
    return 2


def scratchpad_session_compositor() -> str:
    """What is actually RUNNING, or "" when nothing is.

    Deliberately does not consider installed binaries. `hyprctl` and `niri`
    coexist in most distro repos, so the presence of `hyprctl` says nothing
    about which compositor owns the session — treating it as proof sent a Niri
    session down the Hyprland path and generated rules for a compositor that
    was not running, which is exactly what the deliberate Niri refusal exists
    to prevent."""
    if os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
        return "hyprland"
    if os.environ.get("NIRI_SOCKET"):
        return "niri"
    desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").strip()
    if desktop:
        folded = desktop.lower()
        for name in ("hyprland", "niri"):
            if name in folded:
                return name
        # A desktop that is set and is neither of those is still a session, and
        # reporting it as "nothing is running" would let generation proceed
        # under a compositor that will never read the result.
        return desktop.split(":")[0].lower()
    return ""


def scratchpad_compositor_supported() -> Tuple[bool, str]:
    """Which backend this session gets, if any.

    Hyprland is the reference implementation; Niri is a separate backend
    (VGS-83) with its own generator and its own toggle, not a translation of the
    Hyprland one — Niri has no special workspaces. Anything else still refuses
    with the reason.

    With NO session running, generation is still meaningful — writing the config
    from a TTY before starting the compositor, or regenerating it after an edit.
    It is allowed, and defaults to the Hyprland backend because that is what the
    generated-without-a-session caveat is written for; the live-session paths
    (`toggle`/`preload`) check for a real session of their own."""
    session = scratchpad_session_compositor()
    if session:
        return (session in ("hyprland", "niri"), session)
    return (True, "none")


def cmd_scratchpad(argv: List[str]) -> int:
    if not argv:
        eprint("Usage: vshell scratchpad <apply|status|toggle|show|hide|preload|release|match|resolve> ...")
        return 2
    action, rest = argv[0], argv[1:]

    supported, compositor = scratchpad_compositor_supported()
    if not supported and action in {"apply", "toggle", "show", "hide", "preload"}:
        # A clean, stated no-op — not a silent success, and not config written
        # into a session that will never read it. `hide` is in the set because
        # it is a live-session action like the rest; Niri is no longer in the
        # refusal because it now has a backend of its own.
        message = f"VGS scratchpads require Hyprland or Niri; this session is running {compositor}."
        if "--json" in rest:
            print(json.dumps({"ok": False, "unsupported": True,
                              "compositor": compositor, "error": message}, indent=2))
        else:
            eprint(message)
        return 1

    # Which backend answers. Two generators and two toggles, one schema — see
    # docs/architecture/scratchpads.md § Niri for why this is not a translation.
    on_niri = compositor == "niri"

    if action == "apply":
        parser = argparse.ArgumentParser(prog="vshell scratchpad apply")
        parser.add_argument("--no-reload", action="store_true")
        # Renders to a scratch path and prints it instead of touching the live
        # compositor config, so generation can be reviewed and diffed without a
        # session being reconfigured underneath the user.
        parser.add_argument("--dry-run", metavar="PATH", default="")
        parser.add_argument("--json", action="store_true")
        args = parser.parse_args(rest)
        if args.dry_run:
            problems: List[Dict[str, str]] = []
            pads = load_scratchpads(problems)
            if on_niri:
                content, meta = render_scratchpads_kdl(pads, problems)
            else:
                monitors, resolved = scratchpad_monitors()
                content, meta = render_scratchpads_lua(pads, monitors, resolved)
            write_file(Path(args.dry_run), content)
            result = {"ok": True, "path": args.dry_run, "compositor": compositor,
                      "scratchpads": meta, "problems": problems, "dryRun": True}
        else:
            result = (apply_scratchpads_niri(reload=not args.no_reload) if on_niri
                      else apply_scratchpads(reload=not args.no_reload))
        print(json.dumps(result, indent=2) if args.json else
              ("ok" if result.get("ok") else (result.get("error") or "failed")))
        return 0 if result.get("ok") else 1

    if action == "status":
        problems: List[Dict[str, str]] = []
        pads = load_scratchpads(problems)
        if on_niri:
            path = scratchpad_niri_config_path()
            # No monitor query: Niri resolves the percentage and the anchor
            # itself, so nothing here is generated against a guessed display and
            # `monitorsResolved` is truthfully not an open question.
            _, meta = render_scratchpads_kdl(pads, problems)
            print(json.dumps({
                "ok": True,
                "compositor": "niri",
                "problems": problems,
                "path": str(path),
                "generated": path.exists(),
                "monitorsResolved": True,
                "monitors": [],
                "unsupported": meta["unsupported"],
                "include": scratchpad_niri_include_status(),
                "scratchpads": pads,
            }, indent=2))
            return 0
        monitors, resolved = scratchpad_monitors()
        print(json.dumps({
            "ok": True,
            "compositor": compositor,
            "problems": problems,
            "path": str(scratchpad_config_path()),
            "generated": scratchpad_config_path().exists(),
            "monitorsResolved": resolved,
            "monitors": [{"name": m.get("name"), "logical": monitor_logical_size(m)} for m in monitors],
            "unsupported": [],
            "include": scratchpad_include_status(),
            "scratchpads": pads,
        }, indent=2))
        return 0

    # Which live windows a pad's pattern actually claims. A class match applies
    # to every current and future instance of an application, and nothing in the
    # UI showed that — so a user with one terminal as a scratchpad and another
    # tiled had both captured with no indication why. This makes the breadth of
    # a pattern inspectable before it is saved.
    if action == "match":
        parser = argparse.ArgumentParser(prog="vshell scratchpad match")
        parser.add_argument("id", nargs="?", default="")
        parser.add_argument("--class-regex", default="")
        parser.add_argument("--title-exclude", default="")
        parser.add_argument("--json", action="store_true")
        args = parser.parse_args(rest)
        pattern, exclude = args.class_regex, args.title_exclude
        if not pattern:
            pad = {p["id"]: p for p in load_scratchpads()}.get(args.id)
            if pad is None:
                eprint(f"unknown scratchpad: {args.id or '(none)'}")
                return 2
            pattern, exclude = pad["classRegex"], pad["titleExclude"]
        result = scratchpad_matching_windows(pattern, exclude)
        if args.json:
            print(json.dumps(result, indent=2))
        elif not result.get("ok"):
            # An error is not a count. Printing "0 live window(s) match" for a
            # pattern that never compiled says the pattern works and happens to
            # match nothing — which is exactly the "silently matches nothing"
            # failure this command exists to surface.
            eprint(result.get("error") or "could not evaluate the pattern")
        elif result.get("known") is False:
            # Nor is "nobody asked". No session means no answer, which must not
            # render as zero either.
            print(f"unknown: no Hyprland session to ask about {pattern}")
        else:
            print(f"{result['count']} live window(s) match {pattern}")
        return 0 if result.get("ok") else 1

    # What geometry a pad resolves to on a given monitor, without applying
    # anything. This is the percentage-sizing answer made inspectable.
    if action == "resolve" and rest:
        pads = {pad["id"]: pad for pad in load_scratchpads()}
        pad = pads.get(rest[0])
        if pad is None:
            eprint(f"unknown scratchpad: {rest[0]}")
            return 2
        if on_niri:
            # There is no pixel answer to print here, and inventing one would be
            # the least honest thing this command could do. Niri resolves the
            # proportion and the anchor against the real output at map time, so
            # what is inspectable is the rule, not a resolved rectangle.
            relative_to = SCRATCHPAD_NIRI_ANCHORS[pad["anchor"]]
            print(json.dumps({
                "id": pad["id"],
                "compositor": "niri",
                "workspace": scratchpad_niri_workspace(pad["id"]),
                "monitor": pad["monitor"],
                "resolvedBy": "niri",
                "rule": {
                    "width": _niri_scratchpad_size_line("default-column-width", pad, "width").strip(),
                    "height": _niri_scratchpad_size_line("default-window-height", pad, "height").strip(),
                    "relativeTo": relative_to,
                    "offsetX": pad["offsetX"],
                    "offsetY": pad["offsetY"],
                    "centred": not relative_to,
                },
            }, indent=2))
            return 0
        monitors, resolved = scratchpad_monitors()
        monitor = pick_scratchpad_monitor(monitors, rest[1] if len(rest) > 1 else pad["monitor"])
        print(json.dumps({
            "id": pad["id"],
            "compositor": compositor,
            "monitor": monitor.get("name") or "",
            "monitorsResolved": resolved,
            "geometry": resolve_scratchpad_geometry(pad, monitor),
        }, indent=2))
        return 0

    if action == "release" and rest:
        parser = argparse.ArgumentParser(prog="vshell scratchpad release")
        parser.add_argument("id")
        parser.add_argument("--class-regex", default="")
        parser.add_argument("--title-exclude", default="")
        parser.add_argument("--json", action="store_true")
        args = parser.parse_args(rest)
        result = (scratchpad_release_niri(args.id, args.class_regex, args.title_exclude) if on_niri
                  else scratchpad_release(args.id, args.class_regex, args.title_exclude))
        if args.json:
            print(json.dumps(result, indent=2))
        elif not result.get("ok"):
            eprint(result.get("error") or "scratchpad release failed")
        return 0 if result.get("ok") else 1

    if action in {"toggle", "show", "hide", "preload"} and rest:
        # Both backends take the same four flags, so the CLI never has to know
        # which compositor it is talking to beyond picking the function.
        toggle_fn = scratchpad_toggle_niri if on_niri else scratchpad_toggle
        result = toggle_fn(rest[0],
                           reveal_only=(action == "show"),
                           launch_only=(action == "preload"),
                           hide_only=(action == "hide"),
                           keep_focus=("--keep-focus" in rest))
        if not result.get("ok"):
            eprint(result.get("error") or "scratchpad toggle failed")
            return 1
        if "--json" in rest:
            print(json.dumps(result, indent=2))
        return 0

    eprint(f"Unknown scratchpad action: {action}")
    return 2


def cmd_config(argv: List[str]) -> int:
    if len(argv) >= 3 and argv[0] == "repair-include":
        compositor, filename = argv[1], argv[2]
        json_output = "--json" in argv[3:]
        if compositor != "niri":
            eprint("include repair is helper-owned only for Niri configs")
            return 2
        result = _niri().ensure_niri_include(filename)
        print(json.dumps(result, indent=2) if json_output else (
            str(result.get("config") or result.get("error") or "")
        ))
        return 0 if result.get("ok") else 1
    if len(argv) >= 3 and argv[0] == "resolve-include":
        compositor, filename = argv[1], argv[2]
        conf = home() / ".config" / ("hypr/hyprland.lua" if compositor == "hyprland" else "niri/config.kdl" if compositor == "niri" else "mango/config.conf")
        if compositor == "niri":
            result = _niri().niri_include_status(filename)
            if not result.get("ok"):
                eprint(result.get("error") or "invalid Niri include")
                return 2
        else:
            result = {"exists": conf.exists(), "included": True, "configFormat": "lua" if compositor == "hyprland" else "", "readOnly": True, "path": str(conf), "includePath": filename, "statusMessage": "VGS reads this compositor config read-only; edit it directly"}
        print(json.dumps(result))
        return 0
    if len(argv) >= 2 and argv[0] == "apply-layout":
        parser = argparse.ArgumentParser(prog="vshell config apply-layout")
        parser.add_argument("compositor", choices=["hyprland", "niri"])
        parser.add_argument("--json", action="store_true")
        args = parser.parse_args(argv[1:])
        result = apply_hyprland_layout() if args.compositor == "hyprland" else _niri().apply_niri_layout()
        if args.json:
            print(json.dumps(result, indent=2))
        else:
            print("ok" if result.get("ok") else (result.get("error") or "failed"))
        return 0 if result.get("ok") else 1
    if len(argv) >= 2 and argv[0] == "apply-cursor" and argv[1] == "niri":
        result = _niri().apply_niri_cursor()
        print(json.dumps(result))
        return 0 if result.get("ok") else 1
    if len(argv) >= 3 and argv[0] == "niri-output-apply":
        try:
            config = json.loads(argv[2])
        except Exception as exc:
            eprint(f"invalid Niri output config: {exc}")
            return 2
        result = _niri().apply_niri_output(argv[1], config)
        print(json.dumps(result))
        return 0 if result.get("ok") else 1
    if len(argv) == 1 and argv[0] in {"niri-outputs-current", "niri-validate", "niri-reload"}:
        if argv[0] == "niri-outputs-current":
            result = _niri().niri_outputs_current()
        elif argv[0] == "niri-validate":
            result = _niri().niri_validate_config()
        else:
            result = _niri().niri_reload_config()
        print(json.dumps(result))
        return 0 if result.get("ok") else 1
    if len(argv) >= 2 and argv[0] in {"niri-outputs-write", "niri-outputs-validate"}:
        try:
            payload = json.loads(argv[1])
            if not isinstance(payload, dict):
                raise ValueError("payload must be an object")
            result = (_niri().niri_outputs_write(payload)
                      if argv[0] == "niri-outputs-write"
                      else _niri().niri_outputs_validate(payload))
        except (ValueError, TypeError, json.JSONDecodeError) as exc:
            eprint(f"invalid Niri outputs payload: {exc}")
            return 2
        print(json.dumps(result))
        return 0 if result.get("ok") else 1
    if len(argv) >= 3 and argv[0] == "windowrules":
        action = argv[1]
        compositor = argv[2]
        if compositor == "niri":
            try:
                if action == "list":
                    print(json.dumps(_niri().niri_windowrules_json()))
                    return 0
                if action == "add" and len(argv) >= 4:
                    result = _niri().niri_windowrule_add(json.loads(argv[3]))
                elif action == "update" and len(argv) >= 5:
                    result = _niri().niri_windowrule_update(argv[3], json.loads(argv[4]))
                elif action == "remove" and len(argv) >= 4:
                    result = _niri().niri_windowrule_remove(argv[3])
                elif action == "reorder" and len(argv) >= 4:
                    result = _niri().niri_windowrule_reorder(json.loads(argv[3]))
                else:
                    return 2
            except (ValueError, TypeError, json.JSONDecodeError) as exc:
                eprint(f"invalid Niri window rule: {exc}")
                return 2
            print(json.dumps(result))
            return 0 if result.get("ok") else 1
        if action == "list":
            message = "VGS window rule editor is read-only for this compositor config"
            print(json.dumps({
                "rules": [],
                "readOnly": True,
                "status": message,
                "vgsStatus": {
                    "exists": False,
                    "included": False,
                    "configFormat": "lua" if compositor == "hyprland" else "",
                    "readOnly": True,
                    "statusMessage": message,
                },
            }))
            return 0
        eprint("VGS window rule editing is not implemented for this config; edit your Hyprland window rules directly")
        return 1
    return 2


def _coerce_int(value: Any, default: int, lo: int, hi: int) -> int:
    try:
        parsed = int(round(float(value)))
    except Exception:
        parsed = default
    return max(lo, min(hi, parsed))


def _optional_nonnegative_int(value: Any, lo: int, hi: int) -> int | None:
    try:
        parsed = int(round(float(value)))
    except Exception:
        return None
    if parsed < 0:
        return None
    return max(lo, min(hi, parsed))


def _lua_bool(value: bool) -> str:
    return "true" if value else "false"


def _lua_string(value: Any) -> str:
    """Quote a scalar for native Hyprland Lua without ASCII-only \\u escapes."""
    return json.dumps(str(value), ensure_ascii=False)


def _lua_table(fields: Dict[str, Any], indent: str = "    ") -> List[str]:
    lines: List[str] = []
    for key, value in fields.items():
        if isinstance(value, bool):
            rendered = _lua_bool(value)
        else:
            rendered = str(value)
        lines.append(f"{indent}{key} = {rendered},")
    return lines


def _hyprland_layout_payload(settings: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
    shell_radius = _coerce_int(settings.get("cornerRadius", 15), 15, 0, 20)
    shell_border = _coerce_int(settings.get("surfaceBorderWidth", 1), 1, 0, 10)
    hypr_radius = _optional_nonnegative_int(settings.get("hyprlandLayoutRadiusOverride"), 0, 20)
    hypr_border = _optional_nonnegative_int(settings.get("hyprlandLayoutBorderSize"), 0, 10)
    target_value = settings.get("surfaceGeometryTarget")
    if target_value is None:
        target = "hyprland" if (hypr_radius is not None and hypr_radius != shell_radius) or hypr_border is not None else "sync"
    else:
        target = str(target_value or "sync")
    if target == "hyprland":
        target = "compositor"
    if target not in {"sync", "quickshell", "compositor"}:
        target = "sync"
    manage_hypr_shape = target != "quickshell"
    radius = shell_radius if target == "sync" else (hypr_radius if hypr_radius is not None else shell_radius)
    border = shell_border if target == "sync" else (hypr_border if hypr_border is not None else shell_border)

    gaps_mode = _coerce_int(settings.get("hyprlandLayoutGapsOverride", -1), -1, -2, 50)
    general: Dict[str, Any] = {}
    if gaps_mode >= 0:
        gaps_in = gaps_mode
        gaps_out_override = _optional_nonnegative_int(settings.get("hyprlandLayoutGapsOutOverride"), 0, 50)
        general["gaps_in"] = gaps_in
        general["gaps_out"] = gaps_out_override if gaps_out_override is not None else gaps_in

    if manage_hypr_shape:
        general["border_size"] = border

    resize_on_border = bool(settings.get("hyprlandResizeOnBorder", True))
    if int(settings.get("configVersion") or 0) < 15 and settings.get("hyprlandResizeOnBorder") is False:
        resize_on_border = True
    general["resize_on_border"] = resize_on_border
    general["extend_border_grab_area"] = 20 if resize_on_border else 0

    lines = [
        "-- Generated by VGS. Do not edit.",
        "-- Surface geometry is stored in VGS settings; themes only own colors and wallpapers.",
        "",
    ]
    if general or manage_hypr_shape:
        lines.append("hl.config({")
        if general:
            lines.append("  general = {")
            lines.extend(_lua_table(general, "    "))
            lines.append("  },")
        if manage_hypr_shape:
            # Groupbar tabs round at half the window radius: softened so grouped
            # tabs start to round, but visibly less than the windows. Tracks the
            # radius slider (regenerated on cornerRadius / hyprlandLayoutRadiusOverride).
            groupbar_radius = radius // 2
            lines.append("  group = {")
            lines.append("    groupbar = {")
            lines.append(f"      rounding = {groupbar_radius},")
            lines.append("    },")
            lines.append("  },")
            lines.append("  decoration = {")
            lines.append(f"    rounding = {radius},")
            lines.append("  },")
        lines.append("})")
        lines.append("")
    else:
        lines.append("-- Hyprland surface geometry is unmanaged by VGS in the current mode.")
        lines.append("")

    meta = {
        "target": target,
        "manageHyprlandShape": manage_hypr_shape,
        "radius": radius if manage_hypr_shape else None,
        "groupbarRadius": (radius // 2) if manage_hypr_shape else None,
        "border": border if manage_hypr_shape else None,
        "gaps": {k: general[k] for k in ("gaps_in", "gaps_out") if k in general},
        "resizeOnBorder": resize_on_border,
    }
    return "\n".join(lines), meta


def hyprland_layout_path() -> Path:
    return home() / ".config" / "hypr" / "vgs" / "layout.lua"


def apply_hyprland_layout() -> Dict[str, Any]:
    settings = load_settings()
    content, meta = _hyprland_layout_payload(settings)
    path = hyprland_layout_path()
    write_file(path, content)

    reload_result: Dict[str, Any] = {"attempted": False}
    if shutil.which("hyprctl") and os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
        proc = run(["hyprctl", "reload"])
        reload_result = {
            "attempted": True,
            "ok": proc.returncode == 0,
            "stdout": proc.stdout.strip(),
            "stderr": proc.stderr.strip(),
        }
    return {
        "ok": not reload_result.get("attempted") or bool(reload_result.get("ok")),
        "path": str(path),
        "layout": meta,
        "reload": reload_result,
    }


# --- Scratchpads (VGS-62) ----------------------------------------------------
# A scratchpad is an app parked on a hidden special workspace that one keybind
# slides in and out. Presentation is split across two rules that have to agree
# (a workspace rule and a window rule), and Hyprland applies workspace/float/
# size/move exactly ONCE, at map time — so any app whose class or title settles
# after mapping loses that race permanently. The generated rules below are
# therefore best-effort initial placement; `scratchpad_toggle` re-asserts the
# intended presentation on every reveal, which is what makes this robust rather
# than mostly-working. See docs/architecture/scratchpads.md.

SCRATCHPAD_ANCHORS = {
    "top-left": ("left", "top"),
    "top-center": ("center", "top"),
    "top-right": ("right", "top"),
    "center-left": ("left", "center"),
    "center": ("center", "center"),
    "center-right": ("right", "center"),
    "bottom-left": ("left", "bottom"),
    "bottom-center": ("center", "bottom"),
    "bottom-right": ("right", "bottom"),
}

SCRATCHPAD_PRESENTATIONS = ("float", "tile", "fullscreen")

# Entry animation, as a per-window-rule `animation` style.
#
# This is deliberately NOT the `specialWorkspace` animation leaf. That leaf is
# global — one value for every special workspace on the system — so emitting it
# once per pad would mean the last pad silently wins and the user's own global
# animation gets overwritten by whichever scratchpad happened to be generated
# last. A window rule is the only per-pad animation Hyprland actually has, so
# that is what a per-pad setting can honestly control; the workspace slide
# itself stays global and VGS does not touch it.
SCRATCHPAD_ANIMATIONS = {
    "slide-top": "slide top",
    "slide-bottom": "slide bottom",
    "slide-left": "slide left",
    "slide-right": "slide right",
    "fade": "fade",
    "scale": "popin 80%",
}

# A pad id becomes a Hyprland special-workspace name and a Lua identifier in the
# generated config, so it is restricted rather than escaped. Rejecting is safer
# than quoting here: an id that needs escaping to be safe is also an id nobody
# can type into `hyprctl dispatch togglespecialworkspace`.
SCRATCHPAD_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")

# Floor on a resolved pad so a mistyped 1% cannot produce a window nobody can
# see or grab. Not a ceiling — a pad legitimately fills a monitor.
SCRATCHPAD_MIN_WIDTH = 160
SCRATCHPAD_MIN_HEIGHT = 120

# Used only when the compositor cannot be asked for real monitors (no session,
# CI, `--dry-run` from a worktree). Recorded in the payload and written into the
# generated file's header so a config produced this way is never mistaken for
# one resolved against the real display.
SCRATCHPAD_FALLBACK_MONITOR = {"name": "", "x": 0, "y": 0, "width": 1920, "height": 1080, "scale": 1.0}


def scratchpad_config_path() -> Path:
    """VGS-named, alongside vgs/layout.lua, and included the same way:
    `pcall(require, "vgs.scratchpads")` in hyprland.lua."""
    return home() / ".config" / "hypr" / "vgs" / "scratchpads.lua"


def scratchpad_include_line() -> str:
    return 'pcall(require, "vgs.scratchpads")'


def normalize_scratchpad(raw: Any, problems: List[Dict[str, str]] | None = None) -> Dict[str, Any] | None:
    """One pad record, coerced to the shape the renderer and the toggle both
    assume. Returns None for anything unusable rather than emitting a partial
    rule — a scratchpad whose class regex is missing would silently capture
    nothing, or worse, everything.

    `problems` collects a reason for every rejection. Rejecting is right, but
    doing it silently is not: a pad with an uncompilable regex simply vanished
    from the generated config, so the user's scratchpad stopped working while
    Settings still showed it as configured. Every `return None` below records
    why, so the caller can name the pad instead of losing it."""
    def reject(reason: str, pad_name: str = "") -> None:
        if problems is not None:
            problems.append({"id": pad_name, "reason": reason})
        return None

    if not isinstance(raw, dict):
        return reject("not a scratchpad record")
    pad_id = str(raw.get("id") or "").strip().lower()
    label = str(raw.get("name") or raw.get("id") or "?")
    if not SCRATCHPAD_ID_RE.match(pad_id):
        return reject("id must be 1-32 characters of a-z, 0-9, '-' or '_', starting alphanumeric", label)
    class_regex = str(raw.get("classRegex") or "").strip()
    command = str(raw.get("command") or "").strip()
    if not class_regex:
        return reject("no window class pattern", label)
    if not command:
        return reject("no launch command", label)
    try:
        re.compile(class_regex)
    except re.error as exc:
        return reject(f"window class pattern does not compile: {exc}", label)

    # Same treatment as classRegex, and for the same reason: an exclusion that
    # does not compile is not "no exclusion", it is an exclusion the user asked
    # for that silently stops applying. The runtime finder would fall back to
    # matching everything with the class, so the pad would select, focus and
    # move the very windows the exclusion existed to keep out.
    title_exclude = str(raw.get("titleExclude") or "").strip()
    if title_exclude:
        try:
            re.compile(title_exclude)
        except re.error as exc:
            return reject(f"title exclusion does not compile: {exc}", label)

    size_mode = str(raw.get("sizeMode") or "percent").strip().lower()
    if size_mode not in {"percent", "pixels"}:
        size_mode = "percent"
    anchor = str(raw.get("anchor") or "top-center").strip().lower()
    if anchor not in SCRATCHPAD_ANCHORS:
        anchor = "top-center"
    animation = str(raw.get("animation") or "slide-top").strip().lower()
    if animation not in SCRATCHPAD_ANIMATIONS:
        animation = "slide-top"
    presentation = str(raw.get("presentation") or "float").strip().lower()
    if presentation not in SCRATCHPAD_PRESENTATIONS:
        presentation = "float"
    monitor = str(raw.get("monitor") or "").strip()
    if monitor and not re.fullmatch(r"[A-Za-z0-9._:-]+", monitor):
        monitor = ""

    return {
        "id": pad_id,
        "name": str(raw.get("name") or pad_id),
        "enabled": raw.get("enabled") is not False,
        "command": command,
        "appId": str(raw.get("appId") or ""),
        "classRegex": class_regex,
        "titleExclude": title_exclude,
        "keybind": str(raw.get("keybind") or "").strip(),
        "sizeMode": size_mode,
        "widthPercent": _coerce_int(raw.get("widthPercent", 60), 60, 5, 100),
        "heightPercent": _coerce_int(raw.get("heightPercent", 70), 70, 5, 100),
        "widthPixels": _coerce_int(raw.get("widthPixels", 1200), 1200, SCRATCHPAD_MIN_WIDTH, 16384),
        "heightPixels": _coerce_int(raw.get("heightPixels", 800), 800, SCRATCHPAD_MIN_HEIGHT, 16384),
        "anchor": anchor,
        "offsetX": _coerce_int(raw.get("offsetX", 0), 0, -8192, 8192),
        "offsetY": _coerce_int(raw.get("offsetY", 0), 0, -8192, 8192),
        "animation": animation,
        "presentation": presentation,
        "monitor": monitor,
        "preload": bool(raw.get("preload")),
        "dismissOnFocusLoss": bool(raw.get("dismissOnFocusLoss")),
    }


def load_scratchpads(problems: List[Dict[str, str]] | None = None) -> List[Dict[str, Any]]:
    pads: List[Dict[str, Any]] = []
    seen: set[str] = set()
    for raw in (load_settings().get("scratchpads") or []):
        pad = normalize_scratchpad(raw, problems)
        if pad is None:
            continue
        # Two pads with one id would generate two rule sets for one special
        # workspace — the exact two-owners problem the settings migration
        # refuses to create by import. First definition wins, and the loser is
        # named rather than quietly discarded.
        if pad["id"] in seen:
            if problems is not None:
                problems.append({"id": pad["name"],
                                 "reason": f"duplicate id '{pad['id']}'; the first definition wins"})
            continue
        seen.add(pad["id"])
        pads.append(pad)
    return pads


def monitor_logical_size(monitor: Dict[str, Any]) -> Tuple[int, int]:
    """Hyprland reports `width`/`height` as the physical mode and a separate
    `scale`; every coordinate a window rule or a dispatch uses is logical. A pad
    sized as a percentage of a 3840x2160 monitor at scale 2 must be a percentage
    of 1920x1080, not of the mode."""
    # NaN and infinity survive float() — `float("nan")` raises nothing, and
    # `nan <= 0` is False — so a monitor reporting a non-numeric scale used to
    # carry NaN all the way to `int(round(width / nan))`, which raises and takes
    # the whole geometry path down with it. This subsystem's rule elsewhere is
    # that a failed query is not a negative answer: degrade to a sane default
    # and say so, rather than crashing on data the compositor handed us.
    scale = 1.0
    raw_scale = monitor.get("scale")
    if raw_scale is not None:
        try:
            candidate = float(raw_scale)
        except (TypeError, ValueError):
            candidate = 0.0
        if math.isfinite(candidate) and candidate > 0:
            scale = candidate
        elif raw_scale not in (0, 0.0, "", None):
            eprint(f"monitor {monitor.get('name') or '?'}: unusable scale {raw_scale!r}; assuming 1")

    try:
        width = int(monitor.get("width") or 0)
        height = int(monitor.get("height") or 0)
    except (TypeError, ValueError, OverflowError):
        return (0, 0)
    # Hyprland rotates the logical box with the transform; odd transforms
    # (90/270 and their flipped forms) swap width and height.
    try:
        transform = int(monitor.get("transform") or 0)
    except (TypeError, ValueError):
        transform = 0
    if transform % 2 == 1:
        width, height = height, width
    return (int(round(width / scale)), int(round(height / scale)))


def resolve_scratchpad_geometry(pad: Dict[str, Any], monitor: Dict[str, Any]) -> Dict[str, int]:
    """Resolve a pad's size and position against one monitor.

    This is the whole point of storing a percentage rather than pixels: the same
    pad record produces correct geometry on a 1080p laptop panel and a 4K
    desktop monitor. Returns both monitor-local coordinates (what a window rule
    `move` wants) and global ones (what `movewindowpixel` wants)."""
    mon_w, mon_h = monitor_logical_size(monitor)
    if mon_w <= 0 or mon_h <= 0:
        mon_w, mon_h = monitor_logical_size(SCRATCHPAD_FALLBACK_MONITOR)

    if pad["sizeMode"] == "pixels":
        width, height = pad["widthPixels"], pad["heightPixels"]
    else:
        width = int(round(mon_w * pad["widthPercent"] / 100.0))
        height = int(round(mon_h * pad["heightPercent"] / 100.0))

    # Clamp to the monitor before positioning, so the anchor arithmetic below is
    # never handed a window larger than the space it is anchoring within.
    width = max(SCRATCHPAD_MIN_WIDTH, min(width, mon_w))
    height = max(SCRATCHPAD_MIN_HEIGHT, min(height, mon_h))

    horizontal, vertical = SCRATCHPAD_ANCHORS[pad["anchor"]]
    if horizontal == "left":
        x = pad["offsetX"]
    elif horizontal == "right":
        x = mon_w - width - pad["offsetX"]
    else:
        x = (mon_w - width) // 2 + pad["offsetX"]
    if vertical == "top":
        y = pad["offsetY"]
    elif vertical == "bottom":
        y = mon_h - height - pad["offsetY"]
    else:
        y = (mon_h - height) // 2 + pad["offsetY"]

    # An offset that would push the pad off the monitor is clamped rather than
    # honoured: a scratchpad you cannot see reads as a broken keybind.
    x = max(0, min(x, mon_w - width))
    y = max(0, min(y, mon_h - height))

    try:
        mon_x, mon_y = int(monitor.get("x") or 0), int(monitor.get("y") or 0)
    except (TypeError, ValueError):
        mon_x, mon_y = 0, 0

    return {
        "width": width, "height": height,
        "x": x, "y": y,
        "globalX": mon_x + x, "globalY": mon_y + y,
        "monitorWidth": mon_w, "monitorHeight": mon_h,
    }


def _scratchpad_session_ready() -> bool:
    """Whether there is a live Hyprland session to talk to.

    One seam for a condition the scratchpad paths each used to spell out. Having
    it in one place is also what lets the tests exercise those paths on a machine
    with no compositor and no `hyprctl` at all — which is every CI runner, and
    exactly where the regression coverage is wanted. A test that returns early
    because a binary is missing is a check that passes without checking."""
    return bool(shutil.which("hyprctl") and os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"))


def _hyprctl_json(*args: str) -> Any:
    """Read-only Hyprland IPC. Returns None when there is no session to ask, so
    every caller has to decide what "unknown" means rather than being handed a
    plausible-looking empty answer."""
    if not _scratchpad_session_ready():
        return None
    proc = run(["hyprctl", "-j", *args])
    if proc.returncode != 0:
        return None
    try:
        return json.loads(proc.stdout or "")
    except json.JSONDecodeError:
        return None


def scratchpad_monitors() -> Tuple[List[Dict[str, Any]], bool]:
    """(monitors, resolved). `resolved` is False when the compositor could not
    be asked — the caller then works against SCRATCHPAD_FALLBACK_MONITOR and
    must say so, because pixel geometry derived from a guessed display is the
    one output that looks authoritative and is not."""
    monitors = _hyprctl_json("monitors")
    if isinstance(monitors, list) and monitors:
        return (monitors, True)
    return ([dict(SCRATCHPAD_FALLBACK_MONITOR)], False)


def pick_scratchpad_monitor(monitors: List[Dict[str, Any]], name: str) -> Dict[str, Any]:
    """The monitor a pad is generated against. An explicitly configured output
    that is not currently connected falls back to the focused one rather than
    failing: unplugging a dock should not stop a scratchpad from opening."""
    if name:
        for monitor in monitors:
            if str(monitor.get("name") or "") == name:
                return monitor
    for monitor in monitors:
        if monitor.get("focused") is True:
            return monitor
    return monitors[0] if monitors else dict(SCRATCHPAD_FALLBACK_MONITOR)


def render_scratchpads_lua(pads: List[Dict[str, Any]], monitors: List[Dict[str, Any]],
                           monitors_resolved: bool = True) -> Tuple[str, Dict[str, Any]]:
    """Pure renderer: pads + monitors in, Lua text + metadata out. Kept free of
    IPC so the generated config can be diffed in a test without a compositor."""
    lines = [
        "-- Generated by VGS (Settings -> Scratchpads). Do not edit.",
        "-- Rules here are best-effort INITIAL placement only. Hyprland applies",
        "-- workspace/float/size/move once, at map time, so an app that renames",
        "-- itself after mapping loses that race; `vshell scratchpad toggle`",
        "-- re-asserts the intended presentation on every reveal.",
    ]
    if not monitors_resolved:
        lines.append(
            f"-- WARNING: generated without a live compositor, against a nominal "
            f"{SCRATCHPAD_FALLBACK_MONITOR['width']}x{SCRATCHPAD_FALLBACK_MONITOR['height']} "
            f"display. Re-run `vshell scratchpad apply` inside the session."
        )
    lines.append("")

    cli = shutil.which("vshell") or "vshell"
    rendered: List[Dict[str, Any]] = []
    enabled = [pad for pad in pads if pad["enabled"]]
    if not enabled:
        lines.append("-- No scratchpads are defined.")
        lines.append("")

    for pad in enabled:
        monitor = pick_scratchpad_monitor(monitors, pad["monitor"])
        geometry = resolve_scratchpad_geometry(pad, monitor)
        special = "special:" + pad["id"]
        monitor_name = str(monitor.get("name") or "")

        lines.append(f"-- {pad['name']} ({pad['id']})")

        # Workspace rule. `on_created_empty` is deliberately NOT set: it spawns
        # the app when the empty workspace is first shown, which is exactly the
        # flash-then-populate behaviour that makes the keybind feel broken. The
        # toggle launches and waits instead.
        workspace_rule: List[str] = [f"  workspace = {_lua_string(special)},"]
        if monitor_name and pad["monitor"]:
            workspace_rule.append(f"  monitor = {_lua_string(monitor_name)},")
        lines.append("hl.workspace_rule({")
        lines.extend(workspace_rule)
        lines.append("})")

        # One match, used by EVERY rule this pad emits. A window the user
        # excluded by title must be excluded from all of them: excluding it from
        # placement but not from event suppression leaves it half-owned — not in
        # the pad, but still stripped of its activation and focus requests,
        # which is worse than either owning it or leaving it alone.
        #
        # Dynamic `title`, not `initial_title`: an app that maps with a
        # placeholder title and renames itself later would be frozen on the
        # placeholder by an initial_title snapshot and never reclassified.
        match_lines = [f"    class = {_lua_string(pad['classRegex'])},"]
        if pad["titleExclude"]:
            match_lines.append(f"    title = {_lua_string('negative:' + pad['titleExclude'])},")

        # Window rule.
        lines.append("hl.window_rule({")
        lines.append("  match = {")
        lines.extend(match_lines)
        lines.append("  },")
        if monitor_name and pad["monitor"]:
            lines.append(f"  monitor = {_lua_string(monitor_name)},")
        lines.append(f"  workspace = {_lua_string(special + ' silent')},")
        lines.append("  no_initial_focus = true,")
        if pad["presentation"] == "fullscreen":
            lines.append("  fullscreen = true,")
        elif pad["presentation"] == "tile":
            lines.append("  tile = true,")
        else:
            size = "{} {}".format(geometry["width"], geometry["height"])
            move = "{} {}".format(geometry["x"], geometry["y"])
            lines.append("  float = true,")
            lines.append(f"  size = {_lua_string(size)},")
            lines.append(f"  move = {_lua_string(move)},")
        lines.append(f"  animation = {_lua_string(SCRATCHPAD_ANIMATIONS[pad['animation']])},")
        lines.append("})")

        # An app that requests activation after mapping (Spotify's XWayland
        # client is the reference case) would otherwise reveal its own hidden
        # special workspace. The toggle focuses it deliberately instead.
        lines.append("hl.window_rule({")
        lines.append("  match = {")
        lines.extend(match_lines)
        lines.append("  },")
        lines.append('  suppress_event = "activate activatefocus",')
        lines.append("})")

        if pad["keybind"]:
            toggle = f"{cli} scratchpad toggle {pad['id']}"
            lines.append(
                f"hl.bind({_lua_string(pad['keybind'])}, hl.dsp.exec_cmd({_lua_string(toggle)}), "
                f"{{ description = {_lua_string('Scratchpad: ' + pad['name'])} }})"
            )
        lines.append("")

        rendered.append({
            "id": pad["id"], "monitor": monitor_name, "keybind": pad["keybind"],
            "presentation": pad["presentation"], "geometry": geometry,
        })

    preload = [pad["id"] for pad in enabled if pad["preload"]]
    if preload:
        # Preload runs the same toggle path in a mode that launches and parks
        # without revealing, so a preloaded pad and a cold one converge on
        # identical placement instead of two code paths drifting apart.
        lines.append("-- Preload at login: launch into the hidden workspace, never reveal.")
        for pad_id in preload:
            lines.append(f"hl.exec_cmd({_lua_string(f'{cli} scratchpad preload {pad_id}')})")
        lines.append("")

    meta = {
        "count": len(rendered),
        "defined": len(pads),
        "monitorsResolved": monitors_resolved,
        "preload": preload,
        "scratchpads": rendered,
    }
    return ("\n".join(lines), meta)


def scratchpad_matching_windows(class_regex: str, title_exclude: str = "") -> Dict[str, Any]:
    """Every live window a pad's pattern claims, using the SAME selection rule
    the runtime toggle uses (class match, minus the title exclusion).

    A derived `StartupWMClass` is an exact class match, which applies to every
    current and future instance of that application — so a pad configured for a
    terminal claims every window of that terminal. Nothing surfaced that. This
    is the query behind the Settings warning; it answers "how wide is this
    pattern, right now" rather than guessing."""
    try:
        pattern = re.compile(class_regex)
    except re.error as exc:
        return {"ok": False, "error": f"pattern does not compile: {exc}", "count": 0, "windows": []}
    exclude = None
    if title_exclude:
        try:
            exclude = re.compile(title_exclude)
        except re.error as exc:
            return {"ok": False, "error": f"title exclusion does not compile: {exc}",
                    "count": 0, "windows": []}

    clients = _hyprctl_json("clients")
    if not isinstance(clients, list):
        # No session to ask. Distinct from "nothing matched": the caller must
        # not render "0 windows match" on the strength of a query that never ran.
        return {"ok": True, "known": False, "count": 0, "windows": []}

    windows = []
    for client in clients:
        if not isinstance(client, dict):
            continue
        if not pattern.search(str(client.get("class") or "")):
            continue
        if exclude is not None and exclude.search(str(client.get("title") or "")):
            continue
        windows.append({"class": client.get("class") or "",
                        "title": client.get("title") or "",
                        "address": client.get("address") or ""})
    return {"ok": True, "known": True, "count": len(windows), "windows": windows}


def scratchpad_include_status() -> Dict[str, Any]:
    """Whether hyprland.lua actually pulls the generated file in.

    VGS does not edit the user's Hyprland config — the existing stance for this
    compositor is read-only ("edit it directly"), and silently appending a
    require to a hand-maintained file would break that. So generation reports
    the one line to add and the Settings page shows it; nothing is written."""
    conf = home() / ".config" / "hypr" / "hyprland.lua"
    line = scratchpad_include_line()
    included = False
    if conf.exists():
        try:
            text = conf.read_text(errors="replace")
        except OSError:
            text = ""
        # Match any require of the module, not the exact pcall spelling: a user
        # who wrote `require("vgs.scratchpads")` has included it just as well,
        # and telling them otherwise would send them to add a duplicate.
        included = re.search(r'require\s*\(\s*["\']vgs\.scratchpads["\']', text) is not None \
            or re.search(r'pcall\s*\(\s*require\s*,\s*["\']vgs\.scratchpads["\']', text) is not None

    return {
        "path": str(conf),
        "exists": conf.exists(),
        "included": included,
        "includeLine": line,
        "readOnly": True,
        "statusMessage": (
            "VGS scratchpad rules are active."
            if included else
            f"Add {line} to {conf} for VGS scratchpads to take effect. "
            "VGS never edits your Hyprland config."
        ),
    }


def apply_scratchpads(reload: bool = True) -> Dict[str, Any]:
    problems: List[Dict[str, str]] = []
    pads = load_scratchpads(problems)
    monitors, resolved = scratchpad_monitors()
    content, meta = render_scratchpads_lua(pads, monitors, resolved)
    path = scratchpad_config_path()
    write_file(path, content)

    reload_result: Dict[str, Any] = {"attempted": False}
    if reload and _scratchpad_session_ready():
        proc = run(["hyprctl", "reload"])
        reload_result = {
            "attempted": True,
            "ok": proc.returncode == 0,
            "stderr": proc.stderr.strip(),
        }
    return {
        "ok": not reload_result.get("attempted") or bool(reload_result.get("ok")),
        "path": str(path),
        "scratchpads": meta,
        # Pads that could not be used, each with the reason. A rejected pad
        # generates no rules, so without this the user's scratchpad would stop
        # working while Settings still showed it as configured.
        "problems": problems,
        "include": scratchpad_include_status(),
        "reload": reload_result,
    }


# --- Scratchpad runtime toggle -----------------------------------------------

def _scratchpad_state_dir() -> Path:
    base = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir()
    path = Path(base) / "vshell-scratchpad"
    path.mkdir(parents=True, exist_ok=True)
    return path


@contextlib.contextmanager
def _scratchpad_lock(pad_id: str) -> Iterable[None]:
    """Keybind execs are asynchronous, so a double-press can start two toggles
    that both observe the pad as hidden, both save an origin, and race. One
    complete show/hide transition per pad at a time."""
    lock_path = _scratchpad_state_dir() / (pad_id + ".lock")
    handle = open(lock_path, "w")
    try:
        fcntl.flock(handle, fcntl.LOCK_EX)
        yield
    finally:
        try:
            fcntl.flock(handle, fcntl.LOCK_UN)
        finally:
            handle.close()


def _scratchpad_dispatch(*args: str) -> bool:
    if not _scratchpad_session_ready():
        return False
    return run(["hyprctl", "dispatch", *args]).returncode == 0


def _scratchpad_restore_target(keep_focus: bool, origin: str, current: str) -> str:
    """Which window a hide must focus afterwards.

    A KEYBIND hide returns to whatever the pad was revealed from: the user is
    dismissing the pad to get back to what they were doing. A FOCUS-LOSS
    dismissal must not — there the user has already chosen where to be, and that
    choice is what triggered the hide, so restoring the reveal origin would yank
    focus out of the window they just moved to.

    `current` is passed as "" when focus is unknown or still sits on the pad's
    own window. Both fall back to the origin: restoring to a window about to be
    hidden leaves focus on nothing, and a failed query is not a reason to strand
    it.

    Each backend gathers `origin` and `current` through its own IPC — they have
    no choice about that — but the DECISION lives here, so it cannot be right on
    one compositor and wrong on the other."""
    if keep_focus and current:
        return current
    return origin


def _scratchpad_select_owned(candidates: List[Dict[str, Any]],
                             owns: Callable[[Dict[str, Any]], bool]) -> Dict[str, Any] | None:
    """The window a pad owns, chosen from ALL of its class/title matches.

    Ownership filters the candidates BEFORE one is selected, and that order is
    the whole point. Selecting the first match and ownership-checking it
    afterwards lets a stray same-class window that happens to be listed earlier
    win the selection, fail the check, and hide the pad's real window behind it
    — release then does nothing, or moves the wrong thing.

    Both backends select through here so the rule cannot be right on one
    compositor and wrong on the other, which is how this path acquired three
    variations of the same defect (VGS-90 on Hyprland, then the Niri workspace
    target, then this ordering)."""
    for window in candidates:
        if owns(window):
            return window
    return None


def _scratchpad_find_windows(pad: Dict[str, Any]) -> List[Dict[str, Any]] | None:
    """Every window matching a pad's class/title, in the compositor's order.

    A list rather than the first hit, so a caller that also has an OWNERSHIP
    test can apply it across all of them instead of only to whichever happened
    to be listed first. See `_scratchpad_select_owned`.

    **None means "could not look", and is not the same as [].** Collapsing a
    failed query into an empty list makes "nothing matched" indistinguishable
    from "the compositor did not answer" — and `release` treats the first as a
    successful no-op that authorises DELETING the pad record. A caller that can
    act destructively has to be able to tell them apart."""
    clients = _hyprctl_json("clients")
    if not isinstance(clients, list):
        return None
    try:
        pattern = re.compile(pad["classRegex"])
    except re.error:
        # The matcher cannot be evaluated, so nothing can be said about what is
        # or is not the pad's — also an unknown, not an empty answer.
        return None
    exclude = None
    if pad["titleExclude"]:
        try:
            exclude = re.compile(pad["titleExclude"])
        except re.error:
            exclude = None
    matches: List[Dict[str, Any]] = []
    for client in clients:
        if not isinstance(client, dict):
            continue
        if not pattern.search(str(client.get("class") or "")):
            continue
        # The live title, never initialTitle: an app that maps with a
        # placeholder and renames itself later would be excluded forever by a
        # frozen snapshot instead of being picked up once its real title lands.
        if exclude is not None and exclude.search(str(client.get("title") or "")):
            continue
        matches.append(client)
    return matches


def _scratchpad_find_window(pad: Dict[str, Any]) -> Dict[str, Any] | None:
    """The first class/title match. Right for the toggle, which is looking for
    "the pad's app" before it has been placed anywhere; release wants the
    ownership-filtered selection instead.

    Unknown and empty both come back as None here, which is what the toggle
    wants — it launches the app either way, and launching one that is already
    running is recoverable. Release must not use this."""
    matches = _scratchpad_find_windows(pad)
    return matches[0] if matches else None


def _scratchpad_visibility(pad_id: str) -> Tuple[str, str]:
    """Whether a pad's special workspace is on screen: (state, monitor).

    state is "visible" (monitor names it), "hidden", or "unknown" — the last
    when the compositor could not be asked at all.

    Hidden and unknown are DIFFERENT ANSWERS and the distinction is
    load-bearing. Settings hides a pad before writing `enabled: false`, and that
    write removes the keybind; if a failed `hyprctl -j monitors` reads as
    "hidden", the hide reports success on a query that never ran and the bind is
    dropped out from under a window that may still be up. A failed query is not
    a negative answer."""
    monitors = _hyprctl_json("monitors")
    if not isinstance(monitors, list):
        return ("unknown", "")
    special = "special:" + pad_id
    for monitor in monitors:
        if isinstance(monitor, dict) and str((monitor.get("specialWorkspace") or {}).get("name") or "") == special:
            return ("visible", str(monitor.get("name") or ""))
    return ("hidden", "")


def _scratchpad_visible_monitor(pad_id: str) -> str:
    """The monitor a pad is visible on, or "" for both hidden and unknown.

    Kept for the callers whose behaviour on an unanswerable query is already the
    safe one — the reveal path treats "" as "not visible yet", so an unknown
    makes it report failure rather than false success. Anything that must not
    conflate the two calls _scratchpad_visibility directly."""
    return _scratchpad_visibility(pad_id)[1]


def _scratchpad_workspace_monitor(pad_id: str) -> str:
    workspaces = _hyprctl_json("workspaces")
    if not isinstance(workspaces, list):
        return ""
    special = "special:" + pad_id
    for workspace in workspaces:
        if isinstance(workspace, dict) and str(workspace.get("name") or "") == special:
            return str(workspace.get("monitor") or "")
    return ""


def _scratchpad_place_workspace(pad_id: str, monitor_name: str, attempts: int = 6) -> bool:
    """A cold-mapped window can create its special workspace on whichever
    monitor had focus when it mapped, even with a monitor rule set — the rule
    describes intent, not an invariant. Check, do not assume."""
    if not monitor_name:
        return True
    for _ in range(attempts):
        if _scratchpad_workspace_monitor(pad_id) == monitor_name:
            return True
        _scratchpad_dispatch("moveworkspacetomonitor", f"special:{pad_id} {monitor_name}")
        time.sleep(0.05)
    return _scratchpad_workspace_monitor(pad_id) == monitor_name


def _scratchpad_ensure_membership(pad_id: str, client: Dict[str, Any]) -> Dict[str, Any]:
    """Put the window on the pad's special workspace if it is not already there.

    This is the other half of the map-time race, and the half that decides
    whether a pad works at all. Hyprland applies the `workspace` rule once, when
    the window maps; an app whose class settles afterwards — Electron apps,
    1Password, anything with a splash — never matched the rule and so mapped
    onto whatever workspace was active at the time.

    Re-asserting only float/size/move would style that window beautifully while
    leaving it exactly where it should not be, and the reveal would show an
    empty special workspace. Membership has to be re-asserted too.

    `movetoworkspacesilent` is deliberate: the caller reveals the workspace
    itself a moment later, and the non-silent variant would switch to it here,
    fighting the placement and focus sequence that follows."""
    special = "special:" + pad_id
    current = str(((client or {}).get("workspace") or {}).get("name") or "")
    address = str((client or {}).get("address") or "")
    if not address or current == special:
        return {"moved": False, "workspace": current}
    moved = _scratchpad_dispatch("movetoworkspacesilent", f"{special},address:{address}")
    return {"moved": moved, "from": current, "workspace": special if moved else current}


def _scratchpad_reassert(pad: Dict[str, Any], address: str) -> Dict[str, Any]:
    """Re-apply the intended presentation to a window that already exists.

    This is the piece that makes scratchpads robust rather than mostly-working.
    Hyprland resolves float/size/move once at map time; an Electron app, 1Password
    or anything with a splash screen settles its class or title after that and
    loses the rule permanently. Re-asserting on every reveal is cheap and fixes
    every one of those cases without special-casing any of them.

    It is also where percentage sizing becomes multi-monitor correct: the pad is
    resolved against the monitor it is ACTUALLY on right now, not the one it was
    generated against."""
    if not address:
        return {"applied": False, "reason": "no window"}

    monitor_name = _scratchpad_visible_monitor(pad["id"]) or _scratchpad_workspace_monitor(pad["id"])
    monitors, resolved = scratchpad_monitors()
    monitor = pick_scratchpad_monitor(monitors, monitor_name or pad["monitor"])
    geometry = resolve_scratchpad_geometry(pad, monitor)
    selector = "address:" + address

    if pad["presentation"] == "fullscreen":
        _scratchpad_dispatch("fullscreenstate", f"2 -1,{selector}")
        return {"applied": True, "mode": "fullscreen", "monitor": monitor_name}

    # Everything below wants a NON-fullscreen window. Changing a mapped pad from
    # fullscreen to float or tile left the fullscreen state set, so the pad went
    # on covering its workspace and every size/move dispatch below was applied
    # to a window whose geometry fullscreen overrides — the setting changed and
    # nothing visible did. `scratchpad_release` drops it before moving a window
    # for exactly this reason.
    #
    # Unconditional rather than conditional on the window's current state: the
    # dispatch is idempotent, and reading the state back first would cost a
    # query on every reveal to save nothing.
    _scratchpad_dispatch("fullscreenstate", f"0 -1,{selector}")

    if pad["presentation"] == "tile":
        _scratchpad_dispatch("settiled", selector)
        return {"applied": True, "mode": "tile", "monitor": monitor_name}

    _scratchpad_dispatch("setfloating", selector)
    # Skip the dispatch when the window is already where it belongs, so an
    # ordinary reveal does not animate the window on every single press.
    client = _scratchpad_find_window(pad) or {}
    size = client.get("size") or [0, 0]
    at = client.get("at") or [0, 0]
    if list(size) != [geometry["width"], geometry["height"]]:
        _scratchpad_dispatch("resizewindowpixel",
                             f"exact {geometry['width']} {geometry['height']},{selector}")
    if list(at) != [geometry["globalX"], geometry["globalY"]]:
        _scratchpad_dispatch("movewindowpixel",
                             f"exact {geometry['globalX']} {geometry['globalY']},{selector}")
    return {"applied": True, "mode": "float", "monitor": monitor_name, "geometry": geometry}


def _scratchpad_wait_for_window(pad: Dict[str, Any], timeout: float) -> Dict[str, Any] | None:
    """Cold start: launch, then WAIT for the window, and only then let the
    caller reveal. Revealing first shows an empty special workspace, which reads
    as a dead keybind and is why users press it two or three times."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        client = _scratchpad_find_window(pad)
        if client:
            return client
        time.sleep(0.1)
    return None


def scratchpad_toggle(pad_id: str, reveal_only: bool = False, launch_only: bool = False,
                      hide_only: bool = False, keep_focus: bool = False,
                      timeout: float = 20.0) -> Dict[str, Any]:
    pads = {pad["id"]: pad for pad in load_scratchpads()}
    pad = pads.get(pad_id)
    if pad is None:
        return {"ok": False, "error": f"unknown scratchpad: {pad_id}"}
    if not _scratchpad_session_ready():
        return {"ok": False, "error": "no Hyprland session"}

    state_file = _scratchpad_state_dir() / (pad_id + ".focus")
    with _scratchpad_lock(pad_id):
        visible_state, visible_on = _scratchpad_visibility(pad_id)

        # `hide` asks for one direction only, and asking to hide something
        # already hidden is a no-op, not a failure. The focus watcher fires on
        # an event, so by the time it gets the lock the user may have dismissed
        # the pad themselves; reporting that as an error would turn an ordinary
        # race into a log line and a non-zero exit. Answered before the enabled
        # check because a *disabled* pad that is already hidden is equally
        # nothing to do.
        #
        # But "already hidden" has to be an ANSWER, not the absence of one. A
        # compositor that could not be asked is a different case from a pad that
        # is down, and it is NOT the ordinary race above — reporting success
        # there would tell Settings to drop the keybind for a pad that may well
        # still be on screen, which is the outcome hiding first exists to
        # prevent.
        if hide_only and visible_state == "unknown":
            return {"ok": False, "id": pad_id, "action": "hide-unknown",
                    "error": f"{pad['name']}: could not determine whether the pad is visible"}
        if hide_only and visible_state == "hidden":
            return {"ok": True, "action": "already-hidden", "id": pad_id}

        hiding = bool(visible_on) and not reveal_only and not launch_only

        # A disabled pad generates no rules and no keybind, so revealing one is
        # never what the user asked for — the enable toggle in Settings would
        # otherwise claim a mechanism it does not have, the same defect the
        # dismiss-on-focus-loss control was removed for.
        #
        # Hiding is still allowed, and deliberately so: a pad disabled while it
        # was on screen would otherwise be stranded visible with no keybind left
        # to dismiss it, which is a worse outcome than the one being fixed.
        if not pad["enabled"] and not hiding:
            return {"ok": False, "id": pad_id, "action": "disabled",
                    "error": f"{pad['name']} is disabled"}

        if visible_on and not reveal_only and not launch_only:
            # Where focus has to end up, decided BEFORE anything moves it.
            #
            # A keybind hide returns to whatever the pad was revealed from: the
            # user is dismissing the pad to get back to what they were doing.
            #
            # A focus-loss dismissal must not. There the user has already chosen
            # where they want to be — that choice is what triggered the hide —
            # so restoring the reveal origin would yank focus out of the window
            # they just moved to, which is the opposite of what dismissal is
            # for. `keep_focus` says "leave them where they are", and the target
            # is read here because the `focusmonitor` below moves focus itself.
            keep_target = ""
            if keep_focus:
                active = _hyprctl_json("activewindow")
                if isinstance(active, dict):
                    # ...unless focus is somehow still on the pad's own window.
                    # Restoring to a window we are about to hide would leave
                    # focus on nothing, or reveal the pad again.
                    on_pad = str((active.get("workspace") or {}).get("name") or "") == "special:" + pad_id
                    if not on_pad:
                        keep_target = str(active.get("address") or "")

            # The reveal origin belongs to the reveal that is now ending, and
            # leaving it behind would restore a stale window the next time this
            # pad is hidden. One store, read by both paths — neither keeps
            # bookkeeping of its own.
            #
            # READ here but CONSUMED only once the hide is confirmed, below. A
            # hide that fails leaves the pad on screen, and the next attempt
            # still needs somewhere to hand focus back to; discarding the origin
            # up front would spend it on an attempt that did not happen.
            origin = ""
            if state_file.exists():
                origin = state_file.read_text().strip()
            restore_to = _scratchpad_restore_target(keep_focus, origin, keep_target)

            # Hide, then hand focus to the target — including when the pad lives
            # on a different monitor, which is the case a naive "focus the last
            # workspace" restore gets wrong.
            #
            # The result is CHECKED, not assumed. Settings hides a pad before
            # disabling it, and only writes `enabled: false` — which regenerates
            # config and removes the keybind — once this reports success. A hide
            # that returned ok without confirming would make that ordering
            # hollow: the bind would go while the window was still on screen,
            # which is the exact outcome hiding first exists to prevent. Same
            # reasoning as the reveal path, which reads its own state back
            # rather than inferring success from the dispatches.
            hide_failures: List[str] = []
            if not _scratchpad_dispatch("focusmonitor", visible_on):
                hide_failures.append(f"could not focus monitor {visible_on}")
            if not _scratchpad_dispatch("togglespecialworkspace", pad_id):
                hide_failures.append("could not toggle the special workspace")

            # The dispatches can each report success and leave the pad on
            # screen, so the outcome is read back — and an unanswerable read-back
            # is not a confirmation. Only "hidden" counts as success.
            still_state, still_monitor = _scratchpad_visibility(pad_id)
            if still_state == "visible":
                hide_failures.append(f"special:{pad_id} is still visible on {still_monitor}")
            elif still_state == "unknown":
                hide_failures.append("could not confirm the pad came down; the compositor did not answer")

            if hide_failures:
                # The origin is deliberately NOT consumed and focus is NOT moved:
                # the pad is still up, so moving focus away now would strand the
                # user beside a window they asked to dismiss.
                return {"ok": False, "id": pad_id, "action": "hide-failed",
                        "error": f"{pad['name']}: " + "; ".join(hide_failures)}

            state_file.unlink(missing_ok=True)
            if restore_to:
                clients = _hyprctl_json("clients")
                live = isinstance(clients, list) and any(
                    isinstance(c, dict) and c.get("address") == restore_to for c in clients)
                if live:
                    _scratchpad_dispatch("focuswindow", "address:" + restore_to)
            return {"ok": True, "action": "hidden", "id": pad_id, "focusedBack": restore_to}

        client = _scratchpad_find_window(pad)
        launched = False
        if client is None:
            launched = True
            spawn_error = _scratchpad_spawn(pad)
            if spawn_error:
                return {"ok": False, "id": pad_id, "action": "launch-refused",
                        "error": spawn_error}
            client = _scratchpad_wait_for_window(pad, timeout)
            if client is None:
                # Nothing to show. Reveal anyway and the user gets an empty
                # workspace and no idea why, so say what happened instead.
                return {"ok": False, "id": pad_id, "action": "launch-timeout",
                        "error": f"{pad['name']}: no window matched {pad['classRegex']} "
                                 f"within {timeout:g}s"}

        address = str(client.get("address") or "")
        target_monitor = _scratchpad_target_monitor(pad)

        # Before anything else: a window whose class settled after mapping never
        # matched the workspace rule and is sitting on whatever workspace was
        # active then. Moving it must come first, because the special workspace
        # may not exist at all until something is on it -- and everything below
        # (placing that workspace on a monitor, revealing it) assumes it does.
        membership = _scratchpad_ensure_membership(pad_id, client)

        if launch_only:
            # Preload: park it, place it, never reveal. Membership matters even
            # more here -- a preloaded app that mapped late would otherwise sit
            # VISIBLY on the active workspace instead of being parked.
            #
            # And it is CHECKED, not assumed. Reporting success while the window
            # is still sitting on the active workspace reports the map-time race
            # this whole subsystem exists to handle as though it had been
            # handled — the one failure the user would actually see, called fine.
            placed = _scratchpad_place_workspace(pad_id, target_monitor)
            reassert = _scratchpad_reassert(pad, address)
            preload_failures: List[str] = []
            if membership.get("from") is not None and not membership.get("moved"):
                preload_failures.append(f"could not move the window onto special:{pad_id}")
            if not placed:
                preload_failures.append(f"could not place special:{pad_id} on {target_monitor}")
            if preload_failures:
                return {"ok": False, "action": "preload-failed", "id": pad_id,
                        "launched": launched, "membership": membership,
                        "reassert": reassert,
                        "error": f"{pad['name']}: " + "; ".join(preload_failures)}
            return {"ok": True, "action": "preloaded", "id": pad_id,
                    "launched": launched, "membership": membership,
                    "reassert": reassert}

        # Remember what to hand focus back to on hide — but ONLY when this call
        # actually changes visibility. `show` on a pad that is already on screen
        # reaches here too, and at that point the focused window is very often
        # the pad's own: recording it would make the next hide "restore" focus to
        # the window it just hid. An already-visible pad is a no-op for focus
        # bookkeeping, so the origin captured by the reveal that opened it
        # survives.
        if not visible_on:
            active = _hyprctl_json("activewindow")
            if isinstance(active, dict) and active.get("address"):
                state_file.write_text(str(active["address"]))

        # Every step below is checked. Reporting success after a dispatch that
        # failed means a toggle that revealed nothing is indistinguishable from
        # one that worked — the caller, and the user reading `--json`, are told
        # the pad is on screen when it is not.
        failures: List[str] = []
        if membership.get("from") is not None and not membership.get("moved"):
            failures.append(f"could not move the window onto special:{pad_id}")
        if not _scratchpad_place_workspace(pad_id, target_monitor):
            failures.append(f"could not place special:{pad_id} on {target_monitor}")
        if target_monitor and not _scratchpad_dispatch("focusmonitor", target_monitor):
            failures.append(f"could not focus monitor {target_monitor}")
        if not _scratchpad_visible_monitor(pad_id):
            if not _scratchpad_dispatch("togglespecialworkspace", pad_id):
                failures.append("could not toggle the special workspace")
        if not _scratchpad_dispatch("focuswindow", "address:" + address):
            failures.append("could not focus the window")
        reassert = _scratchpad_reassert(pad, address)

        # The dispatches can each report success and still leave the pad hidden
        # (a workspace that would not move, a compositor that ignored the
        # toggle), so the outcome is confirmed by reading the state back rather
        # than inferred from the calls.
        if not _scratchpad_visible_monitor(pad_id):
            failures.append("the special workspace is still not visible")

        if failures:
            return {"ok": False, "action": "reveal-failed", "id": pad_id,
                    "launched": launched, "membership": membership,
                    "reassert": reassert,
                    "error": f"{pad['name']}: " + "; ".join(failures)}

        return {"ok": True, "action": "revealed", "id": pad_id,
                "launched": launched, "membership": membership,
                "reassert": reassert}


def scratchpad_release(pad_id: str, class_regex: str = "", title_exclude: str = "") -> Dict[str, Any]:
    """Hand a scratchpad's window back to the user's normal workspace.

    Deleting a pad removes its keybind and every rule that pointed at its
    special workspace. A window already mapped there would be left with no way
    to reach it short of `hyprctl` by hand — invisible, but still running and
    still holding whatever was open in it.

    Moving it to the active workspace is chosen over closing it because
    removing a *configuration entry* must not destroy the user's running
    program or unsaved work, and over refusing the removal because a pad the
    user no longer wants should not become unremovable until they find and
    close its window.

    `class_regex` and `title_exclude` are both passed in so this does not depend
    on reading the pad back out of settings — the caller is about to delete it,
    and a lookup here would race that write. They travel TOGETHER for the same
    reason the generated rules share one match: releasing on the class alone
    would relocate a same-class window the user explicitly excluded from the
    pad, so deleting a scratchpad would yank an unrelated window onto their
    active workspace. `release` must own exactly the windows the placement rule
    owned, and no others."""
    if not _scratchpad_session_ready():
        return {"ok": True, "released": False, "reason": "no Hyprland session"}

    pattern = class_regex
    exclude = title_exclude
    if not pattern:
        pad = {p["id"]: p for p in load_scratchpads()}.get(pad_id)
        if pad is None:
            return {"ok": False, "error": f"unknown scratchpad: {pad_id}"}
        pattern = pad["classRegex"]
        # Only fall back for the exclusion when the class came from the same
        # lookup; mixing a passed-in class with a looked-up exclusion would pair
        # two pads' criteria.
        exclude = title_exclude or pad["titleExclude"]

    # Ownership first, then selection — the same rule the Niri backend uses, via
    # the same selector. A pad's window is the one parked on its special
    # workspace; a same-class window elsewhere is one the pad never owned, and
    # moving that to the active workspace is a surprise, not a rescue. A window
    # that is already on a normal workspace needs no release either: it is
    # reachable exactly where it is.
    special = "special:" + pad_id

    def owns(candidate: Dict[str, Any]) -> bool:
        return str((candidate.get("workspace") or {}).get("name") or "") == special

    candidates = _scratchpad_find_windows({"classRegex": pattern, "titleExclude": exclude})
    if candidates is None:
        # The window list could not be read. Reporting a successful no-op here
        # tells Settings the release is done, and Settings then DELETES the pad
        # record — so a failed IPC call would cost the user their scratchpad
        # configuration. A release that could not look has not succeeded.
        return {"ok": False, "released": False,
                "error": "could not read the window list, so nothing can be said about "
                         "this pad's window; the scratchpad was kept"}
    client = _scratchpad_select_owned(candidates, owns)
    if client is None:
        return {"ok": True, "released": False, "reason": "no window mapped"}

    address = str(client.get("address") or "")
    selector = "address:" + address

    active = _hyprctl_json("activeworkspace")
    workspace_id = (active or {}).get("id") if isinstance(active, dict) else None
    if workspace_id is None:
        return {"ok": False, "error": "could not read the active workspace"}

    # Drop fullscreen first: a window moved out still fullscreen would cover the
    # workspace it lands on, which is a worse surprise than the one being fixed.
    _scratchpad_dispatch("fullscreenstate", f"0 -1,{selector}")
    moved = _scratchpad_dispatch("movetoworkspace", f"{workspace_id},{selector}")
    return {"ok": moved, "released": moved, "address": address, "workspace": workspace_id}


def _scratchpad_target_monitor(pad: Dict[str, Any]) -> str:
    """The output a pad should open on, resolved against what is CONNECTED.

    A configured monitor name is an intent, not a guarantee: a laptop that has
    left its dock still carries `DP-1` in the pad record, and dispatching at a
    name no output answers to silently does nothing — the workspace stays
    wherever it was and the pad appears in the wrong place, or not at all.
    Falling back to the focused output is the same thing "follow focus" already
    means, so an unplugged monitor degrades to the behaviour the user would
    have picked anyway rather than to a broken keybind."""
    configured = str(pad.get("monitor") or "")
    if configured:
        monitors = _hyprctl_json("monitors")
        if isinstance(monitors, list):
            for monitor in monitors:
                if isinstance(monitor, dict) and str(monitor.get("name") or "") == configured:
                    return configured
            # Connected outputs were readable and the configured one is not
            # among them.
            eprint(f"scratchpad {pad.get('id')}: monitor {configured} is not connected; "
                   f"falling back to the focused output")
        else:
            # The monitor list could not be read at all. The configured name is
            # the best information available, so keep it rather than silently
            # relocating the pad on the strength of a failed query.
            return configured
    return _scratchpad_focused_monitor()


def _scratchpad_focused_monitor() -> str:
    monitors = _hyprctl_json("monitors")
    if not isinstance(monitors, list):
        return ""
    for monitor in monitors:
        if isinstance(monitor, dict) and monitor.get("focused") is True:
            return str(monitor.get("name") or "")
    return ""


# --- Scratchpads on Niri (VGS-83) --------------------------------------------
#
# Not a port of the Hyprland generator. Niri has no special workspaces — nothing
# that overlays the current view and hides again — so the model is a PERSISTENT
# NAMED WORKSPACE per pad plus window rules, and the toggle focuses that
# workspace and focuses back. See docs/architecture/scratchpads.md § Niri.
#
# The persisted pad record is unchanged, which was VGS-62's point: anchor +
# percentage geometry is compositor-agnostic. Niri resolves both itself
# (`proportion` for size, `relative-to` for the anchor), so this backend does
# not compute pixels at all and never needs a monitor query to render — the
# whole "generated against a nominal display" caveat the Hyprland file carries
# simply does not arise here.

# VGS anchor -> niri `relative-to`. Niri's coordinates already run INWARD from
# the named corner or edge (its own docs: with `bottom-left`, y counts upward),
# which is exactly what offsetX/offsetY mean in the Hyprland resolver, so the
# offsets carry over unchanged.
#
# "center" maps to nothing: niri has no centre `relative-to`. It does centre new
# floating windows by default, so an unoffset centre pad is emitted by OMITTING
# the position rule; a centre pad with an offset cannot be expressed and is
# reported rather than approximated.
SCRATCHPAD_NIRI_ANCHORS = {
    "top-left": "top-left",
    "top-center": "top",
    "top-right": "top-right",
    "center-left": "left",
    "center": "",
    "center-right": "right",
    "bottom-left": "bottom-left",
    "bottom-center": "bottom",
    "bottom-right": "bottom-right",
}


# Shell operators. A command carrying one was being INTERPRETED by `sh -c`
# rather than executed, which is what AGENTS.md § Backend rules forbids ("exec
# external tools with argv arrays") — and on Niri a preloaded pad runs its
# command at login, not only when the user presses the keybind.
_SCRATCHPAD_SHELL_OPERATORS = re.compile(r"(?:\|\||&&|>>|[;|&<>()$])")
# Substitutions and expansions. Unlike an operator these survive shlex as part
# of a token, and as argv they are passed through LITERALLY — safe, but not what
# the user wrote.
_SCRATCHPAD_SHELL_EXPANSION = re.compile(r"(?:\$\(|`|\$\{|\$[A-Za-z_])")


def scratchpad_launch_argv(command: str) -> Tuple[List[str], str]:
    """(argv, error) for a pad's launch command.

    Parsed with `shlex` and executed directly, so quoting still works
    (`ghostty --title="My Pad"`) but nothing is handed to a shell to interpret.

    A command using shell OPERATORS is refused rather than run either way.
    Passing `&&` to execvp as a literal argument would silently do the wrong
    thing, and keeping `sh -c` for exactly the commands where interpretation
    matters would keep the rule broken where it counts. Refusing names the
    problem instead of picking a quiet failure."""
    text = str(command or "").strip()
    if not text:
        return ([], "the launch command is empty")
    try:
        # punctuation_chars makes shlex tokenise `();<>|&` the way a shell does,
        # so `foo; bar` yields a bare `;` token instead of hiding it inside
        # `foo;` — while quoted text is still left alone. It is a lexer option,
        # not a `split()` one, so the lexer is built directly.
        lexer = shlex.shlex(text, posix=True, punctuation_chars=True)
        lexer.whitespace_split = True
        argv = list(lexer)
    except ValueError as exc:
        return ([], f"the launch command could not be parsed ({exc})")
    if not argv:
        return ([], "the launch command is empty")
    # Classified AFTER splitting, never on the raw string. `sh -c 'foo && bar'`
    # is the deliberate opt-in this very message recommends, and there the `&&`
    # is INSIDE an argument — scanning the raw text refused the one command
    # shape that is meant to work.
    for token in argv:
        if _SCRATCHPAD_SHELL_OPERATORS.fullmatch(token):
            return ([], f"the launch command uses shell syntax ({token!r}). VGS runs pad "
                        "commands directly rather than through a shell, so ask for one "
                        f"explicitly: sh -c {shlex.quote(text)}")
        if _SCRATCHPAD_SHELL_EXPANSION.search(token):
            # As argv this would be passed through literally rather than
            # expanded, which is silently not what was written.
            return ([], f"the launch command uses a shell expansion ({token!r}). VGS runs pad "
                        "commands directly, so it would be passed through literally; ask for "
                        f"a shell explicitly: sh -c {shlex.quote(text)}")
    return (argv, "")


def _scratchpad_spawn(pad: Dict[str, Any]) -> str:
    """Launch a pad's app detached, or return why it could not be launched.

    Detached and in its own session: the pad outlives this toggle process and
    must not inherit the flock fd and hold the pad's lock for its whole life."""
    argv, error = scratchpad_launch_argv(pad.get("command", ""))
    if error:
        return f"{pad.get('name') or pad.get('id')}: {error}"
    try:
        subprocess.Popen(argv,
                         start_new_session=True,
                         stdin=subprocess.DEVNULL,
                         stdout=subprocess.DEVNULL,
                         stderr=subprocess.DEVNULL,
                         close_fds=True)
    except OSError as exc:
        return f"{pad.get('name') or pad.get('id')}: could not launch {argv[0]!r} ({exc})"
    return ""


def scratchpad_niri_workspace(pad_id: str) -> str:
    """The named workspace a pad lives on. Prefixed so a VGS pad can never
    collide with a workspace the user named themselves."""
    return "vgs-" + pad_id


def scratchpad_niri_config_path() -> Path:
    return _niri().niri_config_dir() / "scratchpads.kdl"


def _kdl_comment_text(value: str) -> str:
    """User text flattened onto one line, for a `//` comment.

    Collapsing beats escaping here: a comment has no escape syntax, so a
    newline does not corrupt the comment, it ENDS it — and the remainder of the
    name becomes config the compositor tries to parse."""
    return re.sub(r"\s+", " ", str(value or "")).strip()


def _kdl_raw_string(value: str) -> str:
    """A KDL raw string, so a regex full of backslashes needs no escaping.

    Returns "" for a value that would terminate the literal early — the caller
    must reject that pad rather than emit a rule that silently means something
    else."""
    return "" if '"#' in value else 'r#"' + value + '"#'


# Constructs Python's `re` accepts that the Rust regex crate niri uses does not.
# A pattern carrying one compiles fine here and then makes niri reject the WHOLE
# config file, so the pad would not merely fail — it would take the user's entire
# Niri configuration down with it. Each entry is a construct Rust's regex crate
# documents as unsupported (it guarantees linear time, which rules out anything
# needing backtracking) paired with the name to report.
_NIRI_REGEX_UNSUPPORTED = (
    (re.compile(r"\(\?(?:=|!|<=|<!)"), "lookahead or lookbehind"),
    # Backreferences, numeric and named. `\1`-`\9`, but not `\10`+ (which Python
    # also reads as a backreference only when that many groups exist) and never
    # an escaped backslash before a digit.
    (re.compile(r"(?<!\\)\\[1-9]"), "a backreference"),
    (re.compile(r"\(\?P=", ), "a named backreference"),
    (re.compile(r"\(\?\("), "a conditional group"),
    (re.compile(r"\(\?>"), "an atomic group"),
    # Possessive quantifiers (Python 3.11+): `a*+`, `a++`, `a?+`, `a{2,3}+`.
    (re.compile(r"(?<!\\)[*+?}]\+"), "a possessive quantifier"),
    (re.compile(r"\(\?#"), "an inline comment group"),
    # Rust spells end-of-text `\z`; `\Z` is not accepted at all.
    (re.compile(r"(?<!\\)\\Z"), r"\Z (Rust spells end-of-text \z)"),
)


def _niri_regex_problem(pattern: str, what: str) -> str:
    """Why `pattern` cannot be handed to niri, or "" when it can.

    A DENY list of constructs that are decidable offline and certain to be
    rejected, not a Rust regex parser — it cannot prove a pattern good, only
    catch the ones known to be bad. That asymmetry is the honest position: this
    machine has no Niri to validate against, so the check refuses what it can
    prove wrong and says nothing about the rest."""
    if not pattern:
        return ""
    for probe, name in _NIRI_REGEX_UNSUPPORTED:
        if probe.search(pattern):
            return (f"{what} uses {name}, which Niri's regex engine does not support; "
                    "a rule containing it would make Niri reject the whole config file")
    return ""


# Hyprland-shaped modifier names (what the Settings capture records) to niri's.
SCRATCHPAD_NIRI_MODIFIERS = {
    "SUPER": "Mod", "MOD": "Mod", "META": "Mod", "WIN": "Mod", "LOGO": "Mod",
    "CTRL": "Ctrl", "CONTROL": "Ctrl",
    "ALT": "Alt", "SHIFT": "Shift",
}

# Key names to xkb keysyms. The Settings capture only ever records a single
# printable character (ScratchpadsTab.keyEventToCombo refuses anything else), so
# the punctuation rows are what it can actually produce; the named rows exist for
# a hand-edited settings.json.
SCRATCHPAD_NIRI_KEYSYMS = {
    "RETURN": "Return", "ENTER": "Return", "KP_ENTER": "KP_Enter",
    "SPACE": "space", "TAB": "Tab", "ESCAPE": "Escape", "ESC": "Escape",
    "BACKSPACE": "BackSpace", "DELETE": "Delete", "DEL": "Delete",
    "INSERT": "Insert", "HOME": "Home", "END": "End",
    "PAGE_UP": "Page_Up", "PAGEUP": "Page_Up", "PRIOR": "Page_Up",
    "PAGE_DOWN": "Page_Down", "PAGEDOWN": "Page_Down", "NEXT": "Page_Down",
    "UP": "Up", "DOWN": "Down", "LEFT": "Left", "RIGHT": "Right",
    "PRINT": "Print", "MENU": "Menu",
    ",": "comma", ".": "period", "/": "slash", "\\": "backslash",
    ";": "semicolon", "'": "apostrophe", "[": "bracketleft", "]": "bracketright",
    "-": "minus", "=": "equal", "`": "grave",
}


def scratchpad_niri_keybind(keybind: str) -> str:
    """A VGS keybind as niri spells it, or "" when it cannot be converted.

    The stored form is Hyprland-shaped — `SUPER + SHIFT, T` — because that is
    what the Settings capture writes and what the Lua backend emits verbatim.
    Niri wants `Mod+Shift+T`, so emitting the stored string unchanged produced a
    bind niri either rejects or silently never fires.

    Returns "" rather than guessing. A bind that cannot be converted is reported
    and omitted, which leaves a pad that still works through `vshell scratchpad
    toggle`; inventing a spelling could instead shadow a bind the user already
    has."""
    raw = str(keybind or "").strip()
    if not raw:
        return ""
    # The LAST separator splits modifiers from the key, not every comma: the
    # comma itself is a bindable key, and splitting on all of them turned
    # `SUPER, ,` into modifiers with nothing left to bind.
    if ", " in raw:
        head, key = raw.rsplit(", ", 1)
    elif "," in raw:
        head, key = raw.rsplit(",", 1)
    elif "+" in raw:
        # No comma: either a plain key, or a bind already written the way niri
        # spells it (`Mod+T`) by someone editing settings.json. Accept both
        # rather than reporting a bind that is already correct as unconvertible.
        head, key = raw.rsplit("+", 1)
    else:
        head, key = "", raw
    mods: List[str] = []
    for part in (piece.strip() for piece in head.split("+")):
        if not part:
            continue
        folded = part.upper()
        if folded not in SCRATCHPAD_NIRI_MODIFIERS:
            # Anything before the separator that is not a modifier means this is
            # not a shape we understand — do not guess at what was intended.
            return ""
        mod = SCRATCHPAD_NIRI_MODIFIERS[folded]
        if mod not in mods:
            mods.append(mod)
    key = key.strip()
    # A chord that is only modifiers has no key to bind.
    if not key or key.upper() in SCRATCHPAD_NIRI_MODIFIERS:
        return ""
    folded = key.upper()
    if folded in SCRATCHPAD_NIRI_KEYSYMS:
        resolved = SCRATCHPAD_NIRI_KEYSYMS[folded]
    elif key in SCRATCHPAD_NIRI_KEYSYMS:
        resolved = SCRATCHPAD_NIRI_KEYSYMS[key]
    elif re.fullmatch(r"F([1-9]|1[0-9]|2[0-4])", folded):
        resolved = folded
    elif re.fullmatch(r"[A-Za-z0-9]", key):
        resolved = key.upper()
    elif re.fullmatch(r"XF86[A-Za-z0-9_]+", key):
        # Media keys are already xkb keysyms; pass them through as written.
        resolved = key
    else:
        return ""
    # niri orders modifiers as written; keep the canonical order VGS shows.
    order = ["Mod", "Ctrl", "Alt", "Shift"]
    mods.sort(key=lambda name: order.index(name) if name in order else len(order))
    return "+".join(mods + [resolved])


def scratchpad_niri_unsupported() -> List[Dict[str, str]]:
    """Pad features this backend cannot express, as a standing list.

    Reported, never silently dropped: a setting that is stored, shown and
    ignored is the defect this subsystem has refused throughout. These are
    properties of the compositor, not of any one pad, so they are stated once
    for the backend rather than repeated per pad."""
    return [
        {
            "field": "animation",
            "reason": "Niri's window-open animation is global config (`animations { window-open ... }`), "
                      "not a per-window-rule property, so a per-pad entry animation cannot be expressed. "
                      "VGS does not overwrite your global animation to fake one.",
        },
        {
            "field": "dismissOnFocusLoss",
            "reason": "Not implemented on Niri. The focus owner VGS uses reads the Hyprland event "
                      "socket; Niri's equivalent is a different mechanism and is not wired up yet.",
        },
    ]


def render_scratchpads_kdl(pads: List[Dict[str, Any]],
                           problems: List[Dict[str, str]] | None = None) -> Tuple[str, Dict[str, Any]]:
    """Pure renderer: pads in, Niri KDL text + metadata out. No IPC, so the
    generated config can be diffed in a test without a compositor — which is the
    only way this backend can be checked at all from a Hyprland machine."""
    cli = shutil.which("vshell") or "vshell"
    lines = [
        "// Generated by VGS (Settings -> Scratchpads). Do not edit.",
        "//",
        "// Niri has no special workspaces, so each pad is a persistent NAMED",
        "// workspace plus window rules; the keybind focuses that workspace and",
        "// focuses back. A pad therefore takes a real slot in your workspace",
        "// list rather than overlaying the current view, which is the one",
        "// visible difference from the Hyprland backend.",
        "",
    ]

    rendered: List[Dict[str, Any]] = []
    unsupported: List[Dict[str, str]] = []
    enabled = [pad for pad in pads if pad["enabled"]]
    binds: List[Tuple[str, str, str]] = []

    if not enabled:
        lines.append("// No scratchpads are defined.")
        lines.append("")

    for pad in enabled:
        regex_problem = (_niri_regex_problem(pad["classRegex"], "window class pattern")
                         or _niri_regex_problem(pad["titleExclude"], "title exclusion"))
        if regex_problem:
            if problems is not None:
                problems.append({"id": pad["name"], "reason": regex_problem})
            continue
        match_value = _kdl_raw_string(pad["classRegex"])
        if not match_value:
            # Reject rather than half-emit: a rule whose match string cannot be
            # written correctly would either fail to parse or, worse, parse as
            # something narrower and quietly stop capturing the window.
            if problems is not None:
                problems.append({"id": pad["name"],
                                 "reason": 'window class pattern contains \'"#\' and cannot be '
                                           "written as a Niri raw string"})
            continue
        exclude_value = ""
        if pad["titleExclude"]:
            exclude_value = _kdl_raw_string(pad["titleExclude"])
            if not exclude_value:
                if problems is not None:
                    problems.append({"id": pad["name"],
                                     "reason": 'title exclusion contains \'"#\' and cannot be '
                                               "written as a Niri raw string"})
                continue

        workspace = scratchpad_niri_workspace(pad["id"])
        # The name is user text and the id is not: `normalize_scratchpad`
        # restricts the id precisely so it can be written unescaped, while a
        # name may contain anything. A newline in one ended the `//` comment and
        # left the rest of the name as config KDL would try to parse.
        lines.append(f"// {_kdl_comment_text(pad['name'])} ({pad['id']})")

        # The workspace. Declared, so it exists before any window is moved onto
        # it and survives the pad being empty.
        if pad["monitor"]:
            # An output that is not connected is left to niri to resolve: it
            # places the workspace somewhere real, which beats VGS guessing on
            # the strength of a monitor list it cannot read at generation time.
            lines.append(f'workspace {json.dumps(workspace)} {{')
            lines.append(f'    open-on-output {json.dumps(pad["monitor"])}')
            lines.append("}")
        else:
            lines.append(f"workspace {json.dumps(workspace)}")

        # The window rule. `match` and `exclude` are both emitted from the same
        # pair of patterns, so a window the user excluded by title is excluded
        # from every property below rather than half-owned.
        lines.append("window-rule {")
        lines.append(f"    match app-id={match_value}")
        if exclude_value:
            lines.append(f"    exclude title={exclude_value}")
        lines.append(f"    open-on-workspace {json.dumps(workspace)}")
        # The pad must not steal focus when it maps: preload opens it without
        # revealing, and the toggle focuses it deliberately a moment later.
        lines.append("    open-focused false")

        if pad["presentation"] == "fullscreen":
            lines.append("    open-fullscreen true")
        elif pad["presentation"] == "tile":
            lines.append("    open-floating false")
            lines.append(_niri_scratchpad_size_line("default-column-width", pad, "width"))
            lines.append(_niri_scratchpad_size_line("default-window-height", pad, "height"))
        else:
            lines.append("    open-floating true")
            lines.append(_niri_scratchpad_size_line("default-column-width", pad, "width"))
            lines.append(_niri_scratchpad_size_line("default-window-height", pad, "height"))
            relative_to = SCRATCHPAD_NIRI_ANCHORS[pad["anchor"]]
            if relative_to:
                lines.append(f'    default-floating-position x={pad["offsetX"]} y={pad["offsetY"]} '
                             f'relative-to={json.dumps(relative_to)}')
            elif pad["offsetX"] or pad["offsetY"]:
                # Centre + offset. Niri centres by default when no position rule
                # is given, so the pad still lands centred — but the offset the
                # user asked for is dropped, and that is said out loud.
                unsupported.append({
                    "id": pad["name"],
                    "field": "anchor offset",
                    "reason": f"anchor 'center' with an offset cannot be expressed on Niri "
                              f"(it has no centre `relative-to`); {pad['name']} is centred and the "
                              f"{pad['offsetX']},{pad['offsetY']} offset is not applied",
                })
        lines.append("}")

        niri_keybind = ""
        if pad["keybind"]:
            niri_keybind = scratchpad_niri_keybind(pad["keybind"])
            if niri_keybind:
                binds.append((niri_keybind, pad["id"], pad["name"]))
            else:
                # The pad is fine and still works through `vshell scratchpad
                # toggle`; only the bind could not be spelled. Emitting the
                # Hyprland form verbatim would leave a bind that never fires,
                # and guessing a spelling could shadow one the user already has.
                unsupported.append({
                    "id": pad["name"],
                    "field": "keybind",
                    "reason": f"the keybind {pad['keybind']!r} could not be converted to Niri's "
                              f"syntax, so no bind was written for {pad['name']}; set it by hand "
                              f"in your Niri config, or re-record it",
                })
        lines.append("")

        rendered.append({
            "id": pad["id"], "workspace": workspace, "monitor": pad["monitor"],
            "keybind": niri_keybind, "presentation": pad["presentation"],
        })

    if binds:
        lines.append("binds {")
        for keybind, pad_id, name in binds:
            action = " ".join(json.dumps(part) for part in (cli, "scratchpad", "toggle", pad_id))
            lines.append(f'    {json.dumps(keybind)} hotkey-overlay-title='
                         f'{json.dumps("Scratchpad: " + name)} {{ spawn {action}; }}')
        lines.append("}")
        lines.append("")

    # Preload is derived from the pads that were actually RENDERED, not merely
    # from the enabled ones. A pad rejected above generates no workspace, no
    # rule and no bind, so preloading it would launch its app at every login
    # into a session that has nowhere to put it — a pad refused for being
    # unusable has to be refused everywhere, not only in the half that emits
    # rules.
    rendered_ids = {entry["id"] for entry in rendered}
    preload = [pad["id"] for pad in enabled if pad["preload"] and pad["id"] in rendered_ids]
    if preload:
        # Same path as a cold toggle, in a mode that launches and parks without
        # focusing, so a preloaded pad and a cold one converge on one placement.
        lines.append("// Preload at login: launch onto the pad's workspace, never focus it.")
        for pad_id in preload:
            action = " ".join(json.dumps(part) for part in (cli, "scratchpad", "preload", pad_id))
            lines.append(f"spawn-at-startup {action}")
        lines.append("")

    meta = {
        "count": len(rendered),
        "defined": len(pads),
        "preload": preload,
        "scratchpads": rendered,
        # Per-pad settings that could not be expressed, plus the standing
        # backend-level ones. Distinct from `problems`: a pad listed here WAS
        # generated and does work, with one property dropped — reporting that as
        # a rejection would be as misleading as not reporting it at all.
        "unsupported": unsupported + scratchpad_niri_unsupported(),
    }
    return ("\n".join(lines), meta)


def _niri_scratchpad_size_line(rule: str, pad: Dict[str, Any], axis: str) -> str:
    """`proportion` for a percentage pad, `fixed` for a pixel one.

    Niri resolves the proportion against the real output, so the percentage
    stays a percentage all the way into the compositor instead of being frozen
    into pixels at generation time the way the Hyprland backend must."""
    if pad["sizeMode"] == "pixels":
        value = pad["widthPixels"] if axis == "width" else pad["heightPixels"]
        return f"    {rule} {{ fixed {int(value)}; }}"
    percent = pad["widthPercent"] if axis == "width" else pad["heightPercent"]
    return f"    {rule} {{ proportion {round(percent / 100.0, 4)}; }}"


def scratchpad_niri_include_status() -> Dict[str, Any]:
    """Whether config.kdl pulls the generated fragment in.

    Unlike the Hyprland side, VGS already manages Niri includes (with a backup)
    — that is this repo's existing stance for this compositor, not a new one
    taken here."""
    status = _niri().niri_include_status("scratchpads.kdl")
    if not status.get("ok"):
        return {
            "path": str(runtime_niri_config()),
            "exists": False,
            "included": False,
            "includeLine": 'include "vgs/scratchpads.kdl"',
            "readOnly": False,
            "statusMessage": str(status.get("error") or "Niri include state is unknown"),
        }
    return {
        "path": status.get("path", ""),
        "exists": bool(status.get("exists")),
        "included": bool(status.get("included")),
        "includeLine": 'include "vgs/scratchpads.kdl"',
        "readOnly": False,
        "statusMessage": ("VGS scratchpad rules are active."
                          if status.get("included") else
                          str(status.get("statusMessage") or "")),
    }


def runtime_niri_config() -> Path:
    return home() / ".config" / "niri" / "config.kdl"


def apply_scratchpads_niri(reload: bool = True) -> Dict[str, Any]:
    problems: List[Dict[str, str]] = []
    pads = load_scratchpads(problems)
    content, meta = render_scratchpads_kdl(pads, problems)
    path = scratchpad_niri_config_path()
    write_file(path, content)
    include = _niri().ensure_niri_include("scratchpads.kdl")

    reload_result: Dict[str, Any] = {"attempted": False}
    if reload and _niri_session_ready():
        result = _niri().niri_reload_config()
        reload_result = {
            "attempted": True,
            "ok": bool(result.get("ok")),
            "stderr": str(result.get("error") or ""),
        }
    return {
        "ok": (not reload_result.get("attempted") or bool(reload_result.get("ok")))
              and bool(include.get("ok", True)),
        "path": str(path),
        "compositor": "niri",
        "scratchpads": meta,
        "problems": problems,
        "include": scratchpad_niri_include_status(),
        "reload": reload_result,
    }


# --- Niri scratchpad runtime -------------------------------------------------

def _niri_session_ready() -> bool:
    """Whether there is a live Niri session to talk to. Same seam, and same
    reason, as `_scratchpad_session_ready` on the Hyprland side: it is what lets
    the tests drive these paths on a machine with no Niri at all."""
    return bool(shutil.which("niri") and os.environ.get("NIRI_SOCKET"))


def _niri_msg_json(*args: str) -> Any:
    """Read-only Niri IPC. Returns None when there is nothing to ask, so every
    caller has to decide what "unknown" means instead of being handed a
    plausible-looking empty answer."""
    if not _niri_session_ready():
        return None
    proc = run(["niri", "msg", "-j", *args])
    if proc.returncode != 0:
        return None
    try:
        return json.loads(proc.stdout or "")
    except json.JSONDecodeError:
        return None


def _niri_scratchpad_action(*args: str) -> bool:
    if not _niri_session_ready():
        return False
    return run(["niri", "msg", "action", *args]).returncode == 0


def _scratchpad_niri_find_windows(pad: Dict[str, Any]) -> List[Dict[str, Any]] | None:
    """Every window matching a pad's class/title, or None when the session could
    not be asked. See the Hyprland twin for why the distinction matters."""
    windows = _niri_msg_json("windows")
    if not isinstance(windows, list):
        return None
    try:
        pattern = re.compile(pad["classRegex"])
    except re.error:
        return None
    exclude = None
    if pad["titleExclude"]:
        try:
            exclude = re.compile(pad["titleExclude"])
        except re.error:
            exclude = None
    matches: List[Dict[str, Any]] = []
    for window in windows:
        if not isinstance(window, dict):
            continue
        if not pattern.search(str(window.get("app_id") or "")):
            continue
        if exclude is not None and exclude.search(str(window.get("title") or "")):
            continue
        matches.append(window)
    return matches


def _scratchpad_niri_find_window(pad: Dict[str, Any]) -> Dict[str, Any] | None:
    """The first match; unknown and empty both read as None. See the Hyprland
    twin — release must not use this."""
    matches = _scratchpad_niri_find_windows(pad)
    return matches[0] if matches else None


def _scratchpad_niri_wait_for_window(pad: Dict[str, Any], timeout: float) -> Dict[str, Any] | None:
    deadline = time.time() + timeout
    while time.time() < deadline:
        window = _scratchpad_niri_find_window(pad)
        if window is not None:
            return window
        time.sleep(0.15)
    return None


def _scratchpad_niri_workspace(pad_id: str) -> Dict[str, Any] | None:
    """The pad's workspace record, or None when it cannot be read.

    None means "unknown", never "not there": the callers below refuse to act on
    it rather than treating a failed query as a negative answer."""
    workspaces = _niri_msg_json("workspaces")
    if not isinstance(workspaces, list):
        return None
    name = scratchpad_niri_workspace(pad_id)
    for workspace in workspaces:
        if isinstance(workspace, dict) and str(workspace.get("name") or "") == name:
            return workspace
    return {}


def _scratchpad_niri_visible_output(pad_id: str) -> str:
    """The output showing the pad's workspace, or "" when it is not shown.

    Mirrors what `_scratchpad_visible_monitor` means on Hyprland: a workspace
    that is active on its output is on screen, whether or not it holds focus."""
    workspace = _scratchpad_niri_workspace(pad_id)
    if not workspace:
        return ""
    return str(workspace.get("output") or "") if workspace.get("is_active") else ""


def _scratchpad_niri_focused_window_id() -> int:
    windows = _niri_msg_json("windows")
    if not isinstance(windows, list):
        return 0
    for window in windows:
        if isinstance(window, dict) and window.get("is_focused"):
            try:
                return int(window.get("id") or 0)
            except (TypeError, ValueError):
                return 0
    return 0


def _scratchpad_niri_window_on_pad(pad_id: str, window_id: int) -> bool:
    """Whether a window is on the pad's workspace. Unknown answers False: this
    only guards "do not restore focus to the pad we are hiding", and a failed
    query there costs a fallback to the reveal origin, not a wrong move."""
    workspace = _scratchpad_niri_workspace(pad_id)
    if not workspace:
        return False
    try:
        pad_workspace_id = int(workspace.get("id") or 0)
    except (TypeError, ValueError):
        return False
    if not pad_workspace_id:
        return False
    windows = _niri_msg_json("windows")
    if not isinstance(windows, list):
        return False
    for entry in windows:
        if not isinstance(entry, dict) or entry.get("id") != window_id:
            continue
        held = entry.get("workspace_id")
        try:
            return held is not None and int(held) == pad_workspace_id
        except (TypeError, ValueError):
            return False
    return False


def _scratchpad_niri_ensure_membership(pad_id: str, window: Dict[str, Any]) -> Dict[str, Any]:
    """Move a window onto the pad's workspace if it is not already there.

    Same map-time race as Hyprland: `open-on-workspace` is applied once, when
    the window opens, so an app whose app-id settles afterwards never matched it
    and is sitting on whatever workspace was focused then. Re-asserting the
    membership is the half that matters — styling a window perfectly while
    leaving it on the wrong workspace makes the reveal show an empty pad."""
    try:
        window_id = int(window.get("id") or 0)
    except (TypeError, ValueError):
        window_id = 0
    workspace = _scratchpad_niri_workspace(pad_id)
    if workspace is None or not workspace:
        return {"moved": False, "from": None}
    try:
        target_id = int(workspace.get("id") or 0)
    except (TypeError, ValueError):
        target_id = 0
    current = window.get("workspace_id")
    if not window_id or (current is not None and target_id and int(current) == target_id):
        return {"moved": False, "from": None}
    # --focus false: the reveal focuses deliberately a moment later, and preload
    # must not steal focus at all.
    moved = _niri_scratchpad_action("move-window-to-workspace", "--window-id", str(window_id),
                                    "--focus", "false", scratchpad_niri_workspace(pad_id))
    return {"moved": moved, "from": current}


def scratchpad_toggle_niri(pad_id: str, reveal_only: bool = False, launch_only: bool = False,
                           hide_only: bool = False, keep_focus: bool = False,
                           timeout: float = 20.0) -> Dict[str, Any]:
    """Reveal/hide a pad on Niri by focusing its named workspace and back."""
    pads = {pad["id"]: pad for pad in load_scratchpads()}
    pad = pads.get(pad_id)
    if pad is None:
        return {"ok": False, "error": f"unknown scratchpad: {pad_id}"}
    if not _niri_session_ready():
        return {"ok": False, "error": "no Niri session"}

    state_file = _scratchpad_state_dir() / (pad_id + ".niri-focus")
    with _scratchpad_lock(pad_id):
        visible_on = _scratchpad_niri_visible_output(pad_id)

        # `hide` asks for one direction only, and asking to hide something
        # already hidden is a no-op, not a failure — the focus watcher fires on
        # an event and may reach the lock after the user dismissed the pad
        # themselves. Answered before the enabled check, because a disabled pad
        # that is already hidden is equally nothing to do.
        if hide_only and not visible_on:
            return {"ok": True, "action": "already-hidden", "id": pad_id}

        # Identical rule to the Hyprland backend: a disabled pad generates no
        # rules and no keybind, so revealing one claims a mechanism the enable
        # toggle does not have — but hiding one that is already on screen stays
        # allowed, or disabling a visible pad would strand it.
        if not pad["enabled"]:
            hiding = bool(visible_on) and not reveal_only and not launch_only
            if not hiding:
                return {"ok": False, "id": pad_id, "action": "disabled",
                        "error": f"{pad['name']} is disabled"}

        if visible_on and not reveal_only and not launch_only:
            # Where focus must land, read BEFORE anything moves it — the same
            # decision the Hyprland backend makes, through the same rule.
            keep_target = ""
            if keep_focus:
                focused_id = _scratchpad_niri_focused_window_id()
                if focused_id and not _scratchpad_niri_window_on_pad(pad_id, focused_id):
                    keep_target = str(focused_id)

            # READ here, CONSUMED only once the hide is confirmed, below. A hide
            # that fails leaves the pad on screen and the next attempt still
            # needs somewhere to hand focus back to; spending the origin up
            # front destroys that on the one path that needs it. Same rule the
            # Hyprland backend follows, and the same shape as release: act on
            # the record only after the thing it describes has succeeded.
            origin = ""
            if state_file.exists():
                origin = state_file.read_text().strip()
            previous = _scratchpad_restore_target(keep_focus, origin, keep_target)

            restored = False
            if previous.isdigit():
                windows = _niri_msg_json("windows")
                alive = isinstance(windows, list) and any(
                    isinstance(w, dict) and str(w.get("id")) == previous for w in windows)
                if alive:
                    restored = _niri_scratchpad_action("focus-window", "--id", previous)
            if not restored:
                # Nothing to go back to, or it is gone. Niri's own "previous
                # workspace" is the honest fallback; there is no window to
                # restore focus to.
                restored = _niri_scratchpad_action("focus-workspace-previous")
            if not restored:
                return {"ok": False, "id": pad_id, "action": "hide-failed",
                        "error": f"{pad['name']}: could not focus away from the pad's workspace"}
            # Confirmed by reading the state back, not inferred from the call:
            # an action can return zero and leave the pad exactly where it was.
            # One snapshot answers both questions below, so they cannot disagree.
            after = _niri_msg_json("workspaces")
            if not isinstance(after, list):
                # The hide cannot be confirmed. Not claimed as success — and the
                # origin is not consumed, because a retry will need it.
                return {"ok": False, "id": pad_id, "action": "hide-unconfirmed",
                        "error": f"{pad['name']}: could not read the workspace list to "
                                 f"confirm the pad was hidden"}
            name = scratchpad_niri_workspace(pad_id)
            pad_workspace = next((w for w in after if isinstance(w, dict)
                                  and str(w.get("name") or "") == name), None)
            still_on = ""
            if pad_workspace and pad_workspace.get("is_active"):
                still_on = str(pad_workspace.get("output") or "")
            focused_output = next((str(w.get("output") or "") for w in after
                                   if isinstance(w, dict) and w.get("is_focused")), "")

            if still_on:
                # The pad's workspace is STILL the active one on `still_on`.
                #
                # On one output that means the hide did not happen and the user
                # is looking straight at the pad they asked to dismiss.
                #
                # Across outputs it is simply what Niri does. There is no
                # overlay to pull away: focus moved to another output and the
                # pad's own output goes on showing its active workspace. Focus
                # having genuinely landed elsewhere is the whole of what a hide
                # can do here, so reporting failure told a multi-monitor user
                # every hide had failed — and now that Settings gates on this
                # result, a false failure is no longer harmless.
                if not focused_output or focused_output == still_on:
                    return {"ok": False, "id": pad_id, "action": "hide-failed",
                            "error": f"{pad['name']}: the pad is still displayed on {still_on}"}
                state_file.unlink(missing_ok=True)
                return {"ok": True, "action": "hidden", "id": pad_id,
                        "focusedBack": previous, "stillDisplayedOn": still_on}

            state_file.unlink(missing_ok=True)
            return {"ok": True, "action": "hidden", "id": pad_id, "focusedBack": previous}

        window = _scratchpad_niri_find_window(pad)
        launched = False
        if window is None:
            launched = True
            spawn_error = _scratchpad_spawn(pad)
            if spawn_error:
                return {"ok": False, "id": pad_id, "action": "launch-refused",
                        "error": spawn_error}
            # Wait for the window BEFORE focusing the workspace. Focusing first
            # shows an empty workspace and reads as a dead keybind, which is the
            # single-press-from-cold requirement.
            window = _scratchpad_niri_wait_for_window(pad, timeout)
            if window is None:
                return {"ok": False, "id": pad_id, "action": "launch-timeout",
                        "error": f"{pad['name']}: no window matched {pad['classRegex']} "
                                 f"within {timeout:g}s"}

        membership = _scratchpad_niri_ensure_membership(pad_id, window)

        if launch_only:
            # Checked, like the reveal path and like the Hyprland preload: a
            # window that never made it onto the pad's workspace is sitting
            # VISIBLY on the user's current one, which is the map-time race
            # reported as success.
            if membership.get("from") is not None and not membership.get("moved"):
                return {"ok": False, "action": "preload-failed", "id": pad_id,
                        "launched": launched, "membership": membership,
                        "error": f"{pad['name']}: could not move the window onto "
                                 f"{scratchpad_niri_workspace(pad_id)}"}
            return {"ok": True, "action": "preloaded", "id": pad_id,
                    "launched": launched, "membership": membership}

        focused_before = _scratchpad_niri_focused_window_id()
        if focused_before:
            state_file.write_text(str(focused_before))

        failures: List[str] = []
        if membership.get("from") is not None and not membership.get("moved"):
            failures.append(f"could not move the window onto {scratchpad_niri_workspace(pad_id)}")
        if not _niri_scratchpad_action("focus-workspace", scratchpad_niri_workspace(pad_id)):
            failures.append(f"could not focus {scratchpad_niri_workspace(pad_id)}")
        try:
            window_id = int(window.get("id") or 0)
        except (TypeError, ValueError):
            window_id = 0
        if window_id and not _niri_scratchpad_action("focus-window", "--id", str(window_id)):
            failures.append("could not focus the window")

        # Read the outcome back rather than trusting the calls: each can report
        # success and still leave the pad unfocused.
        if not _scratchpad_niri_workspace_focused(pad_id):
            failures.append("the pad's workspace is still not focused")

        if failures:
            return {"ok": False, "action": "reveal-failed", "id": pad_id,
                    "launched": launched, "membership": membership,
                    "error": f"{pad['name']}: " + "; ".join(failures)}
        return {"ok": True, "action": "revealed", "id": pad_id,
                "launched": launched, "membership": membership}


def _scratchpad_niri_workspace_focused(pad_id: str) -> bool:
    workspace = _scratchpad_niri_workspace(pad_id)
    if not workspace:
        return False
    return bool(workspace.get("is_focused"))


def scratchpad_release_niri(pad_id: str, class_regex: str = "", title_exclude: str = "") -> Dict[str, Any]:
    """Hand a pad's window back to the focused workspace before the pad is
    deleted, so it is not left on a named workspace whose declaration and
    keybind are about to disappear."""
    if not _niri_session_ready():
        return {"ok": True, "released": False, "reason": "no Niri session"}
    pad = {"classRegex": class_regex or "", "titleExclude": title_exclude or ""}
    if not pad["classRegex"]:
        pads = {item["id"]: item for item in load_scratchpads()}
        record = pads.get(pad_id)
        if record is None:
            return {"ok": False, "error": f"unknown scratchpad: {pad_id}"}
        pad = record
    # Release must own exactly the window the pad owned — the one ON the pad's
    # workspace. Matching on the class alone would pick up a same-class window
    # that was never in the pad (a second terminal, say) and yank it onto the
    # user's active workspace.
    pad_workspace = _scratchpad_niri_workspace(pad_id)
    if pad_workspace is None:
        # The workspace list could not be read. Refusing beats moving a window
        # chosen only by class: a failed query is not a negative answer.
        return {"ok": False, "released": False,
                "error": "could not read the workspace list to confirm which window belongs to the pad"}
    if not pad_workspace:
        # The pad's workspace does not exist, so nothing is parked on it.
        return {"ok": True, "released": False, "reason": "the pad's workspace does not exist"}
    try:
        pad_workspace_id = int(pad_workspace.get("id") or 0)
    except (TypeError, ValueError):
        pad_workspace_id = 0
    if not pad_workspace_id:
        return {"ok": False, "released": False,
                "error": "the pad's workspace has no usable id"}

    def owns(candidate: Dict[str, Any]) -> bool:
        held = candidate.get("workspace_id")
        try:
            return held is not None and int(held) == pad_workspace_id
        except (TypeError, ValueError):
            return False

    # Ownership filters ALL the matches, before one is picked. Selecting first
    # and checking afterwards let a stray same-class window listed earlier win
    # the selection, fail the check, and hide the pad's real window behind it.
    candidates = _scratchpad_niri_find_windows(pad)
    if candidates is None:
        # Same rule as the Hyprland backend, and for the same reason: Settings
        # deletes the pad record on a successful release, so a query that never
        # ran must not report success.
        return {"ok": False, "released": False,
                "error": "could not read the window list, so nothing can be said about "
                         "this pad's window; the scratchpad was kept"}
    window = _scratchpad_select_owned(candidates, owns)
    if window is None:
        return {"ok": True, "released": False,
                "reason": "no window of this pad is on its workspace"}

    workspaces = _niri_msg_json("workspaces")
    target = ""
    if isinstance(workspaces, list):
        for workspace in workspaces:
            if not isinstance(workspace, dict) or not workspace.get("is_focused"):
                continue
            # `idx` ONLY. niri parses a numeric workspace reference as an
            # INDEX, so falling back to the global `id` would name a different
            # workspace entirely — a plausible-looking number that moves the
            # window somewhere nobody asked for.
            index = workspace.get("idx")
            if isinstance(index, int) and index > 0:
                target = str(index)
            break
    if not target:
        # Without a destination the move would be a guess. Say so rather than
        # reporting a release that did not happen.
        return {"ok": False, "released": False,
                "error": "could not determine the focused workspace to release onto"}
    try:
        window_id = int(window.get("id") or 0)
    except (TypeError, ValueError):
        window_id = 0
    if not window_id:
        return {"ok": False, "released": False, "error": "the pad's window has no id"}
    moved = _niri_scratchpad_action("move-window-to-workspace", "--window-id", str(window_id),
                                    "--focus", "false", target)
    return {"ok": moved, "released": moved,
            "error": "" if moved else "could not move the window off the pad's workspace"}
























































def _clamp_float(value: Any, lo: float = 0.0, hi: float = 1.0) -> float:
    try:
        v = float(value)
    except Exception:
        v = lo
    return max(lo, min(hi, v))


def _hyprctl_eval(script: str) -> subprocess.CompletedProcess[str]:
    if not shutil.which("hyprctl") or not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
        return subprocess.CompletedProcess(["hyprctl", "eval"], 1, "", "hyprctl or Hyprland session not available")
    return run(["hyprctl", "eval", script])


def _hyprland_blur_support() -> Dict[str, Any]:
    if not shutil.which("hyprctl") or not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
        return {"available": False, "reason": "hyprctl or Hyprland session not available"}
    proc = _hyprctl_eval('if hl == nil or hl.layer_rule == nil then error("hl.layer_rule unavailable") end')
    if proc.returncode != 0:
        return {"available": False, "reason": (proc.stderr or proc.stdout or "hyprctl eval failed").strip()}
    return {"available": True, "reason": "hyprland layer rules available"}


def _hyprland_blur_script(enabled: bool, strength: float, glass: bool, opacity: float, mode: str = "dark") -> str:
    strength = _clamp_float(strength)
    opacity = _clamp_float(opacity, 0.08, 1.0)
    light = mode == "light"
    # Hyprland's layer blur uses global decoration.blur parameters, so the shell
    # slider maps to a conservative global blur profile, targeted by namespace.
    # Strength only scales how much the backdrop is diffused; surface opacity is
    # owned by the shell's opacity slider / glass material and must not be
    # modulated here. Glass follows Apple's material recipe shape: strong
    # saturation (~1.8x) plus a luminance pull toward the material tone — light
    # glass lifts the backdrop, dark glass sinks it so a bright window can't
    # bleed through the tint above — plus a fine grain. A tint alone can't floor
    # a bright backdrop dark; the blur does the adaptation, the tint finishes it.
    # Base blur lands near Apple's ~30px material radius.
    size = int(round((4 if glass else 6) + strength * (12 if glass else 8)))
    passes = 3 if (glass or strength >= 0.5) else 2
    ignore_alpha = round(max(0.03, min(0.80, opacity * 0.42)), 3)
    if glass:
        # Symmetric luminance pull toward the material tone: light glass lifts
        # the backdrop (dark wallpaper stays light enough for dark-on-light
        # labels), dark glass sinks it (a bright window is dragged dark enough
        # for light labels on the tint above). vibrancy_darkness deepens the
        # sink so residual color reads without muddying legibility.
        brightness = 1.18 if light else 0.50
        contrast = 0.98
        vibrancy = round((0.60 if light else 0.55) + strength * 0.15, 3)
        vibrancy_darkness = 0.0 if light else 0.25
        noise = round(0.010 + strength * 0.008, 4)
    else:
        brightness = 1.0 if light else 0.90
        contrast = 0.92
        vibrancy = round(0.15 + strength * 0.15, 3)
        vibrancy_darkness = 0.0 if light else 0.10
        noise = round(0.004 + strength * 0.005, 4)
    rule_enabled = "true" if enabled else "false"
    # A namespace belongs here only when its WHOLE surface rectangle is an
    # acceptable per-frame live-blur region. The blur pass runs over the
    # rectangle (xray = false selects the live path); ignore_alpha only masks the
    # result, so "it paints something small" is not the test. Whole-output
    # painters, the popouts' `:background` dismiss windows and
    # backdrop-less-by-design menus stay out: docs/architecture/design-language.md
    # § Popout surfaces are screen-tall, enforced by
    # scripts/check-vshell-helper.py::test_hyprland_blur_script.
    blurred_namespaces = [
        "battery",
        "bluetooth-pairing",
        "clipboard",
        "clipboard-popout",
        "color-picker",
        "confirm-modal",
        "control-center",
        "dash",
        "filebrowser",
        "input-modal",
        "keybinds",
        "layout",
        "modal",
        "mux",
        "network-info",
        "network-info-wired",
        "network-usage-popout",
        "notification-center-modal",
        "notification-center-popout",
        "notification-popup",
        "polkit-auth-surface",
        "popout",
        "power-menu",
        "power-profiles",
        "process-list-popout",
        "switch-user-modal",
        "system-update",
        "toast",
        "tooltip",
        "vgs-menu",
        "vpn",
        "wifi-password",
        "wifi-qrcode",
    ]
    namespace_pattern = "^(vshell:(" + "|".join(blurred_namespaces) + ")|vshell:plugins:[^:]+)$"
    return f"""
local rule_name = "vgs-shell-layer-blur"
if _G.VGS_SHELL_LAYER_BLUR_RULE ~= nil then
  _G.VGS_SHELL_LAYER_BLUR_RULE:set_enabled(false)
  _G.VGS_SHELL_LAYER_BLUR_RULE = nil
end
if {rule_enabled} then
  hl.config({{
    decoration = {{
      blur = {{
        enabled = true,
        size = {size},
        passes = {passes},
        ignore_opacity = true,
        new_optimizations = true,
        special = false,
        brightness = {brightness},
        contrast = {contrast},
        vibrancy = {vibrancy},
        vibrancy_darkness = {vibrancy_darkness},
        noise = {noise},
      }},
    }},
  }})
  _G.VGS_SHELL_LAYER_BLUR_RULE = hl.layer_rule({{
    name = rule_name,
    match = {{ namespace = "{namespace_pattern}" }},
    blur = true,
    blur_popups = true,
    ignore_alpha = {ignore_alpha},
    xray = false,
  }})
end
""".strip()


def _apply_hyprland_blur(enabled: bool, strength: float, glass: bool, opacity: float, mode: str = "dark") -> Dict[str, Any]:
    support = _hyprland_blur_support()
    if not support.get("available"):
        return {"ok": False, "backend": "hyprland-layer", "error": support.get("reason", "not available")}
    proc = _hyprctl_eval(_hyprland_blur_script(enabled, strength, glass, opacity, mode))
    ok = proc.returncode == 0
    return {
        "ok": ok,
        "backend": "hyprland-layer",
        "enabled": enabled,
        "strength": _clamp_float(strength),
        "glass": glass,
        "opacity": _clamp_float(opacity, 0.08, 1.0),
        "mode": "light" if mode == "light" else "dark",
        "stdout": proc.stdout.strip(),
        "stderr": proc.stderr.strip(),
    }


def cmd_battery(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell battery")
    sub = parser.add_subparsers(dest="cmd", required=True)
    p_limit = sub.add_parser("set-charge-limit")
    p_limit.add_argument("limit", type=int)
    p_limit.add_argument("--json", action="store_true")
    args = parser.parse_args(argv)

    if args.cmd == "set-charge-limit":
        limit = args.limit
        if not (20 <= limit <= 100):
            eprint("charge limit must be between 20 and 100")
            return 2
        # The limit reaches the privileged shell as a positional argument, never
        # via string interpolation.
        script = (
            'for bat in /sys/class/power_supply/BAT*; do\n'
            '  if [ -f "$bat/charge_control_limit_max" ]; then\n'
            '    echo "$1" > "$bat/charge_control_limit_max"\n'
            '  elif [ -f "$bat/charge_stop_threshold" ]; then\n'
            '    echo "$1" > "$bat/charge_stop_threshold"\n'
            '  elif [ -f "$bat/charge_control_end_threshold" ]; then\n'
            '    echo "$1" > "$bat/charge_control_end_threshold"\n'
            '  fi\n'
            'done\n'
        )
        proc = run(["pkexec", "sh", "-c", script, "sh", str(limit)])
        payload = {"ok": proc.returncode == 0, "limit": limit, "stderr": proc.stderr.strip()}
        if args.json:
            print(json.dumps(payload))
        else:
            print("ok" if payload["ok"] else (payload["stderr"] or "failed"))
        return 0 if payload["ok"] else 1
    return 2


def cmd_blur(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell blur")
    sub = parser.add_subparsers(dest="cmd", required=True)
    p_check = sub.add_parser("check")
    p_check.add_argument("--json", action="store_true")
    p_apply = sub.add_parser("apply")
    p_apply.add_argument("--enabled", choices=["true", "false", "1", "0", "yes", "no"], default="true")
    p_apply.add_argument("--strength", type=float, default=0.5)
    p_apply.add_argument("--glass", choices=["true", "false", "1", "0", "yes", "no"], default="false")
    p_apply.add_argument("--opacity", type=float, default=1.0)
    p_apply.add_argument("--mode", choices=["dark", "light"], default="dark")
    p_apply.add_argument("--json", action="store_true")
    args = parser.parse_args(argv)

    if args.cmd == "check":
        hypr = _hyprland_blur_support()
        payload = {
            "available": bool(hypr.get("available")),
            "backend": "hyprland-layer" if hypr.get("available") else "none",
            "hyprlandLayerBlur": bool(hypr.get("available")),
            # Hyprland's native layer blur is the supported VGS backend; the
            # Quickshell ext-background-effect path is intentionally not treated
            # as available on Hyprland because current Hyprland rejects it.
            "backgroundEffect": False,
            "reason": hypr.get("reason", ""),
        }
        if args.json:
            print(json.dumps(payload, indent=2))
        else:
            print("supported" if payload["available"] else "unsupported")
        return 0 if payload["available"] else 1

    if args.cmd == "apply":
        enabled = args.enabled in ("true", "1", "yes")
        glass = args.glass in ("true", "1", "yes")
        result = _apply_hyprland_blur(enabled, args.strength, glass, args.opacity, args.mode)
        if args.json:
            print(json.dumps(result, indent=2))
        else:
            print("ok" if result.get("ok") else (result.get("error") or "failed"))
        return 0 if result.get("ok") else 1
    return 2


GREETER_CACHE_DEFAULT = Path("/var/cache/vshell-greeter")
GREETD_CONFIG = Path("/etc/greetd/config.toml")


def validate_greeter_cache_dir(path: Path, privileged: bool) -> Path:
    raw = Path(os.path.expanduser(str(path)))
    if raw.exists() and raw.is_symlink():
        raise RuntimeError(f"Refusing symlink greeter cache path: {raw}")
    expanded = raw.resolve(strict=False)
    default = GREETER_CACHE_DEFAULT.resolve(strict=False)
    if not privileged:
        return expanded
    if os.environ.get("VSHELL_ALLOW_UNSAFE_GREETER_CACHE") == "1":
        return expanded
    if expanded != default:
        raise RuntimeError(f"Refusing privileged greeter sync outside {default}. Set VSHELL_ALLOW_UNSAFE_GREETER_CACHE=1 only for controlled tests.")
    return expanded


def current_login_user() -> str:
    for key in ("SUDO_USER", "USER", "LOGNAME"):
        value = os.environ.get(key, "").strip()
        if value and value != "root":
            return value
    try:
        return pwd.getpwuid(os.getuid()).pw_name
    except Exception:
        return "root"


def user_home_dir(username: str) -> Path:
    try:
        return Path(pwd.getpwnam(username).pw_dir)
    except Exception:
        return home()


def greeter_identity(allow_root: bool = False) -> Tuple[str, int, str, int]:
    user = ""
    uid = -1
    group = ""
    gid = -1
    for candidate in ("greeter", "greetd", "_greeter"):
        try:
            pw = pwd.getpwnam(candidate)
            user, uid = candidate, pw.pw_uid
            group, gid = candidate, pw.pw_gid
            break
        except KeyError:
            pass
    for candidate in ("greeter", "greetd", "_greeter"):
        try:
            gr = grp.getgrnam(candidate)
            group, gid = candidate, gr.gr_gid
            break
        except KeyError:
            pass
    if not user:
        if allow_root:
            return "root", 0, "root", 0
        raise RuntimeError("No dedicated greeter user found. Install/configure greetd with a greeter/greetd/_greeter account before running `vshell greeter sync`.")
    if gid < 0:
        gid = pwd.getpwnam(user).pw_gid
        group = user
    return user, uid, group, gid


def ensure_root_for(argv: List[str], terminal: bool = False) -> int | None:
    """Return process exit when re-execed, or None when already root."""
    if os.geteuid() == 0:
        return None
    helper_path = str(Path(__file__).resolve())
    cleaned = [a for a in argv if a != "--terminal"]
    cmd = [sys.executable, helper_path, *cleaned]
    if terminal:
        return launch_terminal(["sudo", *cmd])
    proc = subprocess.run(["sudo", "-n", *cmd], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    if proc.stdout:
        print(proc.stdout, end="")
    if proc.stderr:
        print(proc.stderr, end="", file=sys.stderr)
    return proc.returncode


# VGS has exactly one terminal resolver and it is this section. QML, plugin JS,
# bash and the Go backend all reach it through `vshell terminal ...` instead of
# invoking a terminal — or `xdg-terminal-exec` — themselves, so the ordering
# below is the only ordering in the tree. See
# docs/architecture/overlay-and-dependencies.md § Terminal and file-manager
# resolution.
TERMINAL_CANDIDATES = ("ghostty", "kitty", "alacritty", "foot", "wezterm", "konsole", "gnome-terminal", "xterm")

# Per-terminal argv shape.
#   subcommand - tokens that must follow the executable before any option.
#                WezTerm is the one that needs this: its launcher is
#                `wezterm start [options] -- cmd`, and plain `wezterm -e` is not
#                a valid invocation.
#   app_id_flag- how the terminal is told which app-id/class to use. A flag
#                ending in "=" is joined to the value, anything else is passed
#                as its own argv entry, and None means the terminal has no
#                equivalent so the requested app-id is dropped rather than
#                handed over as an option the terminal will reject.
#   exec_flags - what separates the terminal's own options from the command to
#                run in it.
TERMINAL_SPECS: Dict[str, Dict[str, Any]] = {
    "xdg-terminal-exec": {"subcommand": [], "app_id_flag": "--app-id=", "exec_flags": ["--"]},
    "ghostty": {"subcommand": [], "app_id_flag": "--class=", "exec_flags": ["-e"]},
    "kitty": {"subcommand": [], "app_id_flag": "--class=", "exec_flags": ["-e"]},
    "alacritty": {"subcommand": [], "app_id_flag": "--class=", "exec_flags": ["-e"]},
    "foot": {"subcommand": [], "app_id_flag": "--app-id=", "exec_flags": ["-e"]},
    "wezterm": {"subcommand": ["start"], "app_id_flag": "--class=", "exec_flags": ["--"]},
    "konsole": {"subcommand": [], "app_id_flag": None, "exec_flags": ["-e"]},
    "gnome-terminal": {"subcommand": [], "app_id_flag": None, "exec_flags": ["--"]},
    "xterm": {"subcommand": [], "app_id_flag": "-class", "exec_flags": ["-e"]},
}

TERMINAL_DEFAULT_SPEC: Dict[str, Any] = {"subcommand": [], "app_id_flag": None, "exec_flags": ["-e"]}

# The app-id VGS asks for when it opens a TUI, which compositor rules float.
TERMINAL_TUI_APP_ID = "TUI.float"

# How long to watch a freshly spawned terminal before treating it as launched.
# A terminal that cannot take `-e sh -lc`, or cannot reach the display, dies
# immediately; without this the caller reports success for a window that never
# appeared, which is the silent-failure class VGS-11 exists to remove.
TERMINAL_SETTLE_SECONDS = 0.75

# launch_terminal: a candidate was spawned but died immediately, so no window
# ever appeared. Distinct from 1 (no terminal installed at all) and from
# SUDO_TOGGLE_EXIT_STALE, which callers must not confuse with a launch failure.
TERMINAL_EXIT_FAILED = 4


def xdg_config_dirs() -> List[Path]:
    config_home = os.environ.get("XDG_CONFIG_HOME", "").strip()
    dirs = [Path(config_home).expanduser() if config_home else home() / ".config"]
    extra = os.environ.get("XDG_CONFIG_DIRS", "").strip() or "/etc/xdg"
    dirs.extend(Path(part) for part in extra.split(":") if part)
    return dirs


def xdg_data_dirs() -> List[Path]:
    data_home = os.environ.get("XDG_DATA_HOME", "").strip()
    dirs = [Path(data_home).expanduser() if data_home else home() / ".local" / "share"]
    extra = os.environ.get("XDG_DATA_DIRS", "").strip() or "/usr/local/share:/usr/share"
    dirs.extend(Path(part) for part in extra.split(":") if part)
    return dirs


def desktop_entry_path(entry_id: str) -> Path | None:
    """Locate a .desktop file by id, including `vendor-name.desktop` subdirs."""
    name = entry_id if entry_id.endswith(".desktop") else entry_id + ".desktop"
    for base in xdg_data_dirs():
        candidate = base / "applications" / name
        if candidate.is_file():
            return candidate
        # Entry ids may encode a subdirectory as "vendor-app.desktop".
        if "-" in name:
            vendor, _, rest = name.partition("-")
            nested = base / "applications" / vendor / rest
            if nested.is_file():
                return nested
    return None


def desktop_entry_fields(path: Path) -> Dict[str, str]:
    """Key/value pairs of a .desktop file's [Desktop Entry] group."""
    fields: Dict[str, str] = {}
    in_group = False
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return fields
    for line in text.splitlines():
        stripped = line.strip()
        if stripped.startswith("[") and stripped.endswith("]"):
            in_group = stripped == "[Desktop Entry]"
            continue
        if not in_group or not stripped or stripped.startswith("#"):
            continue
        key, sep, value = stripped.partition("=")
        if sep:
            fields.setdefault(key.strip(), value.strip())
    return fields


def desktop_entry_command(entry_id: str) -> List[str]:
    """Executable argv for a desktop entry id, or [] when it cannot be run.

    Field codes (%f, %U, …) are dropped: callers append their own target.
    """
    path = desktop_entry_path(entry_id)
    if not path:
        return []
    fields = desktop_entry_fields(path)
    try_exec = fields.get("TryExec", "").strip()
    if try_exec and not (shutil.which(try_exec) or Path(try_exec).exists()):
        return []
    try:
        parts = shlex.split(fields.get("Exec", ""))
    except ValueError:
        return []
    argv = [part for part in parts if not re.fullmatch(r"%[a-zA-Z]", part)]
    if not argv or not (shutil.which(argv[0]) or Path(argv[0]).exists()):
        return []
    return argv


def xdg_terminals_list() -> List[str]:
    """Desktop entry ids from xdg-terminals.list, most preferred first.

    This is the file Settings -> Default Apps -> Terminal writes, and the same
    file `xdg-terminal-exec` reads. VGS parses it directly so the user's choice
    is still honoured when that AUR-only binary is not installed.
    """
    entries: List[str] = []
    for base in xdg_config_dirs():
        try:
            text = (base / "xdg-terminals.list").read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        for line in text.splitlines():
            value = line.strip()
            if value and not value.startswith("#") and value not in entries:
                entries.append(value)
    return entries


def session_terminal_override() -> List[str]:
    """`terminalOverride` from session.json: the Settings terminal picker."""
    try:
        data = json.loads((state_dir() / "session.json").read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return []
    value = str((data or {}).get("terminalOverride", "") or "").strip()
    if not value:
        return []
    try:
        return shlex.split(value)
    except ValueError:
        return []


def terminal_candidates(prefer: List[str] | None = None) -> List[List[str]]:
    """Terminal argv prefixes to try, most preferred first.

    Order, and why:
      0. prefer              - a terminal the caller resolved itself and must
                               not have silently discarded (the backend's
                               `upgradeParams.terminal`).
      1. terminalOverride    - the VGS setting, an explicit choice the user made
                               in Settings, so it outranks everything inherited.
      2. $TERMINAL           - a per-session override.
      3. xdg-terminal-exec   - implements the whole XDG terminal spec when the
                               user has it; AUR-only, so never required.
      4. xdg-terminals.list  - the same user choice, read directly, so Settings
                               -> Default Apps -> Terminal works without (3).
      5. installed terminals - so a default install with none of the above
                               still opens a window instead of failing.
    """
    candidates: List[List[str]] = []
    if prefer:
        candidates.append(list(prefer))
    override = session_terminal_override()
    if override:
        candidates.append(override)
    configured = os.environ.get("TERMINAL", "").strip()
    if configured:
        try:
            parsed = shlex.split(configured)
        except ValueError:
            parsed = []
        if parsed:
            candidates.append(parsed)
    if shutil.which("xdg-terminal-exec"):
        candidates.append(["xdg-terminal-exec"])
    for entry_id in xdg_terminals_list():
        argv = desktop_entry_command(entry_id)
        if argv:
            candidates.append(argv)
    for term in TERMINAL_CANDIDATES:
        if shutil.which(term):
            candidates.append([term])
    resolved: List[List[str]] = []
    for base in candidates:
        if not base or not (shutil.which(base[0]) or Path(base[0]).exists()):
            continue
        if base not in resolved:
            resolved.append(base)
    return resolved


def have_terminal() -> bool:
    return bool(terminal_candidates())


def terminal_spec(executable: str) -> Dict[str, Any]:
    return TERMINAL_SPECS.get(Path(executable).name, TERMINAL_DEFAULT_SPEC)


# How long to give the one-off `uwsm app -- true` probe below.
APP_SCOPE_PROBE_SECONDS = 5

_app_scope_usable: bool | None = None


def app_scope_prefix() -> List[str]:
    """`uwsm app --` when the session can actually use it, else nothing.

    Launching into a systemd scope is an enhancement, never a requirement:
    hardcoding the prefix is what made every VGS terminal action fail with
    `command not found` on installs without uwsm.

    Presence is not usability — uwsm can be installed while the current session
    has no user manager to attach a scope to. That is settled once, up front,
    with a no-op command, because the alternative (launch the payload, see it
    die, launch it again without the prefix) would run the user's command twice.
    """
    global _app_scope_usable
    if os.environ.get("VSHELL_NO_APP_SCOPE"):
        return []
    if not Path("/run/systemd/system").exists():
        return []
    uwsm = shutil.which("uwsm")
    if not uwsm:
        return []
    if _app_scope_usable is None:
        try:
            probe = run([uwsm, "app", "--", "true"], timeout=APP_SCOPE_PROBE_SECONDS)
            _app_scope_usable = probe.returncode == 0
        except (OSError, subprocess.SubprocessError):
            _app_scope_usable = False
    return [uwsm, "app", "--"] if _app_scope_usable else []


def notify_user(title: str, details: str = "") -> None:
    """Put a failure in front of the user when the caller cannot see it.

    Every VGS terminal call site launches through `Quickshell.execDetached`,
    which discards stdout, stderr and the exit status — so printing here reaches
    nobody, and a broken button looks like a button that does nothing. The
    shell's own toast IPC is the channel that survives detachment; `notify-send`
    is the fallback for when the shell is not the caller.
    """
    eprint(f"{title}: {details}" if details else title)
    cli = shutil.which("vshell") or str(repo_root() / "bin" / "vshell")
    try:
        proc = run([cli, "ipc", "call", "toast", "errorWith", title, details, "", "terminal"],
                   timeout=5)
        if proc.returncode == 0 and "SUCCESS" in (proc.stdout or ""):
            return
    except (OSError, subprocess.SubprocessError):
        pass
    notify = shutil.which("notify-send")
    if not notify:
        return
    with contextlib.suppress(OSError, subprocess.SubprocessError):
        run([notify, "--app-name", "VGS", "-u", "critical", title, details], timeout=5)


def terminal_argv(base: List[str], cmd: List[str], app_id: str = "") -> List[str]:
    """Full argv for running `cmd` in the terminal described by `base`."""
    spec = terminal_spec(base[0])
    argv = [base[0], *spec.get("subcommand", []), *base[1:]]
    flag = spec.get("app_id_flag")
    if app_id and flag:
        if flag.endswith("="):
            argv.append(flag + app_id)
        else:
            argv.extend([flag, app_id])
    if cmd:
        argv.extend(spec.get("exec_flags") or ["-e"])
        argv.extend(cmd)
    return argv


def terminal_hold_script(cmd: List[str]) -> List[str]:
    quoted = " ".join(shlex.quote(part) for part in cmd)
    script = f"{quoted}; code=$?; echo; echo 'VGS command exited with status '$code'. Press Enter to close.'; read _; exit $code"
    return ["sh", "-lc", script]


def spawn_terminal(
    cmd: List[str],
    app_id: str = "",
    hold: bool = False,
    detach: bool = False,
    wait: bool = False,
    prefer: List[str] | None = None,
    notify: bool = False,
    what: str = "VGS command",
) -> int:
    """Run `cmd` (or an interactive shell when empty) in the resolved terminal.

    The single spawn path behind `vshell terminal` and `launch_terminal()`.

    `wait` blocks until the terminal exits rather than returning as soon as its
    window is up. Callers that treat this process's exit as "the command
    finished" — the backend's upgrade supervisor does — must pass it, or they
    will call a running package-manager transaction complete.

    `notify` puts a failure in front of the user instead of only on stderr. Set
    it whenever the caller cannot see this process's output, which is every
    `Quickshell.execDetached` call site.
    """
    payload = terminal_hold_script(cmd) if (cmd and hold) else list(cmd)
    scope = app_scope_prefix()
    # A fast non-zero exit only means "the terminal failed" when the payload
    # cannot itself exit fast, which is exactly what the hold wrapper
    # guarantees. Without it the status is ambiguous, and trying the next
    # candidate would re-run the user's command once per installed terminal.
    retry_next_candidate = hold or not cmd
    tried: List[str] = []
    for base in terminal_candidates(prefer):
        full = [*scope, *terminal_argv(base, payload, app_id)]
        try:
            proc = subprocess.Popen(full, start_new_session=detach)
        except Exception:
            # The exec itself failed, which is unambiguously this candidate's
            # fault and cannot have run the payload. Always move on.
            continue
        tried.append(base[0])
        try:
            # Popen only proves the exec succeeded. Give the terminal a moment
            # to fail on its own (bad -e handling, no display) before treating
            # the window as up.
            proc.wait(timeout=TERMINAL_SETTLE_SECONDS)
        except subprocess.TimeoutExpired:
            if not wait:
                return 0  # still running: the window is up
            return proc.wait()  # caller needs the command's whole lifetime
        if proc.returncode == 0:
            return 0  # exited cleanly and fast; nothing to distrust
        if not retry_next_candidate:
            # The payload ran; this is its status, not a launch failure.
            return proc.returncode
    if tried:
        message = f"Terminal exited immediately for {what} (tried: " + ", ".join(tried) + ")"
        if notify:
            notify_user("VGS could not open a terminal", message)
        else:
            eprint(message)
        return TERMINAL_EXIT_FAILED
    detail = ("Install a terminal, set $TERMINAL, or pick one in "
              "Settings -> Default Apps -> Terminal. VGS looks for: "
              + ", ".join(TERMINAL_CANDIDATES))
    if notify:
        notify_user("No terminal found", detail)
    else:
        eprint(f"No terminal found for {what}. " + detail)
    return 1


def launch_terminal(cmd: List[str]) -> int:
    return spawn_terminal(cmd, hold=True, what="privileged VGS command")


# Same rule as terminals: one file-manager resolver, and it asks the XDG
# default-apps layer (the one Settings -> Default Apps -> File Manager writes
# through `xdg-mime`) before falling back to whatever is installed.
FILE_MANAGER_CANDIDATES = ("nautilus", "dolphin", "thunar")

FILE_MANAGER_MIME = "inode/directory"


def file_manager() -> Dict[str, Any]:
    """The user's file manager: {"argv", "source", "name"}, or {} when none."""
    if shutil.which("xdg-mime"):
        try:
            proc = run(["xdg-mime", "query", "default", FILE_MANAGER_MIME], timeout=5)
            entry_id = (proc.stdout or "").strip().splitlines()
        except (OSError, subprocess.SubprocessError):
            entry_id = []
        for candidate in entry_id:
            entry = candidate.strip()
            argv = desktop_entry_command(entry)
            if not argv:
                continue
            path = desktop_entry_path(entry)
            fields = desktop_entry_fields(path) if path else {}
            return {
                "argv": argv,
                "source": "xdg-mime",
                "entry": entry,
                "name": fields.get("Name", "") or Path(argv[0]).name,
                # A TUI file manager (yazi, ranger, lf) is a legitimate default
                # here, and launching it without a terminal opens nothing.
                "terminal": fields.get("Terminal", "").strip().lower() == "true",
            }
    for name in FILE_MANAGER_CANDIDATES:
        if shutil.which(name):
            return {"argv": [name], "source": "installed", "entry": "", "name": name,
                    "terminal": False}
    return {}


def safe_toml_string(value: str) -> str:
    return json.dumps(value)


def backup_path(path: Path) -> Path:
    return path.with_name(path.name + f".bak-{int(time.time())}")


def write_root_file(path: Path, content: str, mode: int = 0o644, gid: int | None = None) -> None:
    if path.exists() and path.is_symlink():
        raise RuntimeError(f"Refusing to overwrite symlink: {path}")
    path.parent.mkdir(parents=True, exist_ok=True)
    if path.exists():
        old = path.read_text(errors="ignore")
        if old == content:
            os.chmod(path, mode)
            if gid is not None:
                os.chown(path, 0, gid)
            return
        shutil.copy2(path, backup_path(path))
    tmp = path.with_name(path.name + f".tmp-{os.getpid()}")
    tmp.write_text(content)
    os.chmod(tmp, mode)
    if gid is not None:
        os.chown(tmp, 0, gid)
    tmp.replace(path)


def ensure_cache_dir(path: Path, gid: int, run_uid: int | None = None) -> None:
    if path.exists() and path.is_symlink():
        raise RuntimeError(f"Refusing symlink cache directory: {path}")
    path.mkdir(parents=True, exist_ok=True)
    os.chown(path, 0, gid)
    os.chmod(path, 0o2770)
    local_state = path / ".local" / "state"
    local_share = path / ".local" / "share"
    local_cache = path / ".cache"
    run_dir = path / "run"
    for sub in (local_state, local_share, local_cache, run_dir, path / "users"):
        if sub.exists() and sub.is_symlink():
            raise RuntimeError(f"Refusing symlink cache directory: {sub}")
        sub.mkdir(parents=True, exist_ok=True)
        os.chown(sub, run_uid if sub == run_dir and run_uid is not None else 0, gid)
        os.chmod(sub, 0o2770 if sub != run_dir else 0o700)


def write_json_file(path: Path, data: Dict[str, Any], gid: int, mode: int = 0o660) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    content = json.dumps(data, indent=2, sort_keys=False) + "\n"
    write_root_file(path, content, mode=mode, gid=gid)


def load_json_file(path: Path, fallback: Dict[str, Any] | None = None) -> Dict[str, Any]:
    try:
        return json.loads(path.read_text())
    except Exception:
        return dict(fallback or {})


def load_required_json_file(path: Path) -> Dict[str, Any]:
    try:
        return json.loads(path.read_text())
    except FileNotFoundError:
        raise RuntimeError(f"required JSON file not found: {path}")
    except json.JSONDecodeError as exc:
        raise RuntimeError(f"invalid JSON in {path}: {exc}")
    except OSError as exc:
        raise RuntimeError(f"cannot read {path}: {exc}")


def current_theme_json() -> Dict[str, Any]:
    path = cfg_dir() / "theme.json"
    if path.exists():
        return load_required_json_file(path)
    return current_theme()


def current_session_json(theme: Dict[str, Any]) -> Dict[str, Any]:
    path = state_dir() / "session.json"
    data = load_json_file(path)
    if not data:
        data = {"wallpaperPath": theme.get("wallpaper", "")}
    elif "wallpaperPath" not in data and theme.get("wallpaper"):
        data["wallpaperPath"] = theme.get("wallpaper")
    return data


def strip_desktop_exec(exec_line: str) -> str:
    parts = shlex.split(exec_line, posix=True) if exec_line else []
    clean = [p for p in parts if not (p.startswith("%") and len(p) <= 3)]
    return " ".join(shlex.quote(p) for p in clean)


def parse_desktop_file(path: Path) -> Dict[str, str] | None:
    try:
        lines = path.read_text(errors="ignore").splitlines()
    except Exception:
        return None
    name = ""
    exec_line = ""
    in_entry = False
    for line in lines:
        line = line.strip()
        if line == "[Desktop Entry]":
            in_entry = True
            continue
        if line.startswith("[") and in_entry:
            break
        if not in_entry:
            continue
        if not name and line.startswith("Name="):
            name = line[5:].strip()
        elif not exec_line and line.startswith("Exec="):
            exec_line = strip_desktop_exec(line[5:].strip())
    if not name or not exec_line:
        return None
    return {"name": name, "exec": exec_line, "path": str(path), "desktopId": path.name}


def discover_sessions(user: str) -> List[Dict[str, str]]:
    user_home = user_home_dir(user)
    dirs = [
        user_home / ".local/share/wayland-sessions",
        user_home / ".local/share/xsessions",
        Path("/usr/local/share/wayland-sessions"),
        Path("/usr/local/share/xsessions"),
        Path("/usr/share/wayland-sessions"),
        Path("/usr/share/xsessions"),
    ]
    xdg_dirs = os.environ.get("XDG_DATA_DIRS", "")
    for raw in xdg_dirs.split(":"):
        if raw:
            dirs.append(Path(raw) / "wayland-sessions")
            dirs.append(Path(raw) / "xsessions")
    seen: set[str] = set()
    sessions: List[Dict[str, str]] = []
    for directory in dirs:
        if not directory.is_dir():
            continue
        for desktop in sorted(directory.glob("*.desktop")):
            item = parse_desktop_file(desktop)
            if not item or item["name"] in seen:
                continue
            seen.add(item["name"])
            sessions.append(item)
    return sessions


def preferred_session(user: str, cache_dir_path: Path) -> Dict[str, str] | None:
    memory = load_json_file(cache_dir_path / ".local/state/memory.json")
    sessions = discover_sessions(user)
    if not sessions:
        return None
    last_path = memory.get("lastSessionId", "")
    last_desktop = memory.get("lastSessionDesktopId", "")
    for item in sessions:
        if (last_path and item["path"] == last_path) or (last_desktop and item["desktopId"] == last_desktop):
            return item
    for item in sessions:
        hay = (item["name"] + " " + item["desktopId"] + " " + item["exec"]).lower()
        if "hyprland" in hay:
            return item
    return sessions[0]


def copy_if_readable(src: Path, dst: Path, gid: int, mode: int = 0o660) -> bool:
    try:
        if not src.is_file():
            return False
        if dst.exists() and dst.is_symlink():
            raise RuntimeError(f"Refusing to overwrite symlink: {dst}")
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, dst)
        os.chown(dst, 0, gid)
        os.chmod(dst, mode)
        return True
    except Exception:
        return False


def copy_required(src: Path, dst: Path, gid: int, mode: int = 0o660) -> None:
    if not src.is_file():
        raise RuntimeError(f"configured greeter wallpaper not found: {src}")
    if dst.exists() and dst.is_symlink():
        raise RuntimeError(f"Refusing to overwrite symlink: {dst}")
    dst.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(src, dst)
    os.chown(dst, 0, gid)
    os.chmod(dst, mode)


def chown_tree(path: Path, uid: int, gid: int, dir_mode: int = 0o2750, file_mode: int = 0o640) -> None:
    if not path.exists():
        return
    if path.is_symlink():
        try:
            os.lchown(path, uid, gid)
        except FileNotFoundError:
            pass
        return
    if path.is_dir():
        os.chown(path, uid, gid)
        os.chmod(path, dir_mode)
        for child in path.iterdir():
            chown_tree(child, uid, gid, dir_mode, file_mode)
        return
    try:
        os.chown(path, uid, gid)
        mode = file_mode | (0o110 if os.access(path, os.X_OK) else 0)
        os.chmod(path, mode)
    except FileNotFoundError:
        pass


def sync_greeter_runtime_bin(runtime_bin: Path) -> None:
    if runtime_bin.exists() and runtime_bin.is_symlink():
        raise RuntimeError(f"Refusing symlink runtime bin directory: {runtime_bin}")
    runtime_bin.mkdir(parents=True, exist_ok=True)
    for name, mode in GREETER_RUNTIME_BIN_FILES.items():
        src = repo_root() / "bin" / name
        if not src.is_file():
            raise RuntimeError(f"required greeter runtime file not found: {src}")
        shutil.copy2(src, runtime_bin / name)
        os.chmod(runtime_bin / name, mode)


def sync_greeter_runtime(cache_dir_path: Path, gid: int) -> None:
    runtime_root = cache_dir_path / "runtime"
    runtime_qs = runtime_root / "quickshell" / "vshell"
    src_qs = repo_root() / "quickshell" / "vshell"
    if runtime_root.exists() and runtime_root.is_symlink():
        raise RuntimeError(f"Refusing symlink runtime directory: {runtime_root}")
    if runtime_qs.exists() and runtime_qs.is_symlink():
        raise RuntimeError(f"Refusing symlink runtime directory: {runtime_qs}")
    if runtime_qs.exists():
        shutil.rmtree(runtime_qs)
    runtime_qs.parent.mkdir(parents=True, exist_ok=True)
    shutil.copytree(src_qs, runtime_qs, symlinks=True)
    sync_greeter_runtime_bin(runtime_root / "bin")
    chown_tree(runtime_root, 0, gid)


def greeter_runtime_cli(cache_dir_path: Path) -> str:
    cached = cache_dir_path / "runtime" / "bin" / "vshell"
    if cached.exists():
        return str(cached)
    return resolve_vshell_cli()


def sync_profile_cache(cache_dir_path: Path, username: str, settings: Dict[str, Any], theme: Dict[str, Any], session: Dict[str, Any], gid: int, root_profile: bool = False, run_uid: int | None = None) -> None:
    target = cache_dir_path if root_profile else cache_dir_path / "users" / username
    ensure_cache_dir(target, gid, run_uid=run_uid if root_profile else None)
    write_json_file(target / "settings.json", settings, gid)
    write_json_file(target / "theme.json", theme, gid)
    write_json_file(target / "session.json", session, gid)
    wallpaper = str(settings.get("greeterWallpaperPath") or "").strip()
    override = target / "greeter_wallpaper_override"
    if wallpaper:
        src = Path(os.path.expanduser(wallpaper))
        if not src.is_absolute():
            src = Path.cwd() / src
        copy_required(src, override, gid)
    elif override.exists():
        override.unlink()

    user_home = user_home_dir(username)
    icon_candidates = [
        Path("/var/lib/AccountsService/icons") / username,
        user_home / ".face",
        user_home / ".face.icon",
    ]
    for candidate in icon_candidates:
        if copy_if_readable(candidate, target / ("profile" + candidate.suffix), gid):
            break
    write_json_file(target / "sync-manifest.json", {"version": 1, "syncedAt": int(time.time()), "user": username}, gid)


def sync_profile_cache_unprivileged(cache_dir_path: Path, username: str) -> None:
    settings = load_settings()
    theme = current_theme_json()
    session = current_session_json(theme)
    target = cache_dir_path / "users" / username
    target.mkdir(parents=True, exist_ok=True)
    for path, data in ((target / "settings.json", settings), (target / "theme.json", theme), (target / "session.json", session)):
        path.write_text(json.dumps(data, indent=2) + "\n")
        os.chmod(path, 0o660)
    wallpaper = str(settings.get("greeterWallpaperPath") or "").strip()
    override = target / "greeter_wallpaper_override"
    if wallpaper:
        src = Path(os.path.expanduser(wallpaper))
        if src.is_file():
            shutil.copy2(src, override)
            os.chmod(override, 0o660)
        else:
            raise RuntimeError(f"configured greeter wallpaper not found: {src}")
    elif override.exists():
        override.unlink()
    (target / "sync-manifest.json").write_text(json.dumps({"version": 1, "syncedAt": int(time.time()), "user": username}, indent=2) + "\n")
    os.chmod(target / "sync-manifest.json", 0o660)


def write_greetd_config(cache_dir_path: Path, autologin: bool, target_user: str, initial_session_cmd: str = "") -> None:
    cli = greeter_runtime_cli(cache_dir_path)
    compositor = choose_greeter_compositor(os.environ.get("VSHELL_GREETER_COMPOSITOR", ""))
    greeter_cmd = f"{shlex.quote(cli)} greeter run --compositor {compositor} --cache-dir {shlex.quote(str(cache_dir_path))}"
    content = [
        "[terminal]",
        "vt = 1",
        "",
        "[default_session]",
        f"user = {safe_toml_string(greeter_identity()[0])}",
        f"command = {safe_toml_string(greeter_cmd)}",
        "",
    ]
    if autologin:
        if not initial_session_cmd:
            raise RuntimeError(f"No session command found for auto-login user {target_user}")
        launch_cmd = f"env XDG_SESSION_TYPE=wayland {initial_session_cmd}"
        content.extend([
            "[initial_session]",
            f"user = {safe_toml_string(target_user)}",
            f"command = {safe_toml_string(launch_cmd)}",
            "",
        ])
    write_root_file(GREETD_CONFIG, "\n".join(content), mode=0o644)


def render_greetd_pam(settings: Dict[str, Any]) -> str:
    lines = [
        "#%PAM-1.0",
        "",
        "auth       required     pam_securetty.so",
        "auth       requisite    pam_nologin.so",
    ]
    if settings.get("greeterEnableFprint"):
        lines.append("auth       sufficient   pam_fprintd.so max-tries=5")
    if settings.get("greeterEnableU2f"):
        lines.append("auth       sufficient   pam_u2f.so cue timeout=10")
    lines.extend([
        "auth       include      system-local-login",
        "auth       optional     pam_gnome_keyring.so",
        "account    include      system-local-login",
        "session    include      system-local-login",
        "session    optional     pam_gnome_keyring.so auto_start",
        "",
    ])
    return "\n".join(lines)


def render_vshell_pam() -> str:
    return """#%PAM-1.0
auth      required     pam_env.so
auth      sufficient   pam_unix.so       try_first_pass nullok
auth      required     pam_deny.so
account   required     pam_unix.so
password  required     pam_deny.so
session   required     pam_permit.so
"""


def render_vshell_u2f_pam() -> str:
    return """#%PAM-1.0

auth    required    pam_u2f.so  cue timeout=10
account required    pam_permit.so
password required   pam_deny.so
session required    pam_permit.so
"""


def cmd_auth(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell auth")
    sub = parser.add_subparsers(dest="cmd", required=True)
    p_sync = sub.add_parser("sync")
    p_sync.add_argument("--yes", action="store_true")
    p_sync.add_argument("--terminal", action="store_true")
    args = parser.parse_args(argv)
    if args.cmd != "sync":
        return 2
    root_exit = ensure_root_for(["auth", *argv], terminal=args.terminal)
    if root_exit is not None:
        return root_exit
    settings = load_settings()
    write_root_file(Path("/etc/pam.d/vshell"), render_vshell_pam(), mode=0o644)
    write_root_file(Path("/etc/pam.d/vshell-u2f"), render_vshell_u2f_pam(), mode=0o644)
    write_root_file(Path("/etc/pam.d/greetd"), render_greetd_pam(settings), mode=0o644)
    print("VGS auth synced: /etc/pam.d/vshell, /etc/pam.d/vshell-u2f, /etc/pam.d/greetd")
    return 0


def provision_empty_login_keyring(username: str, force: bool = False) -> Tuple[bool, str]:
    if not shutil.which("gnome-keyring-daemon"):
        return False, "gnome-keyring-daemon not found; keyring empty-password provisioning skipped"
    try:
        pw = pwd.getpwnam(username)
    except KeyError:
        return False, f"user {username!r} not found; keyring provisioning skipped"

    keyring_dir = Path(pw.pw_dir) / ".local/share/keyrings"
    login_keyring = keyring_dir / "login.keyring"
    marker = keyring_dir / "login.keyring.vshell-empty"
    backup = ""
    keyring_dir.mkdir(parents=True, exist_ok=True)
    os.chown(keyring_dir, pw.pw_uid, pw.pw_gid)
    os.chmod(keyring_dir, 0o700)

    if login_keyring.exists() and marker.exists():
        return True, "login keyring already marked as VGS empty-password keyring"
    if login_keyring.exists() and not force:
        return False, "existing login.keyring kept; run `vshell greeter keyring empty --force` to back it up and convert it to an empty-password login keyring"

    temp_home = Path(tempfile.mkdtemp(prefix="vshell-keyring-"))
    try:
        temp_keyring_dir = temp_home / ".local/share/keyrings"
        temp_keyring_dir.mkdir(parents=True, exist_ok=True)
        chown_tree(temp_home, pw.pw_uid, pw.pw_gid, dir_mode=0o700, file_mode=0o600)
        script = r"""
set -euo pipefail
mkdir -p "$HOME/.local/share/keyrings"
systemctl --user stop gnome-keyring-daemon.service >/dev/null 2>&1 || true
pkill -u "$(id -u)" -f '(^|/)gnome-keyring-daemon( |$)' >/dev/null 2>&1 || true
if command -v dbus-run-session >/dev/null 2>&1; then
  timeout 10s dbus-run-session -- bash -lc 'set -euo pipefail; eval "$(printf "\n" | gnome-keyring-daemon --unlock --components=secrets,pkcs11)"; if command -v secret-tool >/dev/null 2>&1; then printf init | secret-tool store --label="VGS keyring init" vshell keyring-init >/dev/null; secret-tool clear vshell keyring-init >/dev/null 2>&1 || true; fi'
else
  printf "\n" | timeout 8s gnome-keyring-daemon --unlock --components=secrets,pkcs11 >/dev/null
fi
pkill -u "$(id -u)" -f '(^|/)gnome-keyring-daemon( |$)' >/dev/null 2>&1 || true
test -f "$HOME/.local/share/keyrings/login.keyring"
chmod 600 "$HOME/.local/share/keyrings/login.keyring"
"""
        env = os.environ.copy()
        env["HOME"] = str(temp_home)
        env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{pw.pw_uid}")
        proc = subprocess.run(["runuser", "-u", username, "--", "bash", "-lc", script], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, timeout=20)
        new_keyring = temp_keyring_dir / "login.keyring"
        if proc.returncode != 0 or not new_keyring.exists():
            detail = (proc.stderr or proc.stdout or "").strip()
            return False, "empty login keyring creation failed" + (f": {detail}" if detail else "")

        if login_keyring.exists():
            backup_path_value = login_keyring.with_name(f"login.keyring.vshell-backup-{int(time.time())}")
            shutil.copy2(login_keyring, backup_path_value)
            os.chown(backup_path_value, pw.pw_uid, pw.pw_gid)
            os.chmod(backup_path_value, 0o600)
            backup = str(backup_path_value)

        tmp_dest = login_keyring.with_name(f"login.keyring.tmp-{os.getpid()}")
        shutil.copy2(new_keyring, tmp_dest)
        os.chown(tmp_dest, pw.pw_uid, pw.pw_gid)
        os.chmod(tmp_dest, 0o600)
        tmp_dest.replace(login_keyring)
        marker.write_text(json.dumps({"createdBy": "vshell", "createdAt": int(time.time())}) + "\n")
        os.chown(marker, pw.pw_uid, pw.pw_gid)
        os.chmod(marker, 0o600)
        return True, "login keyring set to empty password" + (f" (backup: {backup})" if backup else "")
    finally:
        shutil.rmtree(temp_home, ignore_errors=True)


def cmd_greeter(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell greeter")
    sub = parser.add_subparsers(dest="cmd", required=True)
    p_run = sub.add_parser("run")
    p_run.add_argument("--compositor", "--command", default="", choices=["hyprland", "niri"])
    p_run.add_argument("--cache-dir", default=str(GREETER_CACHE_DEFAULT))
    p_run.add_argument("--debug", action="store_true")
    p_sync = sub.add_parser("sync")
    p_sync.add_argument("--yes", action="store_true")
    p_sync.add_argument("--terminal", action="store_true")
    p_sync.add_argument("--autologin", action="store_true")
    p_sync.add_argument("--profile", action="store_true")
    p_sync.add_argument("--cache-dir", default=str(GREETER_CACHE_DEFAULT))
    p_sync.add_argument("--user", default="")
    p_sync.add_argument("--force-keyring", action="store_true")
    p_launch = sub.add_parser("launch-session")
    p_launch.add_argument("--from-memory", action="store_true")
    p_launch.add_argument("--cache-dir", default=str(GREETER_CACHE_DEFAULT))
    p_launch.add_argument("--session", default="")
    p_keyring = sub.add_parser("keyring")
    p_keyring.add_argument("action", choices=["empty"])
    p_keyring.add_argument("--user", default="")
    p_keyring.add_argument("--force", action="store_true")
    p_keyring.add_argument("--terminal", action="store_true")
    args = parser.parse_args(argv)

    if args.cmd == "run":
        return greeter_run(args.cache_dir, debug=args.debug, requested_compositor=args.compositor)
    if args.cmd == "launch-session":
        return greeter_launch_session(Path(args.cache_dir), args.session)
    if args.cmd == "keyring":
        root_exit = ensure_root_for(["greeter", *argv], terminal=args.terminal)
        if root_exit is not None:
            return root_exit
        user = args.user or current_login_user()
        ok, msg = provision_empty_login_keyring(user, force=args.force)
        print(msg)
        return 0 if ok else 1
    if args.cmd != "sync":
        return 2

    if args.profile and os.geteuid() != 0 and not args.terminal:
        target_user = args.user or current_login_user()
        sync_profile_cache_unprivileged(Path(args.cache_dir), target_user)
        print(f"VGS greeter profile synced for {target_user}: {Path(args.cache_dir) / 'users' / target_user}")
        return 0

    root_exit = ensure_root_for(["greeter", *argv], terminal=args.terminal)
    if root_exit is not None:
        return root_exit
    cache_dir_path = validate_greeter_cache_dir(Path(args.cache_dir), privileged=True)
    target_user = args.user or current_login_user()
    _guser, guid, _ggroup, gid = greeter_identity()
    settings = load_settings()
    keyring_msg = ""
    if settings.get("greeterAutoLogin") and settings.get("greeterAutoLoginKeyringMode", "keep") == "empty":
        keyring_ok, keyring_msg = provision_empty_login_keyring(target_user, force=args.force_keyring)
        if not keyring_ok:
            eprint(keyring_msg)
            return 1
    ensure_cache_dir(cache_dir_path, gid, run_uid=guid)
    sync_greeter_runtime(cache_dir_path, gid)
    monitor_layout = capture_user_monitor_layout(target_user)
    monitors_path = cache_dir_path / GREETER_MONITORS_FILENAME
    if monitor_layout:
        write_json_file(monitors_path, {"monitors": monitor_layout}, gid)
    elif monitors_path.exists():
        # Deliberately keep the previous snapshot: sync is a root command that is
        # legitimately run from a TTY or over ssh, where the user's compositor is
        # simply not reachable, and the last known orientation beats none at all.
        # Say so, though — a silently stale layout would rotate the greeter wrong
        # after a monitor change with no indication why.
        eprint(f"Could not read the current monitor layout; greeter keeps the previous {monitors_path}")
    theme = current_theme_json()
    session = current_session_json(theme)
    sync_profile_cache(cache_dir_path, target_user, settings, theme, session, gid, root_profile=True, run_uid=guid)
    sync_profile_cache(cache_dir_path, target_user, settings, theme, session, gid, root_profile=False)
    sess = preferred_session(target_user, cache_dir_path)
    memory_path = cache_dir_path / ".local/state/memory.json"
    memory = load_json_file(memory_path)
    if sess:
        memory.setdefault("lastSessionId", sess["path"])
        memory.setdefault("lastSessionDesktopId", sess["desktopId"])
    memory.setdefault("lastSuccessfulUser", target_user)
    write_json_file(memory_path, memory, gid)
    write_root_file(Path("/etc/pam.d/greetd"), render_greetd_pam(settings), mode=0o644)
    write_greetd_config(cache_dir_path, bool(settings.get("greeterAutoLogin")), target_user, sess["exec"] if sess else "")
    print(f"VGS greeter synced for {target_user}: {cache_dir_path}")
    print(f"greetd default_session -> vshell greeter ({'auto-login enabled' if settings.get('greeterAutoLogin') else 'auto-login disabled'})")
    if keyring_msg:
        print(keyring_msg)
    return 0


def greeter_cursor_environment(cache_path: Path, base_env: Dict[str, str]) -> Dict[str, str]:
    settings_path = cache_path / "settings.json"
    if not settings_path.exists():
        return {}
    try:
        settings = load_json_file(settings_path)
    except Exception:
        return {}
    cursor = settings.get("cursorSettings") or {}
    if not isinstance(cursor, dict):
        return {}
    theme = str(cursor.get("theme") or "").strip()
    if not theme or theme == "System Default":
        return {}
    size = str(cursor.get("size") or "").strip()

    icon_roots: List[Path] = []
    for data_dir in base_env.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":"):
        if data_dir:
            icon_roots.append(Path(data_dir) / "icons")
    icon_roots.extend([Path("/run/current-system/sw/share/icons"), Path("/usr/share/icons"), Path("/usr/local/share/icons")])

    seen: set[str] = set()
    for icon_root in icon_roots:
        root_str = str(icon_root)
        if root_str in seen:
            continue
        seen.add(root_str)
        if not (icon_root / theme / "cursors").is_dir():
            continue
        out = {
            "XCURSOR_THEME": theme,
            "XCURSOR_PATH": root_str + ((":" + base_env["XCURSOR_PATH"]) if base_env.get("XCURSOR_PATH") else ""),
        }
        if size:
            out["XCURSOR_SIZE"] = size
        return out
    return {}


def greeter_primary_monitor(cache_path: Path) -> str:
    settings_path = cache_path / "settings.json"
    if not settings_path.exists():
        return ""
    try:
        settings = load_json_file(settings_path)
    except Exception:
        return ""
    monitor = str(settings.get("greeterPrimaryMonitor") or "").strip()
    # Keep connector selection to real compositor-style identifiers even
    # though the generated native-Lua config also quotes this value.
    if monitor and not re.fullmatch(r"[A-Za-z0-9._:-]+", monitor):
        eprint(f"Ignoring invalid greeter primary monitor: {monitor!r}")
        return ""
    return monitor


GREETER_MONITORS_FILENAME = "monitors.json"


def _lua_number(value: float) -> str:
    """Render a whole scale as `2`, not `2.0` — matching how monitor scales are
    written by hand elsewhere and keeping the generated config diff-friendly."""
    return str(int(value)) if float(value).is_integer() else repr(float(value))


def _sane_monitor_scale(value: Any) -> float | None:
    """Validate a scale before it is rendered into the greeter's Lua config.

    `nan`/`inf` would reach the config as bare `nan`/`inf` literals and a zero or
    negative scale is not a mode Hyprland can bring up — either way the greeter
    fails to start, which on a login screen means locked out.
    """
    try:
        scale = float(value if value is not None else 1)
    except (TypeError, ValueError):
        return None
    if not math.isfinite(scale) or not (0 < scale <= 10):
        return None
    return scale


def _sane_monitor_transform(value: Any) -> int | None:
    """wl_output transforms are 0-7; anything else is not a rotation."""
    try:
        transform = int(value if value is not None else 0)
    except (TypeError, ValueError):
        return None
    return transform if 0 <= transform <= 7 else None


def capture_user_monitor_layout(target_user: str) -> List[Dict[str, Any]]:
    """Snapshot the user's live Hyprland outputs for the greeter to reproduce.

    The greeter compositor otherwise starts every output untransformed, so a
    physically rotated panel renders the login UI sideways. `transform` is the
    field that actually matters here; scale keeps the UI the size the user
    already tuned for.

    `greeter sync` re-execs itself as root, so the invoking session's Hyprland
    environment is gone by the time this runs. Locate the instance socket under
    the target user's runtime dir rather than trusting an inherited
    HYPRLAND_INSTANCE_SIGNATURE.
    """
    try:
        uid = pwd.getpwnam(target_user).pw_uid
    except KeyError:
        return []
    hypr_dir = Path(f"/run/user/{uid}/hypr")
    if not hypr_dir.is_dir():
        return []
    try:
        instances = sorted(
            (p for p in hypr_dir.iterdir() if p.is_dir()),
            key=lambda p: p.stat().st_mtime,
            reverse=True,
        )
    except OSError:
        return []

    for instance in instances:
        env = {
            **os.environ,
            "HYPRLAND_INSTANCE_SIGNATURE": instance.name,
            "XDG_RUNTIME_DIR": f"/run/user/{uid}",
        }
        try:
            monitors = json.loads(run(["hyprctl", "monitors", "-j"], env=env).stdout or "[]")
        except Exception:
            continue
        captured: List[Dict[str, Any]] = []
        for monitor in monitors:
            name = str(monitor.get("name") or "").strip()
            # Same connector-shaped guard as greeter_primary_monitor: these
            # names are interpolated into generated native Lua.
            if not name or not re.fullmatch(r"[A-Za-z0-9._:-]+", name):
                continue
            # Virtual outputs (e.g. the Sunshine headless sink) do not exist at
            # greetd time; a rule for an absent output is inert, but there is no
            # reason to carry them.
            if name.startswith("HEADLESS-"):
                continue
            scale = _sane_monitor_scale(monitor.get("scale"))
            transform = _sane_monitor_transform(monitor.get("transform"))
            if scale is None or transform is None:
                eprint(f"Ignoring monitor {name} with unusable scale/transform for the greeter")
                continue
            captured.append({"name": name, "scale": scale, "transform": transform})
        if captured:
            return captured
    return []


def greeter_monitor_layout(cache_path: Path) -> List[Dict[str, Any]]:
    data = load_json_file(cache_path / GREETER_MONITORS_FILENAME)
    monitors = data.get("monitors")
    return monitors if isinstance(monitors, list) else []


def render_hyprland_greeter_config(qs_cmd: str, cache_path: Path, cursor_env: Dict[str, str]) -> str:
    lines = [
        "-- Generated transiently by VGS for greetd.",
        f'hl.env("VSHELL_RUN_GREETER", {_lua_string("1")})',
        f'hl.env("VSHELL_GREET_CFG_DIR", {_lua_string(cache_path)})',
        f'hl.env("XDG_SESSION_TYPE", {_lua_string("wayland")})',
    ]
    for key in ("XCURSOR_PATH", "XCURSOR_THEME", "XCURSOR_SIZE"):
        if cursor_env.get(key):
            lines.append(f"hl.env({_lua_string(key)}, {_lua_string(cursor_env[key])})")
    lines.append("")
    # Reproduce the user's output orientation. `mode`/`position` stay at
    # preferred/auto on purpose: the greeter only needs each panel upright and
    # legibly scaled, and a captured mode string that no longer matches (cable
    # swap, different EDID at greetd time) would be a way to lose the login
    # screen entirely.
    for monitor in greeter_monitor_layout(cache_path):
        name = str(monitor.get("name") or "").strip()
        if not name or not re.fullmatch(r"[A-Za-z0-9._:-]+", name):
            continue
        # Re-validate at render time, not just at capture: monitors.json is a
        # file on disk that can predate these checks or be edited by hand, and a
        # bad literal here costs the login screen.
        scale = _sane_monitor_scale(monitor.get("scale"))
        transform = _sane_monitor_transform(monitor.get("transform"))
        if scale is None or transform is None:
            eprint(f"Skipping greeter monitor rule for {name}: unusable scale/transform")
            continue
        lines.extend([
            "hl.monitor({",
            f"  output = {_lua_string(name)},",
            '  mode = "preferred",',
            '  position = "auto",',
            f"  scale = {_lua_number(scale)},",
            f"  transform = {transform},",
            "})",
        ])

    lines.extend([
        "",
        "hl.config({",
        "  misc = {",
        "    disable_hyprland_logo = true,",
        "    disable_splash_rendering = true,",
        "  },",
    ])
    primary_monitor = greeter_primary_monitor(cache_path)
    if primary_monitor:
        # Hyprland documents cursor.default_monitor as the startup output
        # selector. GreeterContent independently uses the same connector as the
        # sole visible owner of greetd state and input focus.
        lines.extend([
            "  cursor = {",
            f"    default_monitor = {_lua_string(primary_monitor)},",
            "  },",
        ])
    lines.extend([
        "})",
        "",
    ])
    exit_cmd = "hyprctl dispatch 'hl.dsp.exit()' || hyprctl dispatch exit"
    lines.extend([
        'hl.on("hyprland.start", function()',
        f"  hl.exec_cmd({_lua_string(qs_cmd + '; ' + exit_cmd)})",
        "end)",
    ])
    return "\n".join(lines) + "\n"


def choose_greeter_compositor(requested: str = "") -> str:
    requested = requested.strip().lower()
    if requested not in {"", "hyprland", "niri"}:
        raise ValueError(f"Unsupported greeter compositor: {requested}")
    if requested:
        return requested
    # Preserve the historical Hyprland default wherever it is installed; a
    # Niri-only machine needs no extra setting.
    return "hyprland" if (shutil.which("start-hyprland") or shutil.which("Hyprland")) else "niri"




def greeter_run(cache_dir_value: str, debug: bool = False, requested_compositor: str = "") -> int:
    cache_path = Path(cache_dir_value)
    if not cache_path.is_dir():
        eprint(f"Greeter cache directory does not exist: {cache_path}. Run `vshell greeter sync`.")
        return 1
    qs_bin = shutil.which("qs") or shutil.which("quickshell")
    if not qs_bin:
        eprint("qs/quickshell not found")
        return 1
    requested_compositor = requested_compositor or os.environ.get("VSHELL_GREETER_COMPOSITOR", "")
    have_hyprland = bool(shutil.which("start-hyprland") or shutil.which("Hyprland"))
    have_niri = bool(shutil.which("niri"))
    try:
        compositor = choose_greeter_compositor(requested_compositor)
    except ValueError as exc:
        eprint(str(exc))
        return 1
    if compositor == "hyprland" and not have_hyprland:
        eprint("Hyprland not found")
        return 1
    if compositor == "niri" and not have_niri:
        eprint("Niri not found")
        return 1
    qs_config = cache_path / "runtime" / "quickshell" / "vshell"
    if not qs_config.exists():
        qs_config = repo_root() / "quickshell" / "vshell"
    qs_cmd = f"{shlex.quote(qs_bin)} -p {shlex.quote(str(qs_config))}"
    env = os.environ.copy()
    cursor_env = greeter_cursor_environment(cache_path, env)
    suffix = ".lua" if compositor == "hyprland" else ".kdl"
    with tempfile.NamedTemporaryFile("w", prefix=f"vshell-greeter-{compositor}-", suffix=suffix, delete=False) as tmp:
        if compositor == "hyprland":
            tmp.write(render_hyprland_greeter_config(qs_cmd, cache_path, cursor_env))
        else:
            tmp.write(_niri().niri_greeter_config(qs_cmd))
        config_path = tmp.name
    env.update({
        "VSHELL_RUN_GREETER": "1",
        "VSHELL_GREET_CFG_DIR": str(cache_path),
        "HOME": str(cache_path),
        "XDG_STATE_HOME": str(cache_path / ".local/state"),
        "XDG_DATA_HOME": str(cache_path / ".local/share"),
        "XDG_CACHE_HOME": str(cache_path / ".cache"),
        "QT_QPA_PLATFORM": "wayland",
        "QT_WAYLAND_DISABLE_WINDOWDECORATION": "1",
        "XDG_SESSION_TYPE": "wayland",
    })
    env.update(cursor_env)
    env.setdefault("XDG_RUNTIME_DIR", str(cache_path / "run"))
    Path(env["XDG_RUNTIME_DIR"]).mkdir(parents=True, exist_ok=True)
    os.chmod(env["XDG_RUNTIME_DIR"], 0o700)
    if compositor == "niri":
        cmd = ["niri", "--config", config_path]
        env["XDG_CURRENT_DESKTOP"] = "niri"
    else:
        cmd = ["start-hyprland", "--", "--config", config_path] if shutil.which("start-hyprland") else ["Hyprland", "-c", config_path]
        env["XDG_CURRENT_DESKTOP"] = "Hyprland"
    if debug:
        eprint("Running", " ".join(shlex.quote(x) for x in cmd))
    os.execvpe(cmd[0], cmd, env)
    return 1


def greeter_launch_session(cache_dir_path: Path, explicit_session: str = "") -> int:
    user = current_login_user()
    session_cmd = explicit_session.strip()
    if not session_cmd:
        sess = preferred_session(user, cache_dir_path)
        if sess:
            session_cmd = sess["exec"]
    if not session_cmd:
        eprint("No greeter session command found")
        return 1
    args = shlex.split(session_cmd)
    if not args:
        eprint("Empty greeter session command")
        return 1
    env = os.environ.copy()
    env.setdefault("XDG_SESSION_TYPE", "wayland")
    desktop = "niri" if "niri" in Path(args[0]).name.lower() or any("niri" in arg.lower() for arg in args[:2]) else "Hyprland"
    env.setdefault("XDG_CURRENT_DESKTOP", desktop)
    os.execvpe(args[0], args, env)
    return 1


def _launcher_search_score(name: str, query: str) -> float:
    """Small, deterministic fuzzy score used after fd narrows the candidate set."""
    haystack = name.casefold()
    needle = query.casefold()
    if not needle:
        return 0.0
    exact = haystack.find(needle)
    if exact >= 0:
        return 1000.0 - exact * 2.0 - max(0, len(haystack) - len(needle)) * 0.15
    pos = -1
    gap = 0
    for char in needle:
        next_pos = haystack.find(char, pos + 1)
        if next_pos < 0:
            return -1.0
        if pos >= 0:
            gap += next_pos - pos - 1
        pos = next_pos
    return 650.0 - gap * 4.0 - max(0, len(haystack) - len(needle)) * 0.1


def _utf16_offset(value: str, codepoint_offset: int) -> int:
    """Return the UTF-16 code-unit offset QML uses for String.slice()."""
    return len(value[:max(0, codepoint_offset)].encode("utf-16-le")) // 2


def _utf8_byte_offset_to_utf16(value: str, byte_offset: int) -> int:
    """Translate ripgrep JSON byte offsets to QML's UTF-16 string offsets."""
    prefix = value.encode("utf-8")[:max(0, byte_offset)].decode("utf-8", errors="ignore")
    return len(prefix.encode("utf-16-le")) // 2


def _launcher_literal_match_ranges(value: str, query: str, limit: int = 400) -> List[Dict[str, int]]:
    if not value or not query:
        return []
    flags = 0 if any(char.isupper() for char in query) else re.IGNORECASE
    try:
        pattern = re.compile(query, flags)
    except re.error:
        pattern = re.compile(re.escape(query), flags)
    ranges: List[Dict[str, int]] = []
    for match in pattern.finditer(value):
        start, end = match.span()
        if end <= start:
            continue
        ranges.append({
            "start": _utf16_offset(value, start),
            "end": _utf16_offset(value, end),
        })
        if len(ranges) >= limit:
            break
    return ranges


def _launcher_search_ignored(path: Path, ignores: List[str], roots: List[Path]) -> bool:
    expanded = path.expanduser()
    for raw in ignores:
        value = os.path.expandvars(os.path.expanduser(raw.strip()))
        if not value:
            continue
        if os.path.isabs(value):
            try:
                expanded.relative_to(Path(value))
                return True
            except ValueError:
                continue
        parts = expanded.parts
        if value in parts or expanded.name == value:
            return True
        for root in roots:
            candidate = root / value
            try:
                expanded.relative_to(candidate)
                return True
            except ValueError:
                pass
    return False


def _launcher_folder_path_hits(
    query: str,
    roots: List[Path],
    ignores: List[str],
    limit: int,
) -> List[Dict[str, Any]]:
    raw = query.strip()
    expanded = Path(os.path.expandvars(os.path.expanduser(raw)))
    if not expanded.is_absolute():
        return []
    exact = expanded.resolve(strict=False)
    parent = exact if raw.endswith(os.sep) else exact.parent
    partial = "" if raw.endswith(os.sep) else exact.name
    raw_parent = raw if raw.endswith(os.sep) else raw[:raw.rfind(os.sep) + 1]
    hits: List[Dict[str, Any]] = []
    seen: set[str] = set()

    def add(path: Path, completion: str, score: float) -> None:
        path_text = str(path)
        if path_text in seen or not path.is_dir() or _launcher_search_ignored(path, ignores, roots):
            return
        try:
            stat = path.stat()
        except OSError:
            return
        seen.add(path_text)
        hits.append({
            "path": path_text,
            "name": path.name or path_text,
            "parent": str(path.parent),
            "is_dir": True,
            "size": stat.st_size,
            "mtime": int(stat.st_mtime),
            "score": score,
            "completion": completion,
        })

    if exact.is_dir():
        exact_completion = raw if raw.endswith(os.sep) else raw + os.sep
        add(exact, exact_completion, 2000.0)

    try:
        children = sorted(parent.iterdir(), key=lambda path: path.name.casefold())
    except OSError:
        children = []
    folded_partial = partial.casefold()
    for child in children:
        if not child.is_dir() or not child.name.casefold().startswith(folded_partial):
            continue
        completion = raw_parent + child.name + os.sep
        add(child, completion, 1800.0 - max(0, len(child.name) - len(partial)))
        if len(hits) >= limit:
            break
    hits.sort(key=lambda hit: (-float(hit["score"]), str(hit["path"]).casefold()))
    return hits[:limit]


def _launcher_search_name_hits(
    query: str,
    kind: str,
    roots: List[Path],
    ignores: List[str],
    limit: int,
    ignore_mounts: bool,
) -> List[Dict[str, Any]]:
    """Rank file and folder names matching `query` under `roots`.

    A folder query that starts at a path is answered below by
    `_launcher_folder_path_hits`, before fd is consulted at all.

    Otherwise fd does the walking when it is installed. The os.walk branch is
    the fallback for callers that accept a full walk of the roots: the
    `vshell launcher-search` CLI, and the vgsMenu plugin, which runs a search
    per keystroke. The overview never dispatches a name search that could land
    here -- it requires fd to be positively detected, and says why instead --
    because a full walk per query, with nothing cached between them, cannot
    answer at typing speed.
    """
    if kind == "folders" and query.strip().startswith(("~", "/")):
        return _launcher_folder_path_hits(query, roots, ignores, limit)

    fd_bin = shutil.which("fd") or shutil.which("fdfind")
    candidates: List[str] = []
    if fd_bin:
        fuzzy_pattern = ".*".join(re.escape(char) for char in query)
        command = [
            fd_bin, "--absolute-path", "--hidden", "--color", "never", "--print0",
            "--ignore-case",
        ]
        if kind == "all":
            command.extend(["--type", "f", "--type", "d"])
        else:
            command.extend(["--type", "d" if kind == "folders" else "f"])
        if ignore_mounts:
            command.append("--one-file-system")
        for ignored in ignores:
            value = ignored.strip()
            if value and not os.path.isabs(os.path.expanduser(value)):
                # Joined, like every other user-derived value on this path: an
                # ignore entry starting with "-" is an option name to fd in the
                # separated form, and fd rejects the whole invocation for it.
                command.append("--exclude=" + value)
        command.append(fuzzy_pattern)
        command.extend(str(root) for root in roots)
        completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=8, check=False)
        # fd exits 0 for an ordinary search, warnings and unreadable directories
        # included, so a non-zero exit means the invocation itself was refused --
        # a rejected argument, a search path that is not a directory. Reporting
        # that as "no matches" is a wrong answer wearing a successful one's
        # clothes, which is exactly what a rejected --exclude used to produce.
        if completed.returncode != 0:
            detail = completed.stderr.decode("utf-8", "replace").strip().splitlines()
            raise RuntimeError(
                "fd exited {} for this search: {}".format(
                    completed.returncode, detail[0] if detail else "no diagnostic"))
        candidates = [os.fsdecode(value) for value in completed.stdout.split(b"\0") if value]
    else:
        for root in roots:
            root_device = root.stat().st_dev if ignore_mounts else None
            for current, dirs, files in os.walk(root):
                current_path = Path(current)
                dirs[:] = [
                    entry for entry in dirs
                    if not _launcher_search_ignored(current_path / entry, ignores, roots)
                    and (root_device is None or (current_path / entry).stat().st_dev == root_device)
                ]
                names = dirs if kind == "folders" else files if kind == "files" else dirs + files
                for name in names:
                    if _launcher_search_score(name, query) >= 0:
                        candidates.append(str(current_path / name))
                if len(candidates) >= max(limit * 40, 1000):
                    break

    hits: List[Dict[str, Any]] = []
    for raw_path in candidates:
        path = Path(raw_path)
        if _launcher_search_ignored(path, ignores, roots):
            continue
        score = _launcher_search_score(path.name, query)
        if score < 0:
            continue
        try:
            stat = path.stat()
        except OSError:
            continue
        hits.append({
            "path": str(path),
            "name": path.name,
            "parent": str(path.parent),
            "is_dir": path.is_dir(),
            "size": stat.st_size,
            "mtime": int(stat.st_mtime),
            "score": score,
        })
    hits.sort(key=lambda hit: (-float(hit["score"]), -int(hit["mtime"]), str(hit["path"]).casefold()))
    return hits[:limit]


def _launcher_zoxide_hits(query: str, limit: int) -> List[Dict[str, Any]]:
    zoxide_bin = shutil.which("zoxide")
    if not zoxide_bin:
        raise RuntimeError("zoxide is required for recent-directory search")
    command = [zoxide_bin, "query", "-ls"]
    command.extend(query.split())
    completed = subprocess.run(
        command, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
        timeout=3, check=False,
    )
    hits: List[Dict[str, Any]] = []
    for raw_line in completed.stdout.splitlines():
        match = re.match(r"^\s*([0-9.]+)\s+(.+)$", raw_line)
        if not match:
            continue
        path = Path(match.group(2))
        if not path.is_dir():
            continue
        try:
            stat = path.stat()
        except OSError:
            continue
        score = float(match.group(1))
        hits.append({
            "path": str(path),
            "name": path.name or str(path),
            "parent": str(path.parent),
            "is_dir": True,
            "size": stat.st_size,
            "mtime": int(stat.st_mtime),
            "score": 2000.0 + score,
            "zoxide_score": score,
            "completion": str(path) + os.sep,
        })
        if len(hits) >= limit:
            break
    return hits


def _launcher_folder_openers() -> List[Dict[str, str]]:
    # A wrapper script that exists only in someone's dotfiles must not be probed
    # for by name here — configure it through the launcherFolderOpenCommand
    # setting, which the "Preferred app" opener passes through as --command. See
    # docs/architecture/overlay-and-dependencies.md § Probing rules.
    #
    openers = [{"id": "default", "label": "Preferred app", "icon": "open_in_new"}]
    # Yazi is a TUI, so it is only openable with a terminal spawner. Advertise it
    # on the same condition _launcher_open_folder() runs it on, or the QML opener
    # list (populated from `vshell launcher-search openers`) offers a choice that
    # can only answer "unavailable".
    if shutil.which("yazi") and have_terminal():
        openers.append({"id": "yazi", "label": "Yazi", "icon": "terminal"})
    # Not a hardcoded application: whichever file manager the user has set for
    # inode/directory, which is what Settings -> Default Apps writes.
    manager = file_manager()
    if manager and (have_terminal() or not manager.get("terminal")):
        openers.append({"id": "filemanager", "label": manager.get("name") or "File manager",
                        "icon": "folder"})
    return openers


def _launcher_open_folder(path: str, command_text: str = "", opener: str = "default") -> Dict[str, Any]:
    target = str(Path(path).expanduser().resolve(strict=False))
    if not Path(target).is_dir():
        return {"ok": False, "error": f"Folder not found: {target}"}
    if opener == "yazi":
        yazi = shutil.which("yazi")
        if not yazi or not have_terminal():
            return {"ok": False, "error": "Yazi folder opener is unavailable"}
        code = spawn_terminal([yazi, target], detach=True, what="the Yazi folder opener")
        if code != 0:
            return {"ok": False, "error": "Yazi folder opener could not start a terminal"}
        return {"ok": True, "command": [yazi, target], "opener": "yazi"}
    if opener in {"filemanager", "nautilus"}:
        manager = file_manager()
        if not manager:
            return {"ok": False, "error": "No file manager is configured or installed"}
        command = [*manager["argv"], target]
        if manager.get("terminal"):
            if not have_terminal():
                return {"ok": False, "error": f"{manager['name']} needs a terminal, and none was found"}
            code = spawn_terminal(command, detach=True, what="the folder opener")
            if code != 0:
                return {"ok": False, "error": f"{manager['name']} could not start a terminal"}
            return {"ok": True, "command": command, "opener": "filemanager"}
    elif command_text.strip():
        command = shlex.split(command_text)
        command = [part.replace("{path}", target) for part in command]
        if not any(target in part for part in command):
            command.append(target)
    else:
        command = ["gio", "open", target]
    if not command or not shutil.which(command[0]):
        return {"ok": False, "error": f"Folder opener not found: {command[0] if command else ''}"}
    subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
    return {"ok": True, "command": command}


def _launcher_search_text_hits(
    query: str,
    roots: List[Path],
    ignores: List[str],
    limit: int,
    ignore_mounts: bool,
) -> List[Dict[str, Any]]:
    rg_bin = shutil.which("rg")
    if not rg_bin:
        raise RuntimeError("ripgrep is required for text search")
    command = [
        rg_bin, "--json", "--hidden", "--smart-case", "--max-columns", "240",
        "--max-columns-preview", "--max-count", "4", "--no-messages",
    ]
    if ignore_mounts:
        command.append("--one-file-system")
    for ignored in ignores:
        value = ignored.strip()
        if not value:
            continue
        if os.path.isabs(os.path.expanduser(value)):
            command.extend(["--glob", "!" + os.path.expanduser(value).rstrip("/") + "/**"])
        else:
            command.extend(["--glob", "!" + value.rstrip("/") + "/**"])
    command.extend(["--", query])
    command.extend(str(root) for root in roots)
    hits: List[Dict[str, Any]] = []
    process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
    try:
        assert process.stdout is not None
        for raw_line in process.stdout:
            try:
                event = json.loads(raw_line)
                if event.get("type") != "match":
                    continue
                data = event["data"]
                path = Path(data["path"]["text"])
                if _launcher_search_ignored(path, ignores, roots):
                    continue
                line_number = int(data.get("line_number") or 0)
                text = (data.get("lines", {}).get("text") or "").rstrip()
                excerpt = text[:500]
                excerpt_utf16_length = _utf16_offset(excerpt, len(excerpt))
                submatches = data.get("submatches") or []
                match_ranges = []
                for match in submatches[:8]:
                    start = _utf8_byte_offset_to_utf16(text, int(match["start"]))
                    end = _utf8_byte_offset_to_utf16(text, int(match["end"]))
                    if start >= excerpt_utf16_length:
                        continue
                    match_ranges.append({
                        "start": start,
                        "end": min(end, excerpt_utf16_length),
                    })
                hits.append({
                    "path": str(path),
                    "name": path.name,
                    "parent": str(path.parent),
                    "is_dir": False,
                    "line": line_number,
                    "excerpt": excerpt,
                    "submatches": match_ranges,
                    "score": 1000.0 - len(hits),
                })
                if len(hits) >= limit:
                    process.terminate()
                    break
            except (KeyError, TypeError, ValueError, json.JSONDecodeError):
                continue
    finally:
        with contextlib.suppress(subprocess.TimeoutExpired):
            process.wait(timeout=0.5)
        if process.poll() is None:
            process.kill()
            process.wait()
    return hits


def _launcher_preview(path: Path, lines: int, focus_line: int = 0, query: str = "") -> Dict[str, Any]:
    if not path.exists():
        return {"ok": False, "error": "Path no longer exists", "path": str(path)}
    mime, _ = mimetypes.guess_type(str(path))
    if path.is_dir():
        entries: List[str] = []
        def add_directory(directory: Path, prefix: str, depth: int) -> None:
            if len(entries) >= 200 or depth > 2:
                return
            children = sorted(directory.iterdir(), key=lambda item: (not item.is_dir(), item.name.casefold()))
            for child in children:
                if len(entries) >= 200:
                    return
                entries.append(prefix + ("▸ " if child.is_dir() else "  ") + child.name)
                if child.is_dir() and depth < 2:
                    with contextlib.suppress(OSError):
                        add_directory(child, prefix + "  ", depth + 1)
        try:
            add_directory(path, "", 0)
        except OSError as exc:
            return {"ok": False, "error": str(exc), "path": str(path)}
        return {"ok": True, "kind": "directory", "path": str(path), "mime": "inode/directory", "text": "\n".join(entries)}
    if mime and mime.startswith("image/"):
        return {"ok": True, "kind": "image", "path": str(path), "mime": mime}
    if mime and (mime.startswith("audio/") or mime.startswith("video/")):
        return {"ok": True, "kind": "media", "path": str(path), "mime": mime}
    try:
        with path.open("r", encoding="utf-8", errors="replace") as stream:
            maximum = max(20, min(lines, 1200))
            first_line = max(1, focus_line - 18) if focus_line > 0 else 1
            last_line = first_line + maximum - 1
            selected = []
            for line_number, value in enumerate(stream, 1):
                if line_number < first_line:
                    continue
                if line_number > last_line:
                    break
                selected.append(value)
            content = "".join(selected)
        if "\0" in content:
            return {"ok": True, "kind": "binary", "path": str(path), "mime": mime or "application/octet-stream", "text": "Binary file"}
        return {
            "ok": True,
            "kind": "text",
            "path": str(path),
            "mime": mime or "text/plain",
            "text": content,
            "submatches": _launcher_literal_match_ranges(content, query),
            "start_line": first_line,
            "focus_line": focus_line,
        }
    except OSError as exc:
        return {"ok": False, "error": str(exc), "path": str(path)}


# --- Passwordless sudo toggle -------------------------------------------------
#
# Protocol (owned by VGS; the sudoToggle plugin is the only consumer):
#   /etc/sudoers.d/50-<user>-nopasswd-toggle        the privileged drop-in
#   ~/.local/state/vshell/sudo-passwordless-toggle  unprivileged state mirror
#
# The mirror exists because /etc/sudoers.d is 0750 root:root, so the shell
# cannot read the real state as the logged-in user. Anything that changes the
# drop-in must update the mirror in the same operation.
#
# The mirror can still go stale behind VGS's back — an admin removing the
# drop-in, a cleaned /etc/sudoers.d, a restored home backup. It is therefore
# never allowed to decide a direction: callers pass the direction they showed
# the user (`set on|off`), and the privileged half refuses and re-syncs rather
# than doing the opposite of what was displayed. Inferring the direction
# root-side turned a "revoke" click into a permanent NOPASSWD grant (VGS-11).

SUDO_TOGGLE_FLAG_NAME = "sudo-passwordless-toggle"
# Pre-VGS-11 location, outside the VGS state dir. Read for migration only.
SUDO_TOGGLE_LEGACY_FLAG = ".local/state/sudo-passwordless-toggle"

# `set` exit codes. Keep these distinct: the widget reports a stale-state
# refusal as an informational warning and a terminal failure as an error, so
# one code cannot mean both.
#   0 changed or already correct
#   1 error
#   3 displayed state was stale: nothing changed, mirror re-synced, re-read
#   4 the terminal for the password prompt never came up (TERMINAL_EXIT_FAILED)
SUDO_TOGGLE_EXIT_STALE = 3


def sudo_toggle_dropin(user: str) -> Path:
    return Path("/etc/sudoers.d") / f"50-{user}-nopasswd-toggle"


def sudo_toggle_flag_path() -> Path:
    return state_dir() / SUDO_TOGGLE_FLAG_NAME


def sudo_toggle_legacy_flag_path() -> Path:
    return home() / SUDO_TOGGLE_LEGACY_FLAG


def sudo_toggle_mirror_state() -> bool:
    """What the unprivileged side believes. Never used to pick a direction."""
    return sudo_toggle_flag_path().is_file() or sudo_toggle_legacy_flag_path().is_file()


def sudo_noninteractive_ok(runner: Any = None) -> bool:
    """Does sudo currently run without prompting?

    True means either a NOPASSWD rule (VGS's or someone else's) or a live
    credential cache — sudo cannot distinguish those without invalidating the
    cache, which would be a side effect on the user's session. Callers must
    treat this as "does not prompt right now", not "has a NOPASSWD rule".
    """
    if runner is None:
        def runner() -> int:
            return subprocess.run(
                ["sudo", "-n", "true"],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            ).returncode
    try:
        return runner() == 0
    except Exception:
        return False


def sudo_toggle_availability() -> Tuple[bool, str]:
    """Can this machine run the toggle at all? (unprivileged check)

    Deliberately does NOT require a terminal. A terminal is only needed to
    GRANT; revoking takes the quiet `sudo -n` path, because the drop-in being
    removed is what makes sudo passwordless. Gating both directions on a
    terminal stranded the escalated state in place on machines with none.
    """
    if not shutil.which("sudo"):
        return False, "sudo is not installed"
    if not shutil.which("visudo"):
        return False, "visudo is not installed (usually shipped with sudo)"
    if not Path("/etc/sudoers.d").is_dir():
        return False, "/etc/sudoers.d does not exist; sudo has no drop-in include directory"
    return True, ""


def sudo_toggle_enable_availability() -> Tuple[bool, str]:
    """Extra requirement for the enable direction only: somewhere to prompt."""
    if not have_terminal():
        return False, ("no terminal emulator found for the password prompt; set $TERMINAL or install one of: "
                       + ", ".join(TERMINAL_CANDIDATES))
    return True, ""


def sudo_toggle_apply(dropin: Path, user: str, enable: bool, visudo_bin: str | None) -> Tuple[bool, str]:
    """Create or remove the NOPASSWD drop-in. Runs privileged.

    The candidate file is validated before it is put in place, and the staging
    name deliberately contains a dot: sudo ignores files in sudoers.d whose name
    contains a '.', so a half-written or invalid candidate is never in effect.
    """
    if not enable:
        try:
            dropin.unlink()
        except FileNotFoundError:
            pass
        return True, "Passwordless sudo disabled"
    if dropin.is_symlink():
        return False, f"Refusing to write through a symlink: {dropin}"
    staging = dropin.with_name(dropin.name + f".vgs-tmp-{os.getpid()}")
    try:
        staging.write_text(f"{user} ALL=(ALL) NOPASSWD: ALL\n")
        os.chmod(staging, 0o440)
        if visudo_bin:
            check = subprocess.run(
                [visudo_bin, "-cf", str(staging)],
                text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            )
            if check.returncode != 0:
                detail = (check.stdout or "").strip()
                return False, "sudoers validation failed, no change made" + (f": {detail}" if detail else "")
        else:
            return False, "visudo is unavailable; refusing to install an unvalidated sudoers drop-in"
        staging.replace(dropin)
    finally:
        try:
            staging.unlink()
        except FileNotFoundError:
            pass
    return True, "Passwordless sudo enabled (persistent, no expiry)"


def sudo_toggle_write_flag(enable: bool) -> Tuple[bool, str]:
    """Mirror the privileged state where the shell can read it.

    Usually runs as root after the sudo re-exec, writing into a directory the
    unprivileged user controls, so every component is checked for symlinks and
    nothing here ever follows one: a planted link would otherwise have root
    create and chown an arbitrary path.
    """
    flag = sudo_toggle_flag_path()
    owner_uid, owner_gid = os.getuid(), os.getgid()
    if os.geteuid() == 0:
        try:
            pw = pwd.getpwnam(current_login_user())
            owner_uid, owner_gid = pw.pw_uid, pw.pw_gid
        except KeyError:
            pass

    # Everything below walks the tree with directory file descriptors and
    # O_NOFOLLOW, never by path. Re-resolving a path per component leaves a
    # window where a symlink planted between the check and the use is followed,
    # which would have root create and chown an attacker-named directory.
    relative = flag.relative_to(home()).parts  # (".local", "state", "vshell", "<flag>")
    dirs, name = list(relative[:-1]), relative[-1]

    open_fds: List[int] = []

    def close_all() -> None:
        for fd in reversed(open_fds):
            try:
                os.close(fd)
            except OSError:
                pass

    try:
        try:
            open_fds.append(os.open(home(), os.O_RDONLY | os.O_DIRECTORY))
        except OSError as exc:
            return False, f"could not open the home directory: {exc}"

        def retire_legacy(state_fd: int) -> None:
            """Drop the pre-VGS-11 mirror so one file is the truth.

            Done as soon as the `state` directory is open, not at the end: on a
            revoke where `state/vshell` does not exist the walk returns early,
            and retiring afterwards would leave the legacy flag behind still
            asserting "enabled" — recreating the stale-mirror state the
            direction guard exists to catch.
            """
            try:
                os.unlink(SUDO_TOGGLE_FLAG_NAME, dir_fd=state_fd)
            except (FileNotFoundError, OSError):
                pass

        legacy_parent_index = len(dirs) - 2  # ".local/state" holds the old flag

        for index, part in enumerate(dirs):
            parent = open_fds[-1]
            try:
                open_fds.append(os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent))
                if index == legacy_parent_index:
                    retire_legacy(open_fds[-1])
                continue
            except FileNotFoundError:
                pass
            except NotADirectoryError:
                return False, f"state mirror path component is not a directory: {'/'.join(dirs[:index + 1])}"
            except OSError as exc:
                # ELOOP is what O_NOFOLLOW raises on a symlinked component.
                if exc.errno == errno.ELOOP:
                    return False, ("refusing to write the state mirror through a symlinked directory: "
                                   + "/".join(dirs[:index + 1]))
                return False, f"could not open {'/'.join(dirs[:index + 1])}: {exc}"

            if not enable:
                return True, ""  # nothing to clear if the tree does not exist
            try:
                os.mkdir(part, 0o700, dir_fd=parent)
            except FileExistsError:
                pass  # lost a benign race with ourselves; reopen below
            except OSError as exc:
                return False, f"could not create {'/'.join(dirs[:index + 1])}: {exc}"
            try:
                open_fds.append(os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent))
            except OSError as exc:
                return False, f"could not open {'/'.join(dirs[:index + 1])}: {exc}"
            if index == legacy_parent_index:
                retire_legacy(open_fds[-1])
            try:
                os.chown(part, owner_uid, owner_gid, dir_fd=parent, follow_symlinks=False)
            except (PermissionError, FileNotFoundError, OSError):
                pass

        leaf = open_fds[-1]

        if not enable:
            try:
                os.unlink(name, dir_fd=leaf)
            except FileNotFoundError:
                pass
            except OSError as exc:
                return False, f"could not clear the state mirror {flag}: {exc}"
            return True, ""

        try:
            fd = os.open(name, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o644, dir_fd=leaf)
        except FileExistsError:
            try:
                info = os.lstat(name, dir_fd=leaf)
            except OSError as exc:
                return False, f"could not inspect the state mirror {flag}: {exc}"
            if stat.S_ISLNK(info.st_mode):
                return False, f"refusing to write the state mirror through a symlink: {flag}"
            return True, ""  # already present and a real file: nothing to do
        except OSError as exc:
            return False, f"could not write the state mirror {flag}: {exc}"
        try:
            os.fchown(fd, owner_uid, owner_gid)
        except (PermissionError, OSError):
            pass
        finally:
            os.close(fd)
        return True, ""
    finally:
        close_all()


def sudo_toggle_status(user: str, sudo_probe: Any = None, probe_sudo: bool = True) -> Dict[str, Any]:
    available, reason = sudo_toggle_availability()
    dropin_installed = sudo_toggle_mirror_state()
    if os.geteuid() == 0:
        # Privileged callers can see the truth; the mirror may have drifted.
        dropin_installed = sudo_toggle_dropin(user).is_file()
    if dropin_installed:
        # NOPASSWD: ALL is in force by definition; no need to ask sudo (and no
        # need to put a line in the auth log).
        non_interactive = True
    elif probe_sudo:
        # Probing is always caller-initiated: the widget passes
        # --no-sudo-probe at shell start and only asks for real once the user
        # hovers the control, so a login never touches sudo. Group membership
        # is deliberately NOT used to pre-filter — it would silently skip the
        # probe for a user granted sudo by a direct sudoers rule
        # (`alice ALL=...`), reporting them as prompting when they are not.
        non_interactive = sudo_noninteractive_ok(sudo_probe)
    else:
        non_interactive = False
    can_enable, enable_reason = sudo_toggle_enable_availability()
    return {
        "ok": True,
        "available": available,
        "reason": reason,
        # VGS's own drop-in, as far as this caller can tell.
        "enabled": dropin_installed,
        "dropinInstalled": dropin_installed,
        # Whether sudo prompts right now, from any rule or a cached credential.
        # Lets the widget avoid claiming "disabled" on a machine that is
        # passwordless for other reasons.
        "sudoNonInteractive": non_interactive,
        # Granting additionally needs a terminal to prompt in. Revoking never
        # does, so this must not gate the control as a whole.
        "canEnable": can_enable,
        "enableReason": enable_reason,
        "user": user,
        "dropin": str(sudo_toggle_dropin(user)),
        "flag": str(sudo_toggle_flag_path()),
    }


def sudo_toggle_set(user: str, want: bool) -> int:
    """Privileged half of `set`. Applies only the direction that was asked for."""
    dropin = sudo_toggle_dropin(user)
    actual = dropin.is_file()
    mirror_before = sudo_toggle_mirror_state()

    if want == actual:
        # Nothing to do. If the mirror disagreed, the caller acted on a state
        # the machine was not in, so re-sync and say so instead of pretending
        # the click did what it looked like it would do.
        ok, message = sudo_toggle_write_flag(actual)
        if not ok:
            eprint(f"vshell sudo-toggle: {message}")
            return 1
        if mirror_before != actual:
            eprint("vshell sudo-toggle: passwordless sudo is already "
                   + ("enabled" if actual else "disabled")
                   + "; the shell was showing stale state. Nothing changed; state refreshed.")
            return SUDO_TOGGLE_EXIT_STALE
        print("passwordless sudo already " + ("enabled" if actual else "disabled"))
        return 0

    ok, message = sudo_toggle_apply(dropin, user, want, shutil.which("visudo"))
    if not ok:
        eprint(f"vshell sudo-toggle: {message}")
        return 1
    ok, flag_message = sudo_toggle_write_flag(want)
    if not ok:
        # The privileged change landed; a mirror we could not write would leave
        # the widget lying, so report it rather than exiting 0.
        eprint(f"vshell sudo-toggle: {message}, but {flag_message}")
        return 1
    print(message)
    return 0


def cmd_sudo_toggle(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell sudo-toggle")
    sub = parser.add_subparsers(dest="cmd", required=True)
    p_status = sub.add_parser("status", help="report availability and current state")
    p_status.add_argument("--json", action="store_true")
    p_status.add_argument("--no-sudo-probe", action="store_true",
                          help="skip the `sudo -n true` check for passwordless sudo from other rules")
    p_set = sub.add_parser("set", help="set passwordless sudo to an explicit state")
    p_set.add_argument("state", choices=["on", "off"])
    p_set.add_argument("--terminal", action="store_true",
                       help="re-exec under sudo in a terminal so it can prompt for a password")
    p_toggle = sub.add_parser("toggle", help="flip passwordless sudo (CLI convenience; UIs must use `set`)")
    p_toggle.add_argument("--terminal", action="store_true",
                          help="re-exec under sudo in a terminal so it can prompt for a password")
    args = parser.parse_args(argv)

    user = current_login_user()
    if args.cmd == "status":
        status = sudo_toggle_status(user, probe_sudo=not args.no_sudo_probe)
        if args.json:
            print(json.dumps(status))
        else:
            state = "enabled" if status["enabled"] else "disabled"
            print(f"passwordless sudo: {state}" if status["available"]
                  else f"passwordless sudo: unavailable ({status['reason']})")
        return 0 if status["available"] else 1

    available, reason = sudo_toggle_availability()
    if not available:
        eprint(f"vshell sudo-toggle: {reason}")
        return 1

    if args.cmd == "toggle":
        # Resolve the direction here, then go through the same guarded `set`
        # path. Root-side direction inference is what let a stale mirror turn a
        # revoke into a grant, so there is exactly one place that decides.
        if os.geteuid() == 0:
            want = not sudo_toggle_dropin(user).is_file()
        else:
            want = not sudo_toggle_mirror_state()
        return cmd_sudo_toggle(["set", "on" if want else "off",
                                *(["--terminal"] if args.terminal else [])])

    want = args.state == "on"

    if os.geteuid() != 0:
        if want:
            # Only the grant direction needs somewhere to prompt, and only when
            # we still have to elevate. Checking this for `set off` too meant a
            # machine with no terminal could not revoke an existing grant at
            # all; checking it as root demanded a terminal that is never used.
            can_enable, enable_reason = sudo_toggle_enable_availability()
            if not can_enable:
                eprint(f"vshell sudo-toggle: {enable_reason}")
                return 1
            # Never take the quiet `sudo -n` route to ENABLE. Where sudo already
            # runs without prompting (an admin wheel NOPASSWD rule, a live
            # credential cache), that would install a permanent
            # `NOPASSWD: ALL` from a single bar click with no prompt, no
            # window and no confirmation. Enabling always goes through a
            # terminal so the user sees what is happening and sudo can
            # authenticate.
            return ensure_root_for(["sudo-toggle", "set", "on", "--terminal"], terminal=True) or 0
        # Disabling only ever removes privilege, and while the drop-in is in
        # place sudo needs no password, so the quiet path is safe here and
        # keeps the common "switch it back off" case silent.
        if not args.terminal:
            quiet = ensure_root_for(["sudo-toggle", "set", "off"])
            if quiet is not None and quiet in (0, SUDO_TOGGLE_EXIT_STALE):
                return quiet
        return ensure_root_for(["sudo-toggle", "set", "off", "--terminal"], terminal=True) or 0

    return sudo_toggle_set(user, want)


def cmd_launcher_search(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell launcher-search")
    sub = parser.add_subparsers(dest="cmd", required=True)
    search = sub.add_parser("search")
    search.add_argument("query")
    search.add_argument("--kind", choices=("all", "files", "folders", "text", "zoxide"), default="files")
    search.add_argument("--root", action="append", default=[])
    search.add_argument("--ignore", action="append", default=[])
    search.add_argument("--ignore-mounts", action="store_true")
    search.add_argument("--limit", type=int, default=80)
    preview = sub.add_parser("preview")
    preview.add_argument("path")
    preview.add_argument("--lines", type=int, default=500)
    preview.add_argument("--line", type=int, default=0)
    preview.add_argument("--query", default="")
    opener = sub.add_parser("open-folder")
    opener.add_argument("path")
    opener.add_argument("--command", default="")
    # "nautilus" is the pre-VGS-32 id for what is now the resolved file manager;
    # accepted so a menu opened before an upgrade still opens a folder.
    opener.add_argument("--opener", choices=("default", "yazi", "filemanager", "nautilus"), default="default")
    sub.add_parser("openers")
    sub.add_parser("status")
    args = parser.parse_args(argv)
    if args.cmd == "status":
        print(json.dumps({
            "ok": True,
            "fd": shutil.which("fd") or shutil.which("fdfind") or "",
            "ripgrep": shutil.which("rg") or "",
            "backend": "fd+ripgrep",
        }))
        return 0
    if args.cmd == "preview":
        print(json.dumps(_launcher_preview(
            Path(args.path).expanduser(), args.lines, args.line, args.query
        ), ensure_ascii=False))
        return 0
    if args.cmd == "openers":
        print(json.dumps({"ok": True, "openers": _launcher_folder_openers()}, ensure_ascii=False))
        return 0
    if args.cmd == "open-folder":
        result = _launcher_open_folder(args.path, args.command, args.opener)
        print(json.dumps(result, ensure_ascii=False))
        return 0 if result.get("ok") else 1
    roots = [Path(value).expanduser().resolve() for value in args.root] if args.root else [home()]
    roots = [root for root in roots if root.is_dir()]
    if not roots:
        print(json.dumps({"ok": False, "error": "No searchable roots"}))
        return 1
    limit = max(1, min(args.limit, 300))
    if args.kind == "text":
        hits = _launcher_search_text_hits(args.query, roots, args.ignore, limit, args.ignore_mounts)
    elif args.kind == "zoxide":
        hits = _launcher_zoxide_hits(args.query, limit)
    else:
        hits = _launcher_search_name_hits(args.query, args.kind, roots, args.ignore, limit, args.ignore_mounts)
    print(json.dumps({"ok": True, "kind": args.kind, "query": args.query, "hits": hits}, ensure_ascii=False))
    return 0


# --- notification daemon ownership -----------------------------------------
#
# org.freedesktop.Notifications is a first-come, first-served session bus name.
# VGS registers it from Services/NotificationService.qml, but any notification
# daemon already installed on the system can take it first -- usually without
# the user ever starting it, because a D-Bus activation file makes the *first
# notification sent after login* start the daemon. The loser gets a journal
# warning and nothing else, so the shell looks like it simply has no
# notifications. These helpers let VGS name the winner, and take the name back.

NOTIFICATION_BUS_NAME = "org.freedesktop.Notifications"
NOTIFICATION_SHADOW_MARKER = "# vshell notifications takeover"
# Who asked for a takeover. Recorded in the undo state, not in settings: the
# provenance belongs with the changes it describes, so it cannot outlive them
# or be contradicted by a restore run from the CLI.
NOTIFICATION_INITIATORS = ("first-run", "manual")

# Process name -> label, for readable output only. Detection never consults
# this list: it works from who owns the bus name and from which activation
# files claim it, so a daemon nobody has heard of is still found.
KNOWN_NOTIFICATION_DAEMONS: Dict[str, str] = {
    "mako": "mako",
    "dunst": "dunst",
    "swaync": "swaync",
    "swaync-notification-center": "swaync",
    "xfce4-notifyd": "xfce4-notifyd",
    "notification-daemon": "notification-daemon",
    "notify-osd": "notify-osd",
    "deadd-notification-center": "deadd-notification-center",
}


def notification_state_file() -> Path:
    return state_dir() / "notification-takeover.json"


def _xdg_data_home() -> Path:
    value = os.environ.get("XDG_DATA_HOME", "").strip()
    return Path(value) if value.startswith("/") else home() / ".local" / "share"


def _xdg_data_dirs() -> List[Path]:
    """Data directories in D-Bus activation precedence order, home first."""
    dirs = [_xdg_data_home()]
    raw = os.environ.get("XDG_DATA_DIRS", "").strip() or "/usr/local/share:/usr/share"
    for entry in raw.split(":"):
        entry = entry.strip()
        if entry.startswith("/") and Path(entry) not in dirs:
            dirs.append(Path(entry))
    return dirs


# The bus answers "nobody owns that name" with an error, so a failed call has
# to be classified. Reporting a broken probe as an unowned bus would be the
# same silent failure this subsystem exists to remove.
# Phrasing differs by bus implementation and busctl version: dbus-daemon says
# "no such name", the systemd/dbus-broker path says "The name does not have an
# owner", and the wire error is NameHasNoOwner. All three mean unowned.
_BUS_NO_SUCH_NAME = re.compile(r"no such name|does not have an owner|NameHasNoOwner", re.IGNORECASE)


def _session_bus_call(member: str, signature: str, *args: str) -> Dict[str, Any]:
    """Call a session bus driver method.

    Returns ``{"value": <reply>, "error": ""}`` on success, ``{"value": None,
    "error": ""}`` when the bus says the name has no owner, and a non-empty
    ``error`` for everything else -- busctl missing, a timeout, an unparseable
    reply. Callers must keep those three apart: "no owner" is a fact about the
    session, the rest are facts about the probe.
    """
    try:
        proc = run([
            "busctl", "--user", "--json=short", "call",
            "org.freedesktop.DBus", "/org/freedesktop/DBus",
            "org.freedesktop.DBus", member, signature, *args,
        ], timeout=5)
    except FileNotFoundError:
        return {"value": None, "error": "busctl is not installed"}
    except (OSError, subprocess.SubprocessError) as exc:
        return {"value": None, "error": f"busctl {member} failed: {exc}"}
    if proc.returncode != 0:
        message = (proc.stderr or "").strip() or f"busctl {member} exited {proc.returncode}"
        if _BUS_NO_SUCH_NAME.search(message):
            return {"value": None, "error": ""}
        return {"value": None, "error": message}
    try:
        payload = json.loads(proc.stdout or "{}")
    except ValueError:
        return {"value": None, "error": f"busctl {member} returned unparseable output"}
    data = payload.get("data")
    return {"value": data[0] if isinstance(data, list) and data else None, "error": ""}


def _systemctl_user(argv: List[str], timeout: float = 10.0) -> subprocess.CompletedProcess[str]:
    try:
        return run(["systemctl", "--user", *argv], timeout=timeout)
    except (OSError, subprocess.SubprocessError) as exc:
        return subprocess.CompletedProcess(argv, 1, "", str(exc))


def _proc_text(pid: int, name: str) -> str:
    try:
        return (_proc_root() / str(pid) / name).read_text(errors="replace").strip()
    except OSError:
        return ""


def _proc_cmdline(pid: int) -> str:
    raw = _proc_text(pid, "cmdline")
    return " ".join(part for part in raw.split("\0") if part)


def _proc_exe(pid: int) -> str:
    try:
        return os.readlink(_proc_root() / str(pid) / "exe")
    except OSError:
        return ""


def _proc_unit(pid: int) -> str:
    """The systemd unit a pid runs under, or "" when it runs under none.

    busctl's own Unit= field reports the *session* unit (user@N.service) for
    everything on the user bus, which cannot be stopped, so the unit is read
    from the cgroup leaf instead.
    """
    for line in reversed(_proc_text(pid, "cgroup").splitlines()):
        leaf = line.rpartition(":")[2].rstrip("/").rpartition("/")[2]
        if leaf.endswith(".service") or leaf.endswith(".scope"):
            return leaf
    return ""


def _unit_is_transient(unit: str) -> bool:
    """True for the throwaway units D-Bus activation creates per connection.

    dbus-broker names them `dbus-:1.42-fr.emersion.mako@0.service`. They can be
    stopped, but masking one is pointless: the next activation gets a new name.
    """
    return unit.startswith("dbus-") and "@" in unit


def _user_unit_state(unit: str) -> Dict[str, Any]:
    empty = {
        "exists": False, "running": False, "masked": False, "transient": False,
        "mainPid": 0, "execStart": "",
    }
    if not unit:
        return empty
    proc = _systemctl_user([
        "show", unit, "--property=LoadState", "--property=ActiveState",
        "--property=UnitFileState", "--property=MainPID", "--property=ExecStart",
    ])
    values: Dict[str, str] = {}
    for line in (proc.stdout or "").splitlines():
        key, sep, value = line.partition("=")
        if sep and key.strip() not in values:
            # ExecStart's own value contains "=", so only the first assignment
            # of each property name is the property.
            values[key.strip()] = value.strip()
    load = values.get("LoadState", "")
    try:
        main_pid = int(values.get("MainPID", "0"))
    except ValueError:
        main_pid = 0
    return {
        "exists": load in {"loaded", "masked"},
        "running": values.get("ActiveState", "") in {"active", "activating", "reloading"},
        "masked": load == "masked" or values.get("UnitFileState", "") in {"masked", "masked-runtime"},
        "transient": _unit_is_transient(unit),
        "mainPid": main_pid,
        "execStart": values.get("ExecStart", ""),
    }


_EXEC_PATH_RE = re.compile(r"path=([^\s;]+)")


def _unit_runs_this_daemon(unit_state: Dict[str, Any], pid: int, exe: str, process: str) -> bool:
    """Whether a unit is the daemon's own, rather than one it merely sits in.

    A cgroup leaf answers "which unit is this process inside", never "which
    unit is this process". A notification daemon started from a compositor rule
    (`exec-once = mako`) has no unit of its own and inherits the compositor's:
    on Hyprland + uwsm that leaf is `wayland-wm@hyprland.desktop.service`.
    Masking and stopping that would end the graphical session and block the
    next login, so a unit is only ever acted on when it demonstrably runs this
    daemon -- its MainPID is the owner, or its ExecStart names the owner's
    binary. Everything else is reported for the user to handle.
    """
    if not unit_state["exists"]:
        return False
    if pid and unit_state["mainPid"] == pid:
        return True
    exec_start = unit_state["execStart"]
    if not exec_start:
        return False
    names = {name for name in (Path(exe).name if exe else "", process) if name}
    if not names:
        return False
    return any(
        Path(path).name in names or path == exe
        for path in _EXEC_PATH_RE.findall(exec_start)
    )


def _parse_dbus_service_file(path: Path) -> Dict[str, str]:
    values: Dict[str, str] = {}
    try:
        text = path.read_text(errors="replace")
    except OSError:
        return values
    section = ""
    for line in text.splitlines():
        line = line.strip()
        if not line or line[0] in "#;":
            continue
        if line.startswith("[") and line.endswith("]"):
            section = line[1:-1].strip()
            continue
        if section != "D-BUS Service":
            continue
        key, sep, value = line.partition("=")
        if sep:
            values[key.strip()] = value.strip()
    return values


def notification_activation_files() -> List[Dict[str, Any]]:
    """Activation files claiming the notification bus name, in bus precedence.

    Only the first file of a given name is effective; the same name in a later
    data directory is shadowed by it. That is exactly the mechanism `takeover`
    uses, by writing its own file into the user's data home.
    """
    out: List[Dict[str, Any]] = []
    seen: Dict[str, bool] = {}
    shadow_dir = _xdg_data_home() / "dbus-1" / "services"
    for directory in _xdg_data_dirs():
        services = directory / "dbus-1" / "services"
        try:
            entries = sorted(services.glob("*.service"))
        except OSError:
            continue
        for path in entries:
            values = _parse_dbus_service_file(path)
            if values.get("Name") != NOTIFICATION_BUS_NAME:
                continue
            is_shadow = False
            if path.parent == shadow_dir:
                try:
                    is_shadow = NOTIFICATION_SHADOW_MARKER in path.read_text(errors="replace")
                except OSError:
                    is_shadow = False
            out.append({
                "path": str(path),
                "file": path.name,
                "exec": values.get("Exec", ""),
                "systemdService": values.get("SystemdService", ""),
                "shadow": is_shadow,
                "effective": path.name not in seen,
            })
            seen[path.name] = True
    return out


def _owner_is_vgs(unit: str, process: str, cmdline: str) -> bool:
    if unit == "vshell.service":
        return True
    return process in QS_BINARIES and "vshell" in cmdline


def notification_owner() -> Dict[str, Any]:
    """Who currently holds org.freedesktop.Notifications on the session bus.

    ``error`` non-empty means the question could not be answered -- which is
    never the same as "nobody owns it", and never the same as "someone else
    owns it" either.
    """
    empty = {
        "present": False, "unique": "", "pid": 0, "process": "",
        "exe": "", "cmdline": "", "unit": "", "isVgs": False, "error": "",
    }
    call = _session_bus_call("GetNameOwner", "s", NOTIFICATION_BUS_NAME)
    if call["error"]:
        return {**empty, "error": call["error"]}
    unique = call["value"]
    if not isinstance(unique, str) or not unique:
        return empty
    pid_call = _session_bus_call("GetConnectionUnixProcessID", "s", unique)
    try:
        pid = int(pid_call["value"])
    except (TypeError, ValueError):
        pid = 0
    if not pid:
        # The name is held, but by whom cannot be established -- so neither
        # "VGS owns it" nor "something else owns it" may be asserted.
        return {
            **empty, "present": True, "unique": unique,
            "error": pid_call["error"] or f"could not identify the process behind {unique}",
        }
    process = _proc_text(pid, "comm") if pid else ""
    cmdline = _proc_cmdline(pid) if pid else ""
    unit = _proc_unit(pid) if pid else ""
    return {
        "present": True,
        "unique": unique,
        "pid": pid,
        "process": process,
        "exe": _proc_exe(pid) if pid else "",
        "cmdline": cmdline,
        "unit": unit,
        "isVgs": _owner_is_vgs(unit, process, cmdline),
        "error": "",
    }


def _unit_stem(unit: str) -> str:
    """`mako.service` / `mako.scope` -> `mako`. _proc_unit returns either."""
    for suffix in (".service", ".scope"):
        if unit.endswith(suffix):
            return unit[: -len(suffix)]
    return unit


def _daemon_label(process: str, exec_line: str, unit: str) -> str:
    for candidate in (process, Path(exec_line.split(" ")[0]).name if exec_line else "", _unit_stem(unit)):
        if candidate and candidate in KNOWN_NOTIFICATION_DAEMONS:
            return KNOWN_NOTIFICATION_DAEMONS[candidate]
    return process or (Path(exec_line.split(" ")[0]).name if exec_line else "") or unit or "unknown"


def _manual_reason(conflict: Dict[str, Any]) -> str:
    """Why VGS will not stop this daemon itself."""
    unit = conflict["unit"]
    if unit and conflict["unitExists"]:
        return (f"it runs inside {unit}, which is not its own unit -- stopping that would take "
                "the rest of the session with it; quit the daemon the way it was started")
    if unit:
        return f"its unit {unit} is not loaded; quit the daemon the way it was started"
    return "it was not started by a user unit; quit it the way it was started"


def vgs_notification_server_enabled() -> bool:
    """Whether the user asked VGS to be the notification daemon at all.

    Turning the shell's server off is a supported choice, so the CLI must not
    then describe another daemon owning the bus name as a fault to fix.
    """
    try:
        value = load_settings().get("notificationServerEnabled", True)
    except Exception:
        return True
    return bool(value) if isinstance(value, bool) else True


def vgs_first_run_takeover_done() -> bool:
    """Whether settings.json ON DISK records the first-run one-shot as spent.

    The shell cannot answer this about itself. `SettingsData.set()` updates the
    in-memory property and asks FileView to save; an unwritable settings.json
    (read-only home, full disk) fails that save without changing the property,
    so the shell believes the one-shot is spent while the next process reads it
    as unspent -- and the automatic takeover masks and stops the user's daemon
    again on every start.

    This is read here, in a separate process, because that is exactly the claim
    that has to be true: the NEXT process must see it. False on any error --
    unreadable is not evidence of a durable write, and the shell refuses to
    take over without one.
    """
    try:
        value = load_settings().get("notificationFirstRunTakeoverDone", False)
    except Exception:
        return False
    return value is True


def notification_status() -> Dict[str, Any]:
    """Bus ownership, every foreign claimant found, and what can be done."""
    owner = notification_owner()
    activations = notification_activation_files()
    conflicts: List[Dict[str, Any]] = []
    by_key: Dict[str, Dict[str, Any]] = {}

    def record(key: str, entry: Dict[str, Any]) -> Dict[str, Any]:
        existing = by_key.get(key)
        if existing is not None:
            for name, value in entry.items():
                if value not in ("", 0, False, None) or name not in existing:
                    existing[name] = value
            return existing
        by_key[key] = entry
        conflicts.append(entry)
        return entry

    if owner["present"] and not owner["isVgs"]:
        unit = owner["unit"]
        unit_state = _user_unit_state(unit)
        # The cgroup leaf is where the daemon runs, not necessarily its own
        # unit; see _unit_runs_this_daemon. An inherited unit is reported and
        # never acted on.
        unit_owned = _unit_runs_this_daemon(unit_state, owner["pid"], owner["exe"], owner["process"])
        record(unit or owner["exe"] or owner["unique"], {
            "daemon": _daemon_label(owner["process"], owner["exe"], unit),
            "holdsName": True,
            "pid": owner["pid"],
            "process": owner["process"],
            "exe": owner["exe"],
            "unit": unit,
            "unitExists": unit_state["exists"],
            "unitControls": unit_owned,
            "unitRunning": unit_state["running"],
            "unitMasked": unit_state["masked"],
            "unitTransient": unit_state["transient"],
            "activationFile": "",
            "shadowed": False,
        })

    shadow_names = {entry["file"] for entry in activations if entry["shadow"]}
    for entry in activations:
        if entry["shadow"]:
            continue
        shadowed = entry["file"] in shadow_names
        # A file behind VGS's own shadow is still worth reporting -- its unit
        # may be running -- but a plain duplicate in a later data directory is
        # unreachable and says nothing.
        if not entry["effective"] and not shadowed:
            continue
        unit = entry["systemdService"]
        exec_name = Path(entry["exec"].split(" ")[0]).name if entry["exec"] else ""
        if unit == "vshell.service" or exec_name in QS_BINARIES:
            continue  # VGS's own activation file, if one is ever shipped
        unit_state = _user_unit_state(unit)
        record(unit or exec_name or entry["file"], {
            "daemon": _daemon_label(exec_name, entry["exec"], unit),
            "holdsName": False,
            "pid": 0,
            "process": exec_name,
            "exe": entry["exec"],
            "unit": unit,
            "unitExists": unit_state["exists"],
            # SystemdService in the daemon's own activation file is a
            # declaration by the daemon that this unit is its own.
            "unitControls": unit_state["exists"],
            "unitRunning": unit_state["running"],
            "unitMasked": unit_state["masked"],
            "unitTransient": unit_state["transient"],
            "activationFile": entry["path"],
            "shadowed": shadowed,
        })

    if owner["error"]:
        # Neither "VGS owns it" nor "something else does" may be claimed from a
        # probe that did not answer. The shell keeps retrying on this state.
        state = "unknown"
    elif owner["present"]:
        state = "vgs" if owner["isVgs"] else "foreign"
    else:
        state = "unowned"

    actionable = [
        conflict for conflict in conflicts
        if (conflict["activationFile"] and not conflict["shadowed"])
        or (conflict["unitControls"] and (conflict["unitRunning"] or not conflict["unitMasked"]))
    ]
    manual = [
        conflict for conflict in conflicts
        if conflict["holdsName"] and not conflict["unitControls"]
    ]
    if actionable:
        reason = ""
    elif manual:
        reason = _manual_reason(manual[0])
    elif conflicts:
        reason = "every conflicting daemon is already masked and shadowed"
    else:
        reason = "no conflicting notification daemon found"

    record_state = _load_takeover_record()
    return {
        "busName": NOTIFICATION_BUS_NAME,
        "state": state,
        "error": owner["error"],
        "vgsServerEnabled": vgs_notification_server_enabled(),
        # Read from settings.json on disk, so the shell can confirm its own
        # one-shot spend actually persisted before acting on it.
        "vgsFirstRunTakeoverDone": vgs_first_run_takeover_done(),
        # VGS holds the name now, but something else would still be activated
        # into it on a session where VGS starts a moment later.
        "atRisk": state == "vgs" and bool(conflicts),
        "owner": owner,
        "conflicts": conflicts,
        "takeover": {"available": bool(actionable), "reason": reason},
        "restore": {
            "available": bool(record_state["shadows"] or record_state["masked"]
                              or record_state["stopped"] or record_state["backups"]),
            # Whether the changes waiting to be undone are ones VGS made on its
            # own initiative. The shell reads this instead of a runtime flag,
            # so an opt-out after a restart still reverses a first-run takeover.
            "initiator": record_state["initiator"],
            "automatic": record_state["initiator"] == "first-run",
            "shadows": record_state["shadows"],
            "masked": record_state["masked"],
            "stopped": record_state["stopped"],
            "backups": record_state["backups"],
        },
    }


def _load_takeover_record() -> Dict[str, Any]:
    """What a previous takeover changed: shadows written, units masked, the
    user files those shadows displaced (shadow path -> saved original), and who
    asked for it.

    `initiator` is "first-run" when the shell took the name on its own
    initiative and "manual" when a person did. The shell reverses only what it
    did unasked, and it cannot remember across a restart -- but the record can,
    because it lives beside the very changes it describes. An unrecognised or
    absent value reads as "manual": a record VGS did not label is not one VGS
    can claim to have made.
    """
    try:
        data = json.loads(notification_state_file().read_text())
    except (OSError, ValueError):
        data = {}
    shadows = [str(item) for item in data.get("shadows", []) if isinstance(item, str)]
    masked = [str(item) for item in data.get("masked", []) if isinstance(item, str)]
    stopped = [str(item) for item in data.get("stopped", []) if isinstance(item, str)]
    raw_backups = data.get("backups")
    backups = {
        str(key): str(value)
        for key, value in (raw_backups.items() if isinstance(raw_backups, dict) else [])
        if isinstance(key, str) and isinstance(value, str)
    }
    initiator = data.get("initiator")
    if initiator not in NOTIFICATION_INITIATORS:
        initiator = "manual"
    return {"shadows": shadows, "masked": masked, "stopped": stopped,
            "backups": backups, "initiator": initiator}


def _takeover_record_has_changes(record: Dict[str, Any]) -> bool:
    """Whether the record describes anything a restore would have to undo.

    An empty record is not a takeover; it is the absence of one, which is why
    _save_takeover_record() deletes the file rather than writing it, and why an
    automatic takeover may stamp its initiator only when this is False.
    """
    return bool(record["shadows"] or record["masked"]
                or record.get("stopped") or record.get("backups"))


def _save_takeover_record(record: Dict[str, Any]) -> str:
    """Persist the undo record; returns "" on success or the failure message.

    The caller must surface a non-empty return: a takeover that masked units
    and wrote shadows but could not record them is not reversible, and
    reporting it as success strands the user with changes restore cannot find.
    """
    path = notification_state_file()
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        # Saved originals outlive their shadows: a backup whose restore failed
        # is still owed to the user, so the record only goes away when there is
        # genuinely nothing left to undo.
        if not _takeover_record_has_changes(record):
            path.unlink(missing_ok=True)
            return ""
        tmp = path.with_suffix(".tmp")
        tmp.write_text(json.dumps(record, indent=2) + "\n")
        tmp.chmod(0o600)
        os.replace(tmp, path)
    except OSError as exc:
        message = f"could not record takeover state in {path}: {exc}"
        eprint(f"vshell notifications: {message}")
        return message
    return ""


def _write_activation_shadow(source: Path) -> Dict[str, Any]:
    """Shadow a system activation file with an inert one in the data home.

    D-Bus resolves an activation file name from the data directories in order
    and stops at the first hit, so a same-named file in XDG_DATA_HOME wins.
    Exec is deliberately a no-op: VGS owns the name whenever it runs, and when
    it does not, failing the activation is honest -- silently starting the
    daemon VGS was asked to displace is what this exists to prevent.

    The file being shadowed can itself live in the data home, i.e. one the user
    installed by hand. Writing over it would destroy it and leave restore with
    nothing to put back, so it is moved aside first and recorded.

    Returns {"path": Path|None, "backup": str, "error": str}.
    """
    target_dir = _xdg_data_home() / "dbus-1" / "services"
    target = target_dir / source.name
    backup = ""
    body = (
        f"{NOTIFICATION_SHADOW_MARKER}\n"
        f"# Shadows {source} so the session bus does not activate a second\n"
        f"# notification daemon into {NOTIFICATION_BUS_NAME} ahead of VGS.\n"
        "# Undo with: vshell notifications restore\n"
        "[D-BUS Service]\n"
        f"Name={NOTIFICATION_BUS_NAME}\n"
        f"Exec={shutil.which('false') or '/bin/false'}\n"
    )
    try:
        target_dir.mkdir(parents=True, exist_ok=True)
        if os.path.lexists(target):
            try:
                existing = target.read_text(errors="replace")
            except OSError:
                existing = ""
            if NOTIFICATION_SHADOW_MARKER not in existing:
                keep = target.with_name(target.name + ".vgs-orig")
                index = 1
                while os.path.lexists(keep):
                    index += 1
                    keep = target.with_name(f"{target.name}.vgs-orig{index}")
                os.replace(target, keep)
                backup = str(keep)
        tmp = target.with_suffix(".service.tmp")
        tmp.write_text(body)
        tmp.chmod(0o644)
        os.replace(tmp, target)
    except OSError as exc:
        eprint(f"vshell notifications: could not shadow {source}: {exc}")
        return {"path": None, "backup": backup, "error": str(exc)}
    return {"path": target, "backup": backup, "error": ""}


def notification_takeover(automatic: bool = False) -> Dict[str, Any]:
    """Make VGS the session's notification daemon, reversibly.

    Nothing is killed: the conflicting unit is masked and stopped, so it
    releases the bus name and Quickshell's pending registration wins it back on
    its own. A daemon that is not a user unit cannot be stopped safely from
    here and is reported for the user to quit.

    `automatic` marks the undo record as VGS's own doing, so a later opt-out
    can reverse it even from a shell that has restarted since. Once set it
    stays set for as long as the record does: a record that mixes an automatic
    takeover with a later manual one cannot be unpicked, and reversing all of
    it is what keeps a notification daemon running.
    """
    status = notification_status()
    record = _load_takeover_record()
    # Only a record being CREATED may be stamped. An existing record's
    # provenance is history: relabelling a takeover the user ran themselves as
    # "first-run" would let the shell reverse a change they made deliberately,
    # which is the one thing the initiator exists to prevent.
    if automatic and not _takeover_record_has_changes(record):
        record["initiator"] = "first-run"
    actions: List[str] = []
    manual: List[str] = []
    failures: List[str] = []

    for conflict in status["conflicts"]:
        source = conflict["activationFile"]
        if source and not conflict["shadowed"]:
            written = _write_activation_shadow(Path(source))
            if written["backup"]:
                # Recorded before anything else can fail: restore must be able
                # to find a displaced user file even if the rest goes wrong.
                record["backups"][str(written["path"] or source)] = written["backup"]
                actions.append(f"saved the existing {source} as {written['backup']}")
            if written["path"] is None:
                failures.append(f"could not shadow {source}: {written['error']}")
            else:
                if str(written["path"]) not in record["shadows"]:
                    record["shadows"].append(str(written["path"]))
                actions.append(f"shadowed D-Bus activation of {conflict['daemon']} ({source})")

        unit = conflict["unit"]
        if conflict["unitControls"] and not conflict["unitMasked"] and not conflict["unitTransient"]:
            proc = _systemctl_user(["mask", unit])
            if proc.returncode == 0:
                if unit not in record["masked"]:
                    record["masked"].append(unit)
                actions.append(f"masked {unit}")
            else:
                failures.append(f"could not mask {unit}: {(proc.stderr or '').strip()}")
        if conflict["unitControls"] and conflict["unitRunning"]:
            proc = _systemctl_user(["stop", unit])
            if proc.returncode == 0:
                if unit not in record["stopped"]:
                    record["stopped"].append(unit)
                actions.append(f"stopped {unit}")
            else:
                failures.append(f"could not stop {unit}: {(proc.stderr or '').strip()}")
        if conflict["holdsName"] and not conflict["unitControls"]:
            manual.append(
                f"{conflict['daemon']} (pid {conflict['pid']}) holds {NOTIFICATION_BUS_NAME}: "
                + _manual_reason(conflict)
            )

    save_error = _save_takeover_record(record)
    if save_error:
        failures.append(save_error + " -- undo the changes above by hand")

    result = notification_status()
    result["actions"] = actions
    result["manual"] = manual
    result["failures"] = failures
    result["ok"] = not failures
    return result


def notification_restore() -> Dict[str, Any]:
    """Undo a previous takeover: drop the shadows, put back anything they
    displaced, and unmask what was masked."""
    record = _load_takeover_record()
    actions: List[str] = []
    failures: List[str] = []

    kept_shadows: List[str] = []
    kept_backups: Dict[str, str] = dict(record["backups"])
    for entry in record["shadows"]:
        path = Path(entry)
        try:
            text = path.read_text(errors="replace")
        except OSError:
            text = None  # already gone; a saved original may still be owed
        if text is not None and NOTIFICATION_SHADOW_MARKER not in text:
            kept_shadows.append(entry)
            failures.append(f"{entry} was replaced by something else; left alone")
            continue
        if text is not None:
            try:
                path.unlink()
                actions.append(f"removed {entry}")
            except OSError as exc:
                kept_shadows.append(entry)
                failures.append(f"could not remove {entry}: {exc}")
                continue

        backup = kept_backups.get(entry, "")
        if backup and os.path.lexists(backup):
            try:
                os.replace(backup, path)
                actions.append(f"restored {entry} from {backup}")
                kept_backups.pop(entry, None)
            except OSError as exc:
                failures.append(f"could not restore {entry} from {backup}: {exc}")
        elif backup:
            kept_backups.pop(entry, None)
            failures.append(f"the saved original {backup} is gone; {entry} was not restored")

    kept_masks: List[str] = []
    for unit in record["masked"]:
        proc = _systemctl_user(["unmask", unit])
        if proc.returncode == 0:
            actions.append(f"unmasked {unit}")
        else:
            kept_masks.append(unit)
            failures.append(f"could not unmask {unit}: {(proc.stderr or '').strip()}")

    # Undoing a takeover means the daemon runs again. Unmasking alone only
    # makes that possible at the next login, which is not what "restore" says
    # on the tin -- so anything takeover stopped is started again here, after
    # the unmask that would otherwise refuse it.
    kept_stops: List[str] = []
    for unit in record["stopped"]:
        proc = _systemctl_user(["start", unit])
        if proc.returncode == 0:
            actions.append(f"started {unit}")
        else:
            kept_stops.append(unit)
            failures.append(f"could not start {unit}: {(proc.stderr or '').strip()}")

    # Provenance is carried through: whatever could not be undone was still
    # VGS's doing if it was VGS's doing, and a later opt-out has to be able to
    # try again.
    save_error = _save_takeover_record({
        "shadows": kept_shadows, "masked": kept_masks,
        "stopped": kept_stops, "backups": kept_backups,
        "initiator": record["initiator"],
    })
    if save_error:
        failures.append(save_error)

    result = notification_status()
    result["actions"] = actions
    result["manual"] = []
    result["failures"] = failures
    result["ok"] = not failures
    return result


def _print_notification_status(status: Dict[str, Any]) -> None:
    owner = status["owner"]
    vgs_wants_it = status["vgsServerEnabled"]
    if status["state"] == "unknown":
        print(f"{NOTIFICATION_BUS_NAME}: could not be determined: {status['error']}")
    elif status["state"] == "vgs":
        print(f"{NOTIFICATION_BUS_NAME}: VGS (pid {owner['pid']})")
    elif status["state"] == "foreign":
        where = owner["unit"] or owner["exe"] or "unknown"
        label = _daemon_label(owner["process"], owner["exe"], owner["unit"])
        # Another daemon owning the name is only a fault when VGS was asked to
        # be the notification daemon in the first place.
        suffix = " -- VGS notifications are inert" if vgs_wants_it else " (VGS notification server turned off in settings)"
        print(f"{NOTIFICATION_BUS_NAME}: {label} (pid {owner['pid']}, {where}){suffix}")
    else:
        print(f"{NOTIFICATION_BUS_NAME}: unowned")
    for conflict in status["conflicts"]:
        detail = []
        if conflict["unit"]:
            detail.append(conflict["unit"] + (" (masked)" if conflict["unitMasked"] else ""))
        if conflict["activationFile"]:
            detail.append("activation " + conflict["activationFile"] + (" (shadowed)" if conflict["shadowed"] else ""))
        print(f"  conflict: {conflict['daemon']}" + (": " + ", ".join(detail) if detail else ""))
    if vgs_wants_it and (status["state"] != "vgs" or status["atRisk"]):
        if status["takeover"]["available"]:
            print("  fix: vshell notifications takeover")
        elif status["takeover"]["reason"]:
            print(f"  note: {status['takeover']['reason']}")


# --- Remote desktop host (Sunshine) -----------------------------------------
#
# Sunshine is deliberately NOT autostarted: its user unit ships `disabled`, and
# the virtual output it captures is created on start and removed on stop, so a
# local-only session carries no phantom monitor and nothing listening.
#
# Those two halves are ONE operation and must never be split. Sunshine picks
# its capture target at startup, so starting the unit without the headless
# output first makes it fall back to the first real monitor -- the user's own
# screen is streamed instead of the virtual one, with no error logged anywhere
# and nothing on screen to notice. That silence is why the lifecycle lives here
# rather than in QML: `vshell remote-desktop start` is the only supported way
# to bring the host up, and no VGS surface may call `systemctl start` on this
# unit directly.

RD_UNIT = "app-dev.lizardbyte.app.Sunshine.service"
RD_OUTPUT = "HEADLESS-1"
RD_WEB_PORT = 47990

_RD_ENCODER_RE = re.compile(r"Creating encoder \[([A-Za-z0-9_.+-]+)\]")
_RD_BITRATE_RE = re.compile(r"Streaming bitrate is (\d+)")
_RD_DEPTH_RE = re.compile(r"Color depth: (\S+)")
_RD_SESSIONS_RE = re.compile(r"active sessions: (\d+)")
_RD_ISO_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})")
_RD_SYSTEMD_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")


def _rd_hypr_env() -> Dict[str, str]:
    """hyprctl needs an instance signature, and an ssh shell inherits none."""
    env = dict(os.environ)
    env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
    if env.get("HYPRLAND_INSTANCE_SIGNATURE"):
        return env
    try:
        instances = sorted(
            (p for p in (Path(env["XDG_RUNTIME_DIR"]) / "hypr").iterdir() if p.is_dir()),
            key=lambda p: p.stat().st_mtime,
            reverse=True,
        )
    except OSError:
        return env
    if instances:
        env["HYPRLAND_INSTANCE_SIGNATURE"] = instances[0].name
    return env


def _rd_output_present() -> Any:
    """True/False when hyprctl answers, None when it cannot be asked.

    None is a first-class answer, not a variant of False. "The output is
    missing" and "nobody could say" lead to opposite decisions in
    remote_desktop_start(), and collapsing them is what would start the host
    onto a real monitor.
    """
    if not command_exists("hyprctl"):
        return None
    try:
        proc = run(["hyprctl", "monitors", "-j"], timeout=3, env=_rd_hypr_env())
    except (OSError, subprocess.SubprocessError):
        return None
    if proc.returncode != 0:
        return None
    try:
        monitors = json.loads(proc.stdout or "[]")
    except (ValueError, TypeError):
        return None
    if not isinstance(monitors, list):
        return None
    return any(isinstance(m, dict) and m.get("name") == RD_OUTPUT for m in monitors)


def _rd_manages_output() -> Dict[str, Any]:
    """Whether VGS can manage the virtual output here, and why not if it cannot.

    `detect_compositor()` answers from THIS process's environment, and an ssh
    session has none of it -- no Wayland socket owner, no instance signature --
    so it reports "unknown". Treating unknown as "not Hyprland" is how starting
    the host over ssh skipped the output entirely and let the capture fall back
    to a real monitor. Starting over ssh is a PRIMARY way a remote-desktop host
    gets used, so that is not an edge case.

    Unknown therefore means ASK, using the same ssh-aware environment
    `_rd_output_present()` already uses. Three sub-cases, graded by how much
    they leave in doubt:

    * hyprctl not installed -> this is definitely not Hyprland. Proceed without
      an output and say so.
    * hyprctl installed but no instance in the runtime dir -> no Hyprland is
      running. Same.
    * an instance IS there but hyprctl will not answer -> refuse. There is a
      Hyprland session here and we cannot talk to it, so we cannot rule out
      capturing a real monitor -- the same reasoning as the `present is None`
      refusal, and the opposite of guessing.
    """
    compositor = detect_compositor()["compositor"]
    if compositor == "hyprland":
        return {"manages": True, "compositor": "hyprland", "blocked": False, "reason": ""}
    if compositor != "unknown":
        return {"manages": False, "compositor": compositor, "blocked": False, "reason": ""}

    if not command_exists("hyprctl"):
        return {"manages": False, "compositor": "unknown", "blocked": False,
                "reason": "no compositor was detected and hyprctl is not installed"}
    env = _rd_hypr_env()
    if not env.get("HYPRLAND_INSTANCE_SIGNATURE"):
        return {"manages": False, "compositor": "unknown", "blocked": False,
                "reason": "no compositor was detected and no Hyprland instance is running"}
    try:
        proc = run(["hyprctl", "-j", "version"], timeout=3, env=env)
    except (OSError, subprocess.SubprocessError) as exc:
        return {"manages": False, "compositor": "unknown", "blocked": True,
                "reason": f"a Hyprland instance is running but hyprctl could not be reached: {exc}"}
    if proc.returncode != 0:
        detail = (proc.stderr or "").strip() or f"hyprctl exited {proc.returncode}"
        return {"manages": False, "compositor": "unknown", "blocked": True,
                "reason": f"a Hyprland instance is running but hyprctl could not be reached: {detail}"}
    # Resolved from the runtime dir: this IS Hyprland, reached over ssh.
    return {"manages": True, "compositor": "hyprland", "blocked": False, "reason": ""}


def _rd_web_host() -> str:
    """Prefer the tailnet address: that is the only route a client has."""
    if command_exists("tailscale"):
        try:
            proc = run(["tailscale", "ip", "-4"], timeout=3)
            if proc.returncode == 0:
                for line in (proc.stdout or "").splitlines():
                    if line.strip():
                        return line.strip()
        except (OSError, subprocess.SubprocessError):
            pass
    return "localhost"


def _rd_journal_window() -> Any:
    """Bound the journal read to the CURRENT run of the unit, or None.

    Without this bound a `CLIENT CONNECTED` from a previous run -- with no
    matching disconnect, because the daemon was killed -- reads as a live
    session forever. The window is what makes the last-event scan sound.

    **There is no fallback window, deliberately.** An earlier version fell back
    to `--boot` when the timestamp could not be established, reasoning that a
    running unit always has an ActiveEnterTimestamp. That reasoning does not
    survive the query itself failing, or systemd phrasing the value differently:
    the read then replays unbounded history, and a stale CLIENT CONNECTED is
    reported as a live session. The widget would show LIVE with nobody
    connected.

    That is the WORSE direction of the same error the readable/active split
    guards. Hiding a real capture is bad; inventing one trains the user to
    ignore the only indicator that says somebody is watching their screen, which
    destroys the thing this plugin exists for. So: no anchor, no read -- the
    session is reported unknown, which is neither a replay nor an idle claim.
    """
    proc = _systemctl_user(["show", RD_UNIT, "--property=ActiveEnterTimestamp", "--value"])
    if proc.returncode != 0:
        return None
    parts = (proc.stdout or "").strip().split()
    if len(parts) >= 3 and _RD_SYSTEMD_DATE_RE.match(parts[1]):
        return ["--since", f"{parts[1]} {parts[2]}"]
    return None


def _rd_session_state() -> Dict[str, Any]:
    """Live session facts, read from the unit's own journal.

    Deliberately NOT the Sunshine Web API. That API is HTTP-Basic-gated behind
    the credentials in Sunshine's own state file, so using it would mean
    reading and holding a second credential inside the shell just to answer
    "is somebody watching my screen". The journal already carries the events.

    What the API would add and this cannot: the connected client's NAME and its
    requested resolution. Neither is logged at Sunshine's `info` level -- see
    `pairedClients` in the status payload, which is the paired list rather than
    a pretend answer.
    """
    state: Dict[str, Any] = {
        "active": False,
        "count": 0,
        "since": "",
        "codec": "",
        "bitrateBps": 0,
        "colorDepth": "",
        "readable": False,
        "error": "",
    }
    window = _rd_journal_window()
    if window is None:
        # Unknown, and specifically NOT idle: `readable` stays false, so the
        # shell renders "could not tell" rather than "nobody is watching".
        state["error"] = (
            "the host's start time could not be established, so the journal "
            "could not be bounded to the current run"
        )
        return state

    argv = ["journalctl", "--user", "-u", RD_UNIT, "--no-pager", "-o", "short-iso", *window]
    try:
        proc = run(argv, timeout=8)
    except (OSError, subprocess.SubprocessError) as exc:
        state["error"] = f"the journal could not be read: {exc}"
        return state
    if proc.returncode != 0:
        state["error"] = (proc.stderr or "").strip() or f"journalctl exited {proc.returncode}"
        return state

    state["readable"] = True
    announced = 0
    for line in (proc.stdout or "").splitlines():
        sessions = _RD_SESSIONS_RE.search(line)
        if sessions:
            try:
                announced = int(sessions.group(1))
            except ValueError:
                announced = 0
            continue
        encoder = _RD_ENCODER_RE.search(line)
        if encoder:
            state["codec"] = encoder.group(1)
            continue
        bitrate = _RD_BITRATE_RE.search(line)
        if bitrate:
            try:
                state["bitrateBps"] = int(bitrate.group(1))
            except ValueError:
                state["bitrateBps"] = 0
            continue
        depth = _RD_DEPTH_RE.search(line)
        if depth:
            state["colorDepth"] = depth.group(1)
            continue
        if "CLIENT CONNECTED" in line:
            state["count"] = announced or (state["count"] + 1)
            state["active"] = True
            stamp = _RD_ISO_RE.match(line)
            state["since"] = stamp.group(1) if stamp else ""
            announced = 0
            continue
        if "CLIENT DISCONNECTED" in line:
            state["count"] = max(0, state["count"] - 1)
            state["active"] = state["count"] > 0
            if not state["active"]:
                state["since"] = ""
            continue

    if not state["active"]:
        # Encoder and bitrate belong to the session that ended. Reporting them
        # beside "listening" would read as a live stream's settings.
        state["codec"] = ""
        state["bitrateBps"] = 0
        state["colorDepth"] = ""
    return state


# Candidate markers for the decode below. Private-use characters, so a real
# state file will not contain one -- and the loop picks one that provably does
# not, rather than assuming.
_RD_DECODE_MARKERS = ("\ue000", "\ue001", "\uf8ff")

# U+FFFD written literally, and the JSON escape a writer may use instead. Both
# are what a device GENUINELY named with a replacement character looks like on
# disk; neither can be produced by decoding an invalid byte.
_RD_FFFD_ESCAPE_RE = re.compile(rb"\\u fffd".replace(b" ", b""), re.IGNORECASE)


def _rd_decode_marking_real_fffd(raw_bytes: bytes) -> Any:
    """Decode leniently, with genuine U+FFFD swapped for a marker first.

    The point is to make the leftover U+FFFD UNAMBIGUOUS. Decoding with
    `errors="replace"` and then asking "is this U+FFFD real?" cannot be
    answered from the decoded text -- an earlier attempt searched the whole file
    for the name's bytes, which answers "does this sequence appear ANYWHERE",
    not "did THIS field decode cleanly", and mis-attributes whenever it appears
    somewhere else.

    So the question is removed rather than approximated: every rendering of a
    real U+FFFD is swapped for a marker BEFORE decoding, and both renderings are
    handled because a JSON writer may or may not escape non-ASCII (Sunshine's
    own state file escapes solidus, so assuming either would be a guess). After
    that, any U+FFFD in the decoded text can only be our own substitution, and
    any marker can only be a real one.

    Returns ``(text, marker)``, or ``(None, "")`` when no marker can be used --
    which is not a failure to paper over, so the caller withholds every
    suspicious name rather than guessing.
    """
    for marker in _RD_DECODE_MARKERS:
        encoded = marker.encode("utf-8")
        escaped_marker = marker.encode("unicode_escape")
        if encoded in raw_bytes or escaped_marker in raw_bytes:
            continue
        marked = raw_bytes.replace("\ufffd".encode("utf-8"), encoded)
        # A function, not a literal: re.sub processes backslash escapes in a
        # replacement string, and the marker's escaped form starts with one.
        marked = _RD_FFFD_ESCAPE_RE.sub(lambda _match: escaped_marker, marked)
        return marked.decode("utf-8", errors="replace"), marker
    return None, ""


def _rd_paired_clients() -> Dict[str, Any]:
    """Names of PAIRED devices, from Sunshine's own state file.

    These are the devices allowed to connect, NOT the ones connected now: the
    journal does not say which client a session belongs to, so nothing here may
    be presented as the current viewer.

    That file also holds Sunshine's Web UI credential hash and salt. Only
    `name` is read out of it; nothing else from the file is returned, printed
    or logged.

    Every shape is checked rather than assumed. Decoding as JSON says nothing
    about the structure -- a list, a scalar, or a `root` that is a string all
    used to raise straight out of `remote_desktop_status()`, so one malformed
    field took out every OTHER field with it and the widget lost the host and
    session state too. By this subsystem's own model unparseable state is
    UNKNOWN, not fatal, so it degrades to that with the reason attached, the
    way the unreadable-journal path already does.

    A missing file is not malformed: no Sunshine config means no paired
    devices, which is an answer.
    """
    config_home = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
    path = Path(config_home) / "sunshine" / "sunshine_state.json"
    try:
        raw_bytes = path.read_bytes()
    except FileNotFoundError:
        return {"names": [], "known": True, "error": "", "undecodable": 0}
    except OSError as exc:
        return {"names": [], "known": False, "error": f"the Sunshine state file could not be read: {exc}", "undecodable": 0}

    # Decoding with `errors="replace"` and moving on turned bad bytes into
    # U+FFFD INSIDE device names, so a client appeared under a mangled name that
    # is indistinguishable from the device genuinely being called that. The file
    # is still decoded leniently -- one bad name must not cost the whole list --
    # but genuine U+FFFD is marked first, so what remains is unambiguous.
    raw, marker = _rd_decode_marking_real_fffd(raw_bytes)
    if raw is None:
        # No usable marker. Nothing here can tell a real replacement character
        # from a substituted one, so the safe direction is to say so.
        raw = raw_bytes.decode("utf-8", errors="replace")
        marker = ""
    try:
        data = json.loads(raw)
    except ValueError as exc:
        return {"names": [], "known": False, "error": f"the Sunshine state file is not valid JSON: {exc}", "undecodable": 0}

    if not isinstance(data, dict):
        return {"names": [], "known": False, "error": "the Sunshine state file is not an object", "undecodable": 0}
    root = data.get("root")
    if root is None:
        # A state file with no paired devices yet legitimately has no `root`.
        return {"names": [], "known": True, "error": "", "undecodable": 0}
    if not isinstance(root, dict):
        return {"names": [], "known": False, "error": "the Sunshine state file's `root` is not an object", "undecodable": 0}
    devices = root.get("named_devices")
    if devices is None:
        return {"names": [], "known": True, "error": "", "undecodable": 0}
    if not isinstance(devices, list):
        return {"names": [], "known": False, "error": "the Sunshine state file's device list is not a list", "undecodable": 0}

    names: List[str] = []
    undecodable = 0
    for device in devices:
        name = device.get("name") if isinstance(device, dict) else None
        if not isinstance(name, str) or not name.strip():
            continue
        # Exact, per name. A U+FFFD surviving the marking above can only be a
        # byte this process could not decode, so the name is withheld rather
        # than shown mangled; a marker can only be a replacement character the
        # file really contained, so it is restored and the name kept.
        if "\ufffd" in name:
            undecodable += 1
            continue
        if marker:
            name = name.replace(marker, "\ufffd")
        names.append(name.strip())
    return {"names": names, "known": True, "error": "", "undecodable": undecodable}


# --- Who created HEADLESS-1 --------------------------------------------------
#
# `start` and `stop` are separate process invocations, so "did VGS create this
# output, or was it already there?" cannot live in memory. It goes in the state
# dir beside the notification-takeover undo record, and for the same reason:
# without provenance, undoing a change means guessing, and the wrong guess here
# deletes a virtual output the user set up for something else. That is not
# recoverable from the shell.
#
# The record is keyed on the Hyprland instance signature. Headless outputs do
# not survive a compositor restart and the signature changes with every start,
# so a record from a previous instance cannot possibly describe the output
# present now -- it is discarded rather than trusted.


def _rd_output_record_file() -> Path:
    return state_dir() / "remote-desktop-output.json"


def _rd_hypr_instance() -> str:
    return _rd_hypr_env().get("HYPRLAND_INSTANCE_SIGNATURE", "")


@contextlib.contextmanager
def _rd_lifecycle_lock():
    """Serialise start/stop/toggle across helper invocations.

    Two concurrent starts could each see "no output", each create one, and
    leave a monitor behind that no record owns. Two concurrent toggles could
    read the same unit state and both act on it.
    """
    ensure_dirs()
    lock_path = state_dir() / "remote-desktop.lock"
    with lock_path.open("a+") as lock:
        fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
        try:
            yield
        finally:
            fcntl.flock(lock.fileno(), fcntl.LOCK_UN)


def _rd_record_output_created() -> str:
    """Record that THIS call created the output. Returns "" or a failure reason."""
    try:
        ensure_dirs()
        _rd_output_record_file().write_text(json.dumps({
            "output": RD_OUTPUT,
            "createdByVgs": True,
            "instance": _rd_hypr_instance(),
            "at": time.strftime("%Y-%m-%dT%H:%M:%S"),
        }, indent=2))
        return ""
    except OSError as exc:
        return str(exc)


def _rd_clear_output_record() -> None:
    with contextlib.suppress(OSError):
        _rd_output_record_file().unlink()


def _rd_output_is_ours() -> bool:
    """True only when VGS created the output the RUNNING compositor still has."""
    try:
        data = json.loads(_rd_output_record_file().read_text(errors="replace"))
    except (OSError, ValueError):
        return False
    if not isinstance(data, dict):
        return False
    if data.get("createdByVgs") is not True or data.get("output") != RD_OUTPUT:
        return False
    instance = _rd_hypr_instance()
    # No signature to compare against is not a match. Trusting a record we
    # cannot place would authorise removing an output from a session VGS never
    # touched.
    return bool(instance) and data.get("instance") == instance


def _rd_remove_output() -> str:
    """Remove the virtual output. Returns "" on success, else a reason."""
    try:
        proc = run(["hyprctl", "output", "remove", RD_OUTPUT], timeout=5, env=_rd_hypr_env())
    except (OSError, subprocess.SubprocessError) as exc:
        return str(exc)
    if proc.returncode != 0:
        return (proc.stderr or proc.stdout or "").strip() or f"hyprctl exited {proc.returncode}"
    return ""


def _rd_unit_state() -> Dict[str, Any]:
    """Unit state PLUS whether the query itself worked.

    `_user_unit_state()` reports a failed `systemctl show` and a genuinely
    absent unit identically -- both come back `exists: False` -- and the whole
    discipline of this subsystem is that a failed question is not a negative
    answer. A transient systemctl failure must not make the widget announce
    that Sunshine is not installed.

    Parsed here rather than by adding a flag to the shared helper: this needs
    two properties, and the notification subsystem's use of _user_unit_state is
    not worth the blast radius.

    **A PARTIAL reply is not an answer either.** Both returned fields carry a
    verdict, and each is read with a default that looks definite: an absent
    `ActiveState` silently makes `running` false, so a truncated reply that
    happened to contain `LoadState` reported the host as *stopped* when its
    state was in fact unknown. Every property the verdict depends on must be
    present AND non-empty before the query counts as known -- `LoadState=` is a
    field, not a value.
    """
    required = ("LoadState", "ActiveState")
    proc = _systemctl_user(["show", RD_UNIT, *(f"--property={name}" for name in required)])
    values: Dict[str, str] = {}
    for line in (proc.stdout or "").splitlines():
        key, sep, value = line.partition("=")
        if sep and key.strip() not in values:
            values[key.strip()] = value.strip()

    if proc.returncode != 0:
        return {
            "known": False,
            "error": (proc.stderr or "").strip() or f"systemctl exited {proc.returncode}",
            "exists": False,
            "running": False,
        }
    missing = [name for name in required if not values.get(name)]
    if missing:
        return {
            "known": False,
            "error": "systemctl's reply was incomplete: " + ", ".join(missing) + " missing",
            "exists": False,
            "running": False,
        }
    return {
        "known": True,
        "error": "",
        "exists": values["LoadState"] in {"loaded", "masked"},
        "running": values["ActiveState"] in {"active", "activating", "reloading"},
    }


def remote_desktop_status() -> Dict[str, Any]:
    unit = _rd_unit_state()
    running = unit["running"]
    managed = _rd_manages_output()
    compositor = managed["compositor"]
    output_present = _rd_output_present() if managed["manages"] else None
    session = _rd_session_state() if (unit["known"] and running) else {
        "active": False, "count": 0, "since": "", "codec": "",
        "bitrateBps": 0, "colorDepth": "",
        # A unit whose state is unknown has an unknown session too; a stopped
        # one genuinely has none.
        "readable": unit["known"], "error": "" if unit["known"] else unit["error"],
    }

    if not unit["known"]:
        # The question failed. Neither "installed" nor "not installed" is an
        # answer this can give, so it gives neither.
        state = "unknown"
        reason = unit["error"]
    elif unit["exists"]:
        state = "running" if running else "stopped"
        reason = ""
    else:
        state = "unavailable"
        reason = f"{RD_UNIT} is not installed"

    paired = _rd_paired_clients()

    return {
        "unit": RD_UNIT,
        "unitKnown": unit["known"],
        "installed": unit["exists"],
        "running": running,
        "state": state,
        "reason": reason,
        "compositor": compositor,
        "output": {
            "name": RD_OUTPUT,
            # Only Hyprland can create a virtual output from here. On anything
            # else the host still runs; it just captures a real monitor, and
            # saying so is better than implying VGS manages an output it cannot.
            "supported": managed["manages"],
            "known": output_present is not None,
            "present": output_present is True,
        },
        # The silent failure, made loud: the host is up on Hyprland with no
        # HEADLESS-1, which means it is capturing a REAL monitor -- the user's
        # own screen -- and nothing else would ever say so.
        "captureFallback": bool(running and managed["manages"] and output_present is False),
        "webUi": f"https://{_rd_web_host()}:{RD_WEB_PORT}",
        "session": session,
        "pairedClients": paired["names"],
        # One malformed field degrades to unknown; it never takes the host and
        # session state down with it.
        "pairedClientsKnown": paired["known"],
        "pairedClientsError": paired["error"],
        # Names dropped because the file held bytes that are not valid UTF-8.
        # Reported rather than silently substituted: a mangled name is
        # indistinguishable from a real one.
        "pairedClientsUndecodable": paired.get("undecodable", 0),
    }


def _rd_result(actions: List[str], failures: List[str], manual: List[str]) -> Dict[str, Any]:
    return {
        "ok": not failures,
        "actions": actions,
        "failures": failures,
        "manual": manual,
        "status": remote_desktop_status(),
    }


def remote_desktop_start() -> Dict[str, Any]:
    actions: List[str] = []
    failures: List[str] = []
    manual: List[str] = []

    unit = _rd_unit_state()
    if not unit["known"]:
        return _rd_result(actions, [
            f"could not determine whether {RD_UNIT} is installed: {unit['error']}"
        ], manual)
    if not unit["exists"]:
        return _rd_result(actions, [f"{RD_UNIT} is not installed"], manual)

    # Idempotent, deliberately. `toggle` decides from a state read, and the unit
    # can change between that read and here -- it can exit on its own, or a
    # concurrent client can bring it up. The losing path must touch NO output:
    # a host that is already running has already picked its capture target, so
    # creating a second virtual output for it would leave the user a phantom
    # monitor and change nothing else.
    if unit["running"]:
        manual.append(f"{RD_UNIT} was already running; nothing to do")
        return _rd_result(actions, failures, manual)

    managed = _rd_manages_output()
    if managed["blocked"]:
        # A Hyprland session is present and unreachable. Proceeding would create
        # no output and let the capture fall back to a real monitor -- the same
        # silent failure the unverifiable-presence refusal guards.
        return _rd_result(actions, [
            f"{managed['reason']}; not starting, because {RD_UNIT} would capture "
            f"a real monitor instead"
        ], manual)

    created = False
    if managed["manages"]:
        present = _rd_output_present()
        if present is None:
            # Refusing is the point. Starting anyway would capture a real
            # monitor and report success, which is the failure this command
            # exists to prevent.
            return _rd_result(actions, [
                f"hyprctl could not say whether {RD_OUTPUT} exists; not starting, "
                f"because {RD_UNIT} would capture a real monitor instead"
            ], manual)
        if present:
            # It was already there, so it is NOT ours to remove later. Clearing
            # any older record is part of that: a stale one would authorise
            # deleting an output VGS did not create.
            _rd_clear_output_record()
            manual.append(f"{RD_OUTPUT} already existed; it will be left in place on stop")
        else:
            try:
                proc = run(["hyprctl", "output", "create", "headless"], timeout=5, env=_rd_hypr_env())
            except (OSError, subprocess.SubprocessError) as exc:
                return _rd_result(actions, [f"could not create {RD_OUTPUT}: {exc}"], manual)
            if proc.returncode != 0:
                detail = (proc.stderr or proc.stdout or "").strip()
                return _rd_result(actions, [f"could not create {RD_OUTPUT}: {detail or f'hyprctl exited {proc.returncode}'}"], manual)
            # hyprctl reporting success is NOT the same as the output
            # existing. If it is absent, Sunshine picks a real monitor at
            # startup and streams the user's own screen with nothing anywhere
            # to say so -- the same silent fallback the `present is None`
            # refusal above guards, reached from the other side. Verify, and
            # refuse rather than start blind.
            #
            # Nothing is rolled back on either failure path: `False` means there
            # is no output to remove, and `None` means we cannot tell what is
            # there, so removing would be the guess this whole record exists to
            # avoid. Ownership is recorded only AFTER this check, so what was
            # verified as created is exactly what is owned and what stop may
            # remove.
            verified = _rd_output_present()
            if verified is not True:
                detail = (
                    "hyprctl reported success but the output is not present"
                    if verified is False
                    else "hyprctl reported success but the output could not be verified"
                )
                return _rd_result(actions, [
                    f"could not create {RD_OUTPUT}: {detail}; not starting, because "
                    f"{RD_UNIT} would capture a real monitor instead"
                ], manual)
            created = True
            actions.append(f"created {RD_OUTPUT}")
            record_error = _rd_record_output_created()
            if record_error:
                # Fail safe toward LEAVING it. An unrecorded output is left
                # alone by stop, which leaks a monitor the user can remove in
                # one click; guessing ownership the other way deletes one that
                # may be theirs, which they cannot undo.
                manual.append(
                    f"created {RD_OUTPUT} but could not record that VGS created it "
                    f"({record_error}); stop will leave it in place"
                )
    else:
        manual.append(
            (managed["reason"] or f"{managed['compositor']}: no virtual output is managed here")
            + ", so the host will capture an existing monitor"
        )

    proc = _systemctl_user(["start", RD_UNIT])
    if proc.returncode != 0:
        detail = (proc.stderr or "").strip()
        failures.append(f"could not start {RD_UNIT}: {detail or f'systemctl exited {proc.returncode}'}")
        # Transactional. The output was created FOR this start; leaving it
        # behind hands the user a phantom monitor AND no host, which is exactly
        # the state the disabled-by-default design exists to avoid. Only what
        # this call created is removed -- `created` is false when the output was
        # already there.
        if created:
            reason = _rd_remove_output()
            if reason:
                manual.append(f"{RD_OUTPUT} is still present after the failed start: {reason}")
            else:
                actions.append(f"removed {RD_OUTPUT} again after the failed start")
                _rd_clear_output_record()
        return _rd_result(actions, failures, manual)

    actions.append(f"started {RD_UNIT}")
    return _rd_result(actions, failures, manual)


def remote_desktop_stop() -> Dict[str, Any]:
    actions: List[str] = []
    failures: List[str] = []
    manual: List[str] = []

    # `systemctl stop` on an already-stopped unit exits 0, so this half is
    # idempotent without a guard -- losing a toggle race here is harmless.
    proc = _systemctl_user(["stop", RD_UNIT])
    if proc.returncode != 0:
        detail = (proc.stderr or "").strip()
        failures.append(f"could not stop {RD_UNIT}: {detail or f'systemctl exited {proc.returncode}'}")
    else:
        actions.append(f"stopped {RD_UNIT}")

    # Only tear down the output this subsystem created, and only once the host
    # is actually down -- removing it from under a live capture is worse than
    # leaving it.
    if not _rd_manages_output()["manages"] or failures:
        return _rd_result(actions, failures, manual)

    present = _rd_output_present()
    if present is None:
        manual.append(f"could not check whether {RD_OUTPUT} is present; leaving it alone")
        return _rd_result(actions, failures, manual)
    if not present:
        # Removed by hand between start and stop. Nothing to do -- and the
        # record has to go, or it would authorise removing a LATER output that
        # happens to carry the same name.
        _rd_clear_output_record()
        return _rd_result(actions, failures, manual)
    if not _rd_output_is_ours():
        # Present, but VGS did not create it -- or the record belongs to a
        # previous compositor instance. Removing it would delete a virtual
        # output the user set up for something else, and they cannot undo that
        # from here.
        manual.append(f"{RD_OUTPUT} was not created by VGS, so it is left in place")
        return _rd_result(actions, failures, manual)

    reason = _rd_remove_output()
    if reason:
        manual.append(f"{RD_OUTPUT} is still present: {reason}")
    else:
        actions.append(f"removed {RD_OUTPUT}")
        _rd_clear_output_record()
    return _rd_result(actions, failures, manual)


def _print_remote_desktop_status(status: Dict[str, Any]) -> None:
    print(f"host:    {status['state']}" + (f" ({status['reason']})" if status["reason"] else ""))
    output = status["output"]
    if not output["supported"]:
        print(f"output:  not managed on {status['compositor']}")
    elif not output["known"]:
        print(f"output:  unknown ({output['name']} could not be checked)")
    elif output["present"]:
        print(f"output:  {output['name']} present")
    else:
        print(f"output:  {output['name']} MISSING" + (" — capture is falling back to a real monitor" if status["captureFallback"] else ""))
    session = status["session"]
    if session["error"]:
        print(f"session: unknown ({session['error']})")
    elif session["active"]:
        detail = ", ".join(part for part in (
            session["codec"],
            f"{session['bitrateBps'] // 1000} kbps" if session["bitrateBps"] else "",
            session["colorDepth"],
        ) if part)
        print(f"session: STREAMING — {session['count']} client(s)" + (f" [{detail}]" if detail else ""))
    elif status["running"]:
        print("session: listening, nobody connected")
    else:
        print("session: none")
    print(f"web ui:  {status['webUi']}")
    if not status.get("pairedClientsKnown", True):
        print(f"paired:  unknown ({status.get('pairedClientsError', '')})")
    else:
        paired = status["pairedClients"]
        undecodable = status.get("pairedClientsUndecodable", 0)
        suffix = f" (+{undecodable} name(s) not valid UTF-8)" if undecodable else ""
        print(f"paired:  {(', '.join(paired) if paired else 'none')}{suffix}")


def _rd_watch_token(line: str) -> str:
    """Classify one journal line into a watch token, or "" to ignore it.

    Pure, so scripts/check-vshell-helper.py can exercise every branch without a
    running host -- which is the only way this gets tested at all, since
    starting Sunshine means capturing somebody's screen.
    """
    if "CLIENT CONNECTED" in line:
        return "connected"
    if "CLIENT DISCONNECTED" in line:
        return "disconnected"
    if any(marker in line for marker in ("Started ", "Stopped ", "Starting ", "Stopping ")):
        return "lifecycle"
    if "Creating encoder [" in line or "Streaming bitrate is" in line:
        return "session"
    return ""


def remote_desktop_watch() -> int:
    """Stream normalised host events, one token per line, until killed.

    This exists so Sunshine's log format is parsed in exactly ONE place. The
    shell needs to know *that* something happened, not what Sunshine phrased it
    as, and a widget string-matching `CLIENT CONNECTED` would be a second copy
    of that knowledge drifting against this one.

    Tokens: `connected`, `disconnected`, `lifecycle`, `session`. Every one of
    them means "re-read `remote-desktop status`"; they are distinguished only so
    the caller can flip the streaming indicator without waiting for the read.
    """
    argv = [
        "journalctl", "--user", "-u", RD_UNIT,
        "--follow", "-n", "0", "-o", "cat",
    ]
    try:
        proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1)
    except (OSError, subprocess.SubprocessError) as exc:
        eprint(f"vshell remote-desktop: could not follow the journal: {exc}")
        return 1
    try:
        assert proc.stdout is not None
        for line in proc.stdout:
            token = _rd_watch_token(line)
            if token:
                print(token, flush=True)
    except KeyboardInterrupt:
        return 130
    except BrokenPipeError:
        # The reader went away; that is the normal way this ends.
        return 0
    finally:
        with contextlib.suppress(Exception):
            proc.terminate()
            proc.wait(timeout=3)
    return proc.returncode or 0


def cmd_remote_desktop(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell remote-desktop")
    sub = parser.add_subparsers(dest="cmd", required=True)
    for name in ("status", "start", "stop", "toggle", "ui"):
        sub.add_parser(name).add_argument("--json", action="store_true")
    sub.add_parser("watch", help="stream normalised host events until killed")
    args = parser.parse_args(argv)

    if args.cmd == "watch":
        return remote_desktop_watch()

    if args.cmd == "ui":
        url = remote_desktop_status()["webUi"]
        try:
            subprocess.Popen(["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
        except (OSError, subprocess.SubprocessError) as exc:
            eprint(f"vshell remote-desktop: could not open {url}: {exc}")
            return 1
        if args.json:
            print(json.dumps({"ok": True, "url": url}, indent=2))
        else:
            print(url)
        return 0

    if args.cmd == "status":
        status = remote_desktop_status()
        if status["state"] == "unknown":
            if args.json:
                print(json.dumps(status, indent=2))
            else:
                _print_remote_desktop_status(status)
            return 3
        if args.json:
            print(json.dumps(status, indent=2))
        else:
            _print_remote_desktop_status(status)
        if not status["installed"]:
            return 2
        return 0 if status["running"] else 1

    # One lock around the whole lifecycle operation, INCLUDING toggle's state
    # read. That closes the window against another helper invocation deciding
    # from the same reading. It cannot close the window against the unit
    # changing on its own -- a Moonlight client connecting, the daemon exiting
    # -- which is why start and stop are also idempotent, and why the losing
    # path of each creates and destroys nothing.
    with _rd_lifecycle_lock():
        if args.cmd == "toggle":
            # A failed query must not be read as "stopped" -- that would start a
            # host that may already be running, and create an output for it.
            toggle_unit = _rd_unit_state()
            if not toggle_unit["known"]:
                result = _rd_result([], [
                    f"could not determine whether {RD_UNIT} is running: {toggle_unit['error']}"
                ], [])
            elif toggle_unit["running"]:
                result = remote_desktop_stop()
            else:
                result = remote_desktop_start()
        elif args.cmd == "start":
            result = remote_desktop_start()
        else:
            result = remote_desktop_stop()

    if args.json:
        print(json.dumps(result, indent=2))
        return 0 if result["ok"] else 1
    for action in result["actions"]:
        print(action)
    for note in result["manual"]:
        print(f"manual: {note}")
    for failure in result["failures"]:
        eprint(f"vshell remote-desktop: {failure}")
    _print_remote_desktop_status(result["status"])
    return 0 if result["ok"] else 1


def cmd_notifications(argv: List[str]) -> int:
    parser = argparse.ArgumentParser(prog="vshell notifications")
    sub = parser.add_subparsers(dest="cmd", required=True)
    for name in ("status", "takeover", "restore"):
        cmd = sub.add_parser(name)
        cmd.add_argument("--json", action="store_true")
        if name == "takeover":
            # Only the shell's own first-run takeover passes this. It records
            # the change as VGS's doing so a later opt-out can reverse it --
            # including from a shell that has restarted since.
            cmd.add_argument("--automatic", action="store_true",
                             help="record this takeover as VGS's own first-run action")
    args = parser.parse_args(argv)

    if args.cmd == "status":
        status = notification_status()
        if args.json:
            print(json.dumps(status, indent=2))
        else:
            _print_notification_status(status)
        if not status["vgsServerEnabled"]:
            return 0  # a foreign owner is the configured outcome, not a fault
        return 0 if status["state"] == "vgs" else 1

    result = (notification_takeover(automatic=args.automatic)
              if args.cmd == "takeover" else notification_restore())
    if args.json:
        print(json.dumps(result, indent=2))
        return 0 if result["ok"] else 1
    for action in result["actions"]:
        print(action)
    for note in result["manual"]:
        print(f"manual: {note}")
    for failure in result["failures"]:
        eprint(f"vshell notifications: {failure}")
    if not result["actions"] and not result["manual"] and not result["failures"]:
        print("nothing to do")
    _print_notification_status(result)
    return 0 if result["ok"] else 1


def cmd_terminal(argv: List[str]) -> int:
    """The one entry point every VGS surface uses to open a terminal."""
    usage = (
        "Usage:\n"
        "  vshell terminal resolve [--json] [--prefer TERMINAL]\n"
        "  vshell terminal open [--app-id ID] [--prefer TERMINAL]\n"
        "  vshell terminal exec [--app-id ID|--tui] [--hold] [--wait]\n"
        "                       [--prefer TERMINAL] -- <command> [args...]\n"
        "\n"
        "  --wait    stay alive until the terminal exits, so the caller can treat\n"
        "            this process's exit as the command having finished.\n"
        "  --prefer  try TERMINAL first; the normal chain still follows it."
    )
    if not argv:
        eprint(usage)
        return 2
    sub, rest = argv[0], argv[1:]
    if sub not in {"resolve", "open", "exec"}:
        eprint(usage)
        return 2

    def _prefer_of(options: List[str]) -> List[str]:
        for position, option in enumerate(options):
            value = ""
            if option == "--prefer" and position + 1 < len(options):
                value = options[position + 1]
            elif option.startswith("--prefer="):
                value = option.split("=", 1)[1]
            if value.strip():
                try:
                    return shlex.split(value)
                except ValueError:
                    return [value.strip()]
        return []

    if sub == "resolve":
        as_json = "--json" in rest
        candidates = terminal_candidates(_prefer_of(rest))
        payload = {
            "ok": bool(candidates),
            "terminal": candidates[0] if candidates else [],
            "candidates": candidates,
            "scope": app_scope_prefix(),
            "xdgTerminalsList": xdg_terminals_list(),
        }
        if as_json:
            print(json.dumps(payload, ensure_ascii=False))
        elif candidates:
            print(" ".join(candidates[0]))
        else:
            eprint("No terminal found. Install one of: " + ", ".join(TERMINAL_CANDIDATES))
        return 0 if candidates else 1

    app_id = ""
    hold = False
    wait = False
    prefer: List[str] = []
    cmd: List[str] = []
    index = 0
    while index < len(rest):
        arg = rest[index]
        if arg == "--":
            cmd = rest[index + 1:]
            break
        if arg == "--hold":
            hold = True
        elif arg == "--wait":
            wait = True
        elif arg == "--app-id":
            index += 1
            app_id = rest[index] if index < len(rest) else ""
        elif arg.startswith("--app-id="):
            app_id = arg.split("=", 1)[1]
        elif arg == "--tui":
            app_id = TERMINAL_TUI_APP_ID
        elif arg == "--prefer":
            index += 1
            prefer = _prefer_of(["--prefer", rest[index]]) if index < len(rest) else []
        elif arg.startswith("--prefer="):
            prefer = _prefer_of([arg])
        else:
            eprint(f"vshell terminal {sub}: unknown option: {arg}")
            eprint(usage)
            return 2
        index += 1
    if sub == "exec" and not cmd:
        eprint("vshell terminal exec: a command is required after --")
        return 2
    # Everything that reaches this CLI was launched detached by the shell, so a
    # failure printed to stderr would reach nobody: report it to the user.
    return spawn_terminal(cmd, app_id=app_id, hold=hold, wait=wait, prefer=prefer, notify=True,
                          # A caller waiting on this process is supervising the
                          # command; keep the terminal in our process group so it
                          # dies with the supervisor rather than being orphaned.
                          detach=not wait,
                          what="the requested terminal command" if cmd else "a terminal")


def main() -> int:
    if len(sys.argv) < 2:
        eprint("Usage: vshell-helper <theme|fonts|deps|notifications|update|ai-usage|capture|cl|dl|trash|color|keybinds|config|scratchpad|compositor|blur|brightness|auth|greeter> ...")
        return 2
    cmd, argv = sys.argv[1], sys.argv[2:]
    try:
        if cmd == "theme": return cmd_theme(argv)
        if cmd == "fonts": return cmd_fonts(argv)
        if cmd == "deps": return cmd_deps(argv)
        if cmd == "update": return cmd_update(argv)
        if cmd in {"ai-usage", "ai"}: return cmd_ai_usage(argv)
        if cmd == "capture": return cmd_capture(argv)
        if cmd in {"screenshot"}: return cmd_capture(["screenshot", *argv])
        if cmd in {"screenrecording", "recording"}: return cmd_capture(["screenrecording", *argv])
        if cmd in {"ocr"}: return cmd_capture(["text", *argv])
        if cmd in {"cl", "clipboard"}: return cmd_clipboard(argv)
        if cmd in {"dl", "download"}: return cmd_download(argv)
        if cmd == "trash": return cmd_trash(argv)
        if cmd == "color": return cmd_color(argv)
        if cmd == "keybinds": return cmd_keybinds(argv)
        if cmd == "config": return cmd_config(argv)
        if cmd == "scratchpad": return cmd_scratchpad(argv)
        if cmd == "compositor": return cmd_compositor(argv)
        if cmd == "instances": return cmd_instances(argv)
        if cmd == "battery": return cmd_battery(argv)
        if cmd == "blur": return cmd_blur(argv)
        if cmd == "brightness": return cmd_brightness(argv)
        if cmd == "auth": return cmd_auth(argv)
        if cmd == "greeter": return cmd_greeter(argv)
        if cmd == "sudo-toggle": return cmd_sudo_toggle(argv)
        if cmd == "notifications": return cmd_notifications(argv)
        if cmd == "remote-desktop": return cmd_remote_desktop(argv)
        if cmd == "launcher-search": return cmd_launcher_search(argv)
        if cmd == "terminal": return cmd_terminal(argv)
        eprint(f"Unknown VGS helper command: {cmd}")
        return 2
    except KeyboardInterrupt:
        return 130
    except Exception as exc:
        eprint(f"vshell-helper error: {exc}")
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
