// nucleic-a11y-agent — the in-guest **accessibility (AT-SPI) semantic agent** for Nucleic Linux VMs.
//
// This is the Linux counterpart of the macOS agent's AX-semantic ops (docs/MACOS_VM_NATIVE_AGENT.md
// §6/§10, guest/NucleicVMAgent Ops+AX.swift) and the core of docs/LINUX_VM_SEMANTIC_AGENT.md Phase 1:
// it reads the accessibility tree and acts on a control *by identity* (`ax_*`) with no coordinates.
//
//   • ax_dump      — walk the active app's AT-SPI tree into the same node schema the macOS agent emits
//                    (ref / role / title / value / enabled / focused / frame / actions / children).
//   • ax_action    — Action.DoAction on a ref'd element (optionally a named action).
//   • ax_set_value — EditableText.SetTextContents on a ref'd element.
//   • ax_focus     — Component.GrabFocus on a ref'd element.
//   • ping         — capability/version handshake (mirrors the C agent's, so the host probes uniformly).
//
// Transport: NDJSON over AF_VSOCK — one JSON request per line, one JSON reply per line — byte-for-byte
// the `VMAgentCore.AgentWire` protocol the C agent + macOS agent speak. This agent listens on a SECOND
// vsock port (default 2036) so the dependency-free static-C `nucleic-linux-agent` keeps owning
// ping/exec on 2035 untouched (docs/LINUX_VM_SEMANTIC_AGENT.md §9 Q2).
//
// It runs INSIDE the user's GNOME session (a systemd *user* service / autostart), not as a root system
// service, because AT-SPI is reached through the session's a11y D-Bus (org.a11y.Bus → the a11y bus);
// a root process outside the session can't see it. Binding AF_VSOCK needs no privilege.
//
// AT-SPI is spoken directly over D-Bus (zbus proxies) rather than via libatspi, keeping this a single
// self-contained binary with no C/GObject-Introspection linkage.

use std::sync::Arc;

use futures_util::future::BoxFuture;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::Mutex;
use tokio_vsock::{VsockAddr, VsockListener, VsockStream};

const AGENT_VERSION: i64 = 2; // mirror of AgentWire.version (host gates on ==)
const DEFAULT_PORT: u32 = 2036;
const VMADDR_CID_ANY: u32 = 0xFFFF_FFFF;

// Tree-walk budget, mirroring the macOS agent (§6.1): a rich app is thousands of D-Bus round-trips.
const NODE_BUDGET: i32 = 800;
const MAX_DEPTH: i32 = 12;
const CHILD_CAP: usize = 48;
const MAX_VALUE_CHARS: usize = 250;

const A11Y_ROOT_DEST: &str = "org.a11y.atspi.Registry";
const A11Y_ROOT_PATH: &str = "/org/a11y/atspi/accessible/root";

// ---- AT-SPI state bit indices (AtspiStateType) we care about --------------------------------------
const STATE_ENABLED: u32 = 8;
const STATE_ACTIVE: u32 = 1;
const STATE_FOCUSED: u32 = 12;
const STATE_SENSITIVE: u32 = 24;

/// AT-SPI packs its state set into two u32 words (a 64-bit field indexed by AtspiStateType).
fn has_state(states: &[u32], bit: u32) -> bool {
    let word = (bit / 32) as usize;
    let shift = bit % 32;
    states.get(word).map_or(false, |w| (w >> shift) & 1 == 1)
}

// ---- AT-SPI D-Bus interfaces (hand-rolled zbus proxies) -------------------------------------------

#[zbus::proxy(interface = "org.a11y.Bus", default_service = "org.a11y.Bus", default_path = "/org/a11y/bus")]
trait A11yBus {
    fn get_address(&self) -> zbus::Result<String>;
}

// AT-SPI objects do NOT emit org.freedesktop.DBus.Properties.PropertiesChanged and their root doesn't
// even implement GetAll (it returns an empty set) — accessibility change notification is a separate
// event system. So every `#[zbus(property)]` here is marked `emits_changed_signal = "false"`, which
// makes zbus fetch it with a direct `Get` on each access instead of relying on its (here empty) cache.
#[zbus::proxy(interface = "org.a11y.atspi.Accessible", assume_defaults = false)]
trait Accessible {
    #[zbus(property(emits_changed_signal = "false"))]
    fn name(&self) -> zbus::Result<String>;
    #[zbus(property(emits_changed_signal = "false"), name = "ChildCount")]
    fn child_count(&self) -> zbus::Result<i32>;
    fn get_child_at_index(&self, index: i32) -> zbus::Result<(String, zvariant::OwnedObjectPath)>;
    fn get_role_name(&self) -> zbus::Result<String>;
    fn get_state(&self) -> zbus::Result<Vec<u32>>;
    fn get_interfaces(&self) -> zbus::Result<Vec<String>>;
}

#[zbus::proxy(interface = "org.a11y.atspi.Component", assume_defaults = false)]
trait Component {
    fn get_extents(&self, coord_type: u32) -> zbus::Result<(i32, i32, i32, i32)>;
    fn grab_focus(&self) -> zbus::Result<bool>;
}

#[zbus::proxy(interface = "org.a11y.atspi.Action", assume_defaults = false)]
trait Action {
    fn get_actions(&self) -> zbus::Result<Vec<(String, String, String)>>;
    fn do_action(&self, index: i32) -> zbus::Result<bool>;
}

#[zbus::proxy(interface = "org.a11y.atspi.Value", assume_defaults = false)]
trait AtspiValue {
    #[zbus(property(emits_changed_signal = "false"), name = "CurrentValue")]
    fn current_value(&self) -> zbus::Result<f64>;
}

#[zbus::proxy(interface = "org.a11y.atspi.EditableText", assume_defaults = false)]
trait EditableText {
    fn set_text_contents(&self, new_contents: &str) -> zbus::Result<bool>;
}

#[zbus::proxy(interface = "org.a11y.atspi.Text", assume_defaults = false)]
trait Text {
    #[zbus(property(emits_changed_signal = "false"), name = "CharacterCount")]
    fn character_count(&self) -> zbus::Result<i32>;
    fn get_text(&self, start_offset: i32, end_offset: i32) -> zbus::Result<String>;
}

/// The Nucleic geometry helper (a GNOME Shell extension, docs/LINUX_VM_SEMANTIC_AGENT.md Phase 2) —
/// the compositor's source of truth for the focused window's global origin, which AT-SPI can't provide
/// on Wayland. Lives on the SESSION bus (where gnome-shell publishes it), not the a11y bus.
#[zbus::proxy(
    interface = "org.nucleic.Geometry",
    default_service = "org.nucleic.Geometry",
    default_path = "/org/nucleic/Geometry"
)]
trait Geometry {
    fn get_focused_window_frame(&self) -> zbus::Result<(i32, i32, i32, i32, String)>;
}

/// An AT-SPI object address: the app's unique D-Bus name + the object path.
type ObjRef = (String, String);

async fn accessible(conn: &zbus::Connection, dest: &str, path: &str) -> zbus::Result<AccessibleProxy<'static>> {
    AccessibleProxy::builder(conn)
        .destination(dest.to_owned())?
        .path(path.to_owned())?
        // AT-SPI objects don't implement Properties.GetAll (the root returns an empty body), so zbus's
        // default cache-population would fail every property read with a signature mismatch. Disable
        // caching → each getter does a direct Properties.Get, which AT-SPI does implement.
        .cache_properties(zbus::proxy::CacheProperties::No)
        .build()
        .await
}

// ---- tree walk ------------------------------------------------------------------------------------

struct DumpState {
    conn: zbus::Connection,
    refs: Vec<ObjRef>, // index i → ref "e{i+1}"
    budget: i32,
    /// The focused window's global origin from the compositor (Phase 2). When present, node frames are
    /// `origin + getExtents(WINDOW)` (true global); when absent, we fall back to `getExtents(SCREEN)`.
    origin: Option<(i32, i32)>,
}

impl DumpState {
    fn register(&mut self, r: ObjRef) -> String {
        self.refs.push(r);
        format!("e{}", self.refs.len())
    }

    fn build_node<'a>(&'a mut self, dest: &'a str, path: &'a str, depth: i32) -> BoxFuture<'a, Value> {
        Box::pin(async move {
            self.budget -= 1;
            let reff = self.register((dest.to_owned(), path.to_owned()));
            let mut node = serde_json::Map::new();
            node.insert("ref".into(), json!(reff));

            let acc = match accessible(&self.conn, dest, path).await {
                Ok(a) => a,
                Err(_) => return Value::Object(node),
            };

            if let Ok(role) = acc.get_role_name().await {
                node.insert("role".into(), json!(role));
            }
            if let Ok(name) = acc.name().await {
                if !name.is_empty() {
                    node.insert("title".into(), json!(name));
                }
            }

            let ifaces = acc.get_interfaces().await.unwrap_or_default();
            let ends = |suffix: &str| ifaces.iter().any(|i| i.ends_with(suffix));
            let has_action = ends("Action");
            let has_component = ends("Component");
            let has_text = ends("Text");
            let has_value = ends("Value");
            drop(ifaces);

            if let Some(v) = read_value(&self.conn, dest, path, has_text, has_value).await {
                node.insert("value".into(), json!(v));
            }

            let states = acc.get_state().await.unwrap_or_default();
            if !has_state(&states, STATE_ENABLED) && !has_state(&states, STATE_SENSITIVE) {
                node.insert("enabled".into(), json!(false));
            }
            if has_state(&states, STATE_FOCUSED) {
                node.insert("focused".into(), json!(true));
            }

            if has_action {
                if let Ok(p) = ActionProxy::builder(&self.conn)
                    .destination(dest.to_owned()).and_then(|b| b.path(path.to_owned()))
                {
                    if let Ok(p) = p.build().await {
                        if let Ok(actions) = p.get_actions().await {
                            let names: Vec<String> =
                                actions.into_iter().map(|(n, _, _)| n).filter(|n| !n.is_empty()).collect();
                            if !names.is_empty() {
                                node.insert("actions".into(), json!(names));
                            }
                        }
                    }
                }
            }

            if has_component {
                if let Some(frame) = read_frame(&self.conn, dest, path, self.origin).await {
                    node.insert("frame".into(), frame);
                }
            }

            if depth < MAX_DEPTH && self.budget > 0 {
                if let Ok(n) = acc.child_count().await {
                    let mut kids = Vec::new();
                    let mut shown = 0usize;
                    for i in 0..n {
                        if self.budget <= 0 || shown >= CHILD_CAP {
                            break;
                        }
                        if let Ok((cdest, cpath)) = acc.get_child_at_index(i).await {
                            let cpath = cpath.as_str().to_owned();
                            let cdest = if cdest.is_empty() { dest.to_owned() } else { cdest };
                            kids.push(self.build_node(&cdest, &cpath, depth + 1).await);
                            shown += 1;
                        }
                    }
                    if !kids.is_empty() {
                        node.insert("children".into(), Value::Array(kids));
                    }
                    if (n as usize) > shown {
                        node.insert("childrenOmitted".into(), json!(n as usize - shown));
                    }
                }
            }
            Value::Object(node)
        })
    }
}

/// A short human/model-readable value: editable/label text (Text iface), else a Value number.
async fn read_value(
    conn: &zbus::Connection, dest: &str, path: &str, has_text: bool, has_value: bool,
) -> Option<String> {
    if has_text {
        if let Ok(p) = TextProxy::builder(conn)
            .destination(dest.to_owned()).ok()?.path(path.to_owned()).ok()?
            .cache_properties(zbus::proxy::CacheProperties::No).build().await
        {
            if let Ok(count) = p.character_count().await {
                if count > 0 {
                    let end = count.min(MAX_VALUE_CHARS as i32);
                    if let Ok(mut s) = p.get_text(0, end).await {
                        if count > MAX_VALUE_CHARS as i32 {
                            s.push('…');
                        }
                        if !s.is_empty() {
                            return Some(s);
                        }
                    }
                }
            }
        }
    }
    if has_value {
        if let Ok(p) = AtspiValueProxy::builder(conn)
            .destination(dest.to_owned()).ok()?.path(path.to_owned()).ok()?
            .cache_properties(zbus::proxy::CacheProperties::No).build().await
        {
            if let Ok(v) = p.current_value().await {
                return Some(format!("{v}"));
            }
        }
    }
    None
}

/// The element's global on-screen frame. AT-SPI's `getExtents(SCREEN)` loses the window origin on
/// Wayland (docs/M_A11Y_RESULTS.md), so when the compositor gave us the focused window's `origin`
/// (Phase 2) we compute the true global frame as `origin + getExtents(WINDOW)`. Without the helper we
/// fall back to SCREEN (best-effort, likely origin-less) then WINDOW-relative. Omitted if zero-size.
async fn read_frame(
    conn: &zbus::Connection, dest: &str, path: &str, origin: Option<(i32, i32)>,
) -> Option<Value> {
    let p = ComponentProxy::builder(conn)
        .destination(dest.to_owned()).ok()?.path(path.to_owned()).ok()?.build().await.ok()?;
    if let Some((ox, oy)) = origin {
        if let Ok((x, y, w, h)) = p.get_extents(1).await {
            if w > 0 && h > 0 {
                return Some(json!({"x": ox + x, "y": oy + y, "w": w, "h": h}));
            }
        }
    }
    for coord in [0u32, 1u32] {
        if let Ok((x, y, w, h)) = p.get_extents(coord).await {
            if w > 0 && h > 0 {
                return Some(json!({"x": x, "y": y, "w": w, "h": h}));
            }
        }
    }
    None
}

/// Find the active application (the one whose top-level window has the ACTIVE state), so `ax_dump`
/// scopes to the frontmost app like the macOS agent. Falls back to the first application. Returns a
/// descriptive error (surfaced by `ax_dump`) rather than a bare `None` so failures are diagnosable.
async fn find_active_app(conn: &zbus::Connection) -> Result<ObjRef, String> {
    let root = accessible(conn, A11Y_ROOT_DEST, A11Y_ROOT_PATH)
        .await
        .map_err(|e| format!("could not reach the AT-SPI desktop root: {e}"))?;
    let n = root
        .child_count()
        .await
        .map_err(|e| format!("reading the desktop's ChildCount failed: {e}"))?;
    if n <= 0 {
        return Err(format!("the AT-SPI desktop reports {n} applications"));
    }
    let mut first: Option<ObjRef> = None;
    for i in 0..n {
        let (adest, apath) = match root.get_child_at_index(i).await {
            Ok(v) => v,
            Err(_) => continue, // skip a flaky child, don't abort the whole scan
        };
        let apath = apath.as_str().to_owned();
        if adest.is_empty() {
            continue;
        }
        if first.is_none() {
            first = Some((adest.clone(), apath.clone()));
        }
        if let Ok(app) = accessible(conn, &adest, &apath).await {
            let wn = app.child_count().await.unwrap_or(0);
            for j in 0..wn {
                if let Ok((wdest, wpath)) = app.get_child_at_index(j).await {
                    let wdest = if wdest.is_empty() { adest.clone() } else { wdest };
                    if let Ok(win) = accessible(conn, &wdest, wpath.as_str()).await {
                        let st = win.get_state().await.unwrap_or_default();
                        if has_state(&st, STATE_ACTIVE) {
                            return Ok((adest, apath));
                        }
                    }
                }
            }
        }
    }
    first.ok_or_else(|| format!("no usable application among {n} desktop children"))
}

// ---- op dispatch ----------------------------------------------------------------------------------

/// Per-connection state: the a11y-bus connection + the session-bus connection (for the compositor
/// geometry helper) + the last dump's ref registry.
struct Session {
    conn: zbus::Connection,    // AT-SPI a11y bus
    session: zbus::Connection, // session bus (org.nucleic.Geometry lives here)
    refs: Vec<ObjRef>,
}

impl Session {
    /// The active window's global origin + title from the compositor geometry helper, or `None` if the
    /// helper isn't present (extension not installed/enabled). The title lets `ax_dump` pick the
    /// matching AT-SPI app (so the dump + geometry agree even without keyboard focus).
    async fn focused_window(&self) -> Option<(i32, i32, String)> {
        let g = GeometryProxy::new(&self.session).await.ok()?;
        let (x, y, w, h, title) = g.get_focused_window_frame().await.ok()?;
        if w > 0 && h > 0 {
            Some((x, y, title))
        } else {
            None
        }
    }

    /// Just the origin (for the coordinate-fallback in `ax_action`).
    async fn focused_origin(&self) -> Option<(i32, i32)> {
        self.focused_window().await.map(|(x, y, _)| (x, y))
    }
}

/// Find the application whose top-level window's title matches `title` (from the compositor helper), so
/// the dump targets the same window the helper reports the geometry for — independent of AT-SPI's
/// focus state. Returns the application's object ref.
async fn find_app_by_window_title(conn: &zbus::Connection, title: &str) -> Option<ObjRef> {
    let root = accessible(conn, A11Y_ROOT_DEST, A11Y_ROOT_PATH).await.ok()?;
    let n = root.child_count().await.ok()?;
    for i in 0..n {
        let (adest, apath) = match root.get_child_at_index(i).await {
            Ok(v) => v,
            Err(_) => continue,
        };
        let apath = apath.as_str().to_owned();
        if adest.is_empty() {
            continue;
        }
        if let Ok(app) = accessible(conn, &adest, &apath).await {
            let wn = app.child_count().await.unwrap_or(0);
            for j in 0..wn {
                if let Ok((wd, wp)) = app.get_child_at_index(j).await {
                    let wd = if wd.is_empty() { adest.clone() } else { wd };
                    if let Ok(win) = accessible(conn, &wd, wp.as_str()).await {
                        if let Ok(name) = win.name().await {
                            if !name.is_empty()
                                && (name == title || title.contains(&name) || name.contains(title))
                            {
                                return Some((adest, apath));
                            }
                        }
                    }
                }
            }
        }
    }
    None
}

fn err(msg: impl Into<String>) -> Value {
    json!({"ok": false, "error": msg.into()})
}

impl Session {
    async fn handle(&mut self, req: &Value) -> Value {
        let op = req.get("op").and_then(|v| v.as_str()).unwrap_or("");
        match op {
            "ping" => json!({
                "ok": true, "version": AGENT_VERSION, "os": "linux", "agent": "a11y",
                "readiness": {"accessibility": true, "screenRecording": false, "postEvent": true}
            }),
            "ax_dump" => self.op_dump().await,
            "ax_action" => self.op_action(req).await,
            "ax_set_value" => self.op_set_value(req).await,
            "ax_focus" => self.op_focus(req).await,
            other => err(format!("unknown op '{other}' (a11y agent protocol v{AGENT_VERSION})")),
        }
    }

    async fn op_dump(&mut self) -> Value {
        // Prefer the compositor helper's active window: it gives both the true global origin AND the
        // title, so we dump the exact window it measured — correct even with no AT-SPI focus. Fall back
        // to AT-SPI's own active-app heuristic (and no origin) when the helper isn't present.
        let (dest, path, origin) = match self.focused_window().await {
            Some((ox, oy, title))
                if !title.is_empty() =>
            {
                match find_app_by_window_title(&self.conn, &title).await {
                    Some((d, p)) => (d, p, Some((ox, oy))),
                    None => match find_active_app(&self.conn).await {
                        Ok((d, p)) => (d, p, None),
                        Err(why) => return err(format!("no accessible application to dump: {why}")),
                    },
                }
            }
            _ => match find_active_app(&self.conn).await {
                Ok((d, p)) => (d, p, None),
                Err(why) => return err(format!("no accessible application to dump: {why}")),
            },
        };
        let mut st = DumpState {
            conn: self.conn.clone(),
            refs: Vec::new(),
            budget: NODE_BUDGET,
            origin,
        };
        let tree = st.build_node(&dest, &path, 0).await;
        let app = accessible(&self.conn, &dest, &path).await.ok();
        let app_name = match app {
            Some(a) => a.name().await.unwrap_or_else(|_| "application".into()),
            None => "application".into(),
        };
        let truncated = st.budget <= 0;
        self.refs = st.refs;
        json!({"ok": true, "app": app_name, "tree": tree, "truncated": truncated})
    }

    fn resolve(&self, req: &Value) -> Result<ObjRef, Value> {
        let r = req.get("ref").and_then(|v| v.as_str()).unwrap_or("");
        if r.is_empty() {
            return Err(err("this action requires an element `ref` (from ax_dump)"));
        }
        let idx = r.strip_prefix('e').and_then(|n| n.parse::<usize>().ok());
        match idx.and_then(|i| self.refs.get(i.wrapping_sub(1))) {
            Some(obj) => Ok(obj.clone()),
            None => Err(err(format!(
                "element ref \"{r}\" is unknown or stale — refs are only valid until the next ax_dump; re-dump and retry"
            ))),
        }
    }

    async fn op_action(&mut self, req: &Value) -> Value {
        let (dest, path) = match self.resolve(req) {
            Ok(o) => o,
            Err(e) => return e,
        };
        let want = req.get("action").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
        // Gather this element's semantic actions (empty if it has no Action interface at all).
        let actions = match ActionProxy::builder(&self.conn)
            .destination(dest.clone())
            .and_then(|b| b.path(path.clone()))
        {
            Ok(b) => match b.build().await {
                Ok(p) => (p.get_actions().await.unwrap_or_default(), Some(p)),
                Err(_) => (Vec::new(), None),
            },
            Err(e) => return err(format!("bad ref: {e}")),
        };
        let (names, proxy) = actions;

        // No semantic action available (and none was named) → coordinate fallback: hand the host the
        // element's true global frame so it can click the center via the surface pointer. This is the
        // rare path — Phase 0 measured ~100% semantic-action coverage on GTK.
        if names.is_empty() && want.is_none() {
            let origin = self.focused_origin().await;
            return match read_frame(&self.conn, &dest, &path, origin).await {
                Some(frame) => json!({
                    "ok": true, "fallback": "click", "frame": frame,
                    "note": "no semantic action; click the frame center"
                }),
                None => err("this element has no semantic action and no geometry to click"),
            };
        }

        let proxy = proxy.unwrap(); // names non-empty ⇒ proxy present
        let index = match want {
            Some(name) => match names.iter().position(|(n, _, _)| n.eq_ignore_ascii_case(name)) {
                Some(i) => i as i32,
                None => return err(format!("no action named '{name}' on this element")),
            },
            None => 0, // the primary/default action (macOS AXPress equivalent)
        };
        match proxy.do_action(index).await {
            Ok(true) => json!({"ok": true}),
            Ok(false) => json!({"ok": true, "note": "the toolkit reported the action did not run; re-dump to verify"}),
            Err(e) => err(format!("performing the action failed: {e}")),
        }
    }

    async fn op_set_value(&mut self, req: &Value) -> Value {
        let (dest, path) = match self.resolve(req) {
            Ok(o) => o,
            Err(e) => return e,
        };
        let value = match req.get("value").and_then(|v| v.as_str()) {
            Some(v) => v,
            None => return err("ax_set_value requires `value` (the text to set)"),
        };
        let p = match EditableTextProxy::builder(&self.conn)
            .destination(dest).and_then(|b| b.path(path))
        {
            Ok(b) => match b.build().await {
                Ok(p) => p,
                Err(_) => return err("this element is not editable text — focus it (ax_focus) and type instead"),
            },
            Err(e) => return err(format!("bad ref: {e}")),
        };
        match p.set_text_contents(value).await {
            Ok(_) => json!({"ok": true}),
            Err(e) => err(format!("setting the value failed: {e}")),
        }
    }

    async fn op_focus(&mut self, req: &Value) -> Value {
        let (dest, path) = match self.resolve(req) {
            Ok(o) => o,
            Err(e) => return e,
        };
        let p = match ComponentProxy::builder(&self.conn)
            .destination(dest).and_then(|b| b.path(path))
        {
            Ok(b) => match b.build().await {
                Ok(p) => p,
                Err(_) => return err("this element has no Component interface to focus"),
            },
            Err(e) => return err(format!("bad ref: {e}")),
        };
        match p.grab_focus().await {
            Ok(true) => json!({"ok": true}),
            Ok(false) => json!({"ok": true, "note": "grabFocus returned false; re-dump to verify focus"}),
            Err(e) => err(format!("focusing failed: {e}")),
        }
    }
}

// ---- connection + vsock server -------------------------------------------------------------------

async fn handle_connection(stream: VsockStream, a11y: zbus::Connection, sbus: zbus::Connection) {
    let (rd, mut wr) = tokio::io::split(stream);
    let mut lines = BufReader::new(rd).lines();
    let mut session = Session { conn: a11y, session: sbus, refs: Vec::new() };
    while let Ok(Some(line)) = lines.next_line().await {
        if line.trim().is_empty() {
            continue;
        }
        let reply = match serde_json::from_str::<Value>(&line) {
            Ok(req) => session.handle(&req).await,
            Err(_) => err("malformed request (want a JSON object with an \"op\")"),
        };
        let mut out = serde_json::to_vec(&reply).unwrap_or_else(|_| b"{\"ok\":false}".to_vec());
        out.push(b'\n');
        if wr.write_all(&out).await.is_err() {
            break;
        }
        let _ = wr.flush().await;
    }
}

/// Connect to the session bus and, via it, the AT-SPI a11y bus (org.a11y.Bus → GetAddress → the a11y
/// bus). Returns both: the session bus (for the org.nucleic.Geometry helper) and the a11y bus.
async fn connect_buses() -> zbus::Result<(zbus::Connection, zbus::Connection)> {
    let session = zbus::Connection::session().await?;
    let bus = A11yBusProxy::new(&session).await?;
    let address = bus.get_address().await?;
    let a11y = zbus::connection::Builder::address(address.as_str())?.build().await?;
    Ok((session, a11y))
}

#[tokio::main]
async fn main() {
    let port = std::env::var("NUCLEIC_A11Y_PORT")
        .ok()
        .and_then(|s| s.parse::<u32>().ok())
        .unwrap_or(DEFAULT_PORT);

    let (sbus, a11y) = match connect_buses().await {
        Ok(c) => c,
        Err(e) => {
            eprintln!("[nucleic-a11y-agent] FATAL: could not reach the AT-SPI a11y bus: {e}");
            eprintln!("[nucleic-a11y-agent] (run inside the desktop session; needs org.a11y.Bus + toolkit-accessibility)");
            std::process::exit(1);
        }
    };
    // zbus::Connection is cheaply clonable; the Mutex just guards the shared handles across accepts.
    let buses = Arc::new(Mutex::new((sbus, a11y)));

    let listener = match VsockListener::bind(VsockAddr::new(VMADDR_CID_ANY, port)) {
        Ok(l) => l,
        Err(e) => {
            eprintln!("[nucleic-a11y-agent] FATAL: vsock bind on port {port} failed: {e}");
            std::process::exit(1);
        }
    };
    eprintln!("[nucleic-a11y-agent] listening on vsock port {port} (protocol v{AGENT_VERSION})");

    loop {
        match listener.accept().await {
            Ok((stream, _addr)) => {
                let (sbus, a11y) = {
                    let g = buses.lock().await;
                    (g.0.clone(), g.1.clone())
                };
                tokio::spawn(handle_connection(stream, a11y, sbus));
            }
            Err(e) => {
                eprintln!("[nucleic-a11y-agent] accept failed: {e}");
                tokio::time::sleep(std::time::Duration::from_secs(1)).await;
            }
        }
    }
}
