import Foundation
import NucleicProtocol

/// Computer-use over the macOS guest: the agent SEES the VM's screen (screenshots) and DRIVES its
/// mouse / keyboard / apps, using the VM as its own Mac development simulator. The framework has no
/// host-side screen/input API for Mac guests, so this is done two ways — both **network-free**:
///   • the **host-side surface** (`VZVirtualMachineView` framebuffer + synthesized HID) — the default,
///     needs no guest software, no TCC, no SIP; and
///   • the **native in-guest agent** over vsock (AX-semantic ops, and pixel ops via CGEvent).
///
/// A last-resort fallback drives `screencapture`/`cliclick` inside the guest **over the vsock exec
/// channel** (``run``). Because those tools must run in the console user's *Aqua* GUI session (an exec
/// lands in launchd's *Background* domain), each is re-dispatched with `launchctl asuser $(id -u)
/// <abs-path>`, and it needs Screen Recording + Accessibility TCC — which is why the surface/agent
/// paths above are preferred (they avoid those permission prompts entirely).
///
/// Coordinates: the VZ display renders 1920×1200 logical points (a 3840×2400 @ 2× Retina panel), and
/// the surface normalizes captures to that space, so screenshot pixels map 1:1 to click coordinates —
/// the agent targets exactly what it sees, no scaling.
extension MacVMEngine {
    // Absolute paths: `launchctl asuser` provides no PATH.
    static let screencapturePath = "/usr/sbin/screencapture"
    static let cliclickPath = "/usr/local/bin/cliclick"
    static let openPath = "/usr/bin/open"
    /// A host AppKit/Virtualization call should finish in milliseconds. Bound it generously so an OS
    /// framework stall cannot leave the MCP request (and therefore the chat turn) waiting forever.
    static let surfaceComputerActionTimeoutSeconds: TimeInterval = 15

    // MARK: - Literal-text typing bounds (the surface synthesizes on the MAIN actor)

    /// Longest literal `type` payload that stays on the low-latency direct path (one surface send,
    /// like a chord). 64 characters covers the common interactive cases — a URL, a filename, a shell
    /// one-liner — and synthesizes in well under a display-frame budget; anything longer routes
    /// through ``typeLongSurfaceText`` so one paste-sized payload can't pin the main actor.
    static let directTypeMaxCharacters = 64
    /// Characters per synthesized chunk for a long payload. Each character is at most four
    /// synchronous `NSEvent` deliveries to the VM view, so a 32-character slice stays far inside one
    /// 120 Hz frame budget on the main actor — and well inside
    /// ``surfaceComputerActionTimeoutSeconds`` now that the surface paces each stroke
    /// (`MacVMComputerSurface.interStrokeMillis`) to stay under the guest keyboard's HID report rate.
    static let typeChunkCharacters = 32
    /// Suspension between chunks. A real sleep (not just `Task.yield`) guarantees the main actor
    /// drains pending host UI work — scrolling, keystrokes — between slices. Largely subsumed by the
    /// surface's per-stroke pacing (every stroke now yields the main actor too), but kept as the
    /// chunk-boundary breather: it is what let the guest's HID endpoint recover between bursts back
    /// when a chunk was synthesized as one unpaced run. Typing throughput is now set by the
    /// per-stroke gap, so a 10,000-character payload takes tens of seconds, not a few.
    static let typeChunkYieldNanos: UInt64 = 10_000_000  // 10 ms
    /// Assumed worst-case in-guest agent typing throughput (characters/second), used only to scale
    /// the vsock request deadline for a long `type` op so a big payload isn't misread as a hung agent.
    static let agentTypeMinCharsPerSecond: Double = 200

    // MARK: - Post-action settle (repaint is async; grabbing immediately hands back a pre-action frame)

    /// Minimum quiet time after injecting an input before the first post-action grab, so the guest has
    /// begun painting the result. Below this even the surface's adaptive wait can only see a pre-action
    /// frame, so it's the floor for every path.
    static let settleFloorNanos: UInt64 = 120_000_000  // 120 ms
    /// Spacing between the surface path's adaptive re-grabs while it waits for the framebuffer to change.
    static let settlePollIntervalNanos: UInt64 = 60_000_000  // 60 ms
    /// Upper bound on how long the surface path waits for a visible change before returning the latest
    /// frame anyway (a click on empty space, or a genuinely slow app, may never repaint in this window).
    static let settleCapNanos: UInt64 = 800_000_000  // 800 ms
    /// Fixed settle for the vsock capture paths (agent / shell), where re-grabbing to detect a change is
    /// too expensive to poll — one delay long enough to clear the common repaint latency, then capture.
    static let settleFixedNanos: UInt64 = 250_000_000  // 250 ms
    /// EXTRA settle after a scroll, on top of the usual change-wait. An app with smooth scrolling
    /// animates to the new offset over a few hundred milliseconds, and the adaptive wait returns at the
    /// FIRST changed frame — the animation's opening frame, which reads as "the scroll barely moved".
    /// Waiting out a typical animation first means the screenshot shows where the content landed.
    static let scrollAnimationSettleNanos: UInt64 = 300_000_000  // 300 ms

    /// Whether `action` injects input and so needs a repaint settle before the post-action grab.
    /// `screenshot` has nothing to settle, `cursor_position` returns text, and `wait` manages its own
    /// timing (it sleeps, then wants the settled frame directly) — none of those wait here.
    static func actionNeedsSettle(_ action: String) -> Bool {
        switch action {
        case "screenshot", "cursor_position", "wait": return false
        default: return true
        }
    }

    /// Grab the surface **once the framebuffer has actually changed** from `before` (the frame captured
    /// before the input was injected), so the agent sees the action's effect instead of a pre-repaint
    /// frame. Waits the floor, then polls the cheap host-side grab until the JPEG bytes differ from
    /// `before` or the cap elapses; returns base64 JPEG. JPEG encoding of an unchanged framebuffer is
    /// deterministic, so byte-equality means "not yet painted"; any nondeterminism only makes this
    /// return at the floor (never worse than a fixed delay). Falls back to a plain grab with no
    /// reference frame.
    func settledSurfaceCapture(name: String, before: Data?) async -> String? {
        await settledSurfaceFrame(name: name, before: before).frame?.base64EncodedString()
    }

    /// ``settledSurfaceCapture`` as raw JPEG plus **whether the framebuffer actually changed** within
    /// the cap. The `changed` flag is what lets the scroll path tell "the guest ignored my wheel event"
    /// apart from "it scrolled" — an unchanged frame is the signature of a swallowed scroll (or of a
    /// view already pinned at its end stop). With no reference frame there is nothing to compare, so
    /// `changed` is reported `true` (assume the action landed rather than retrying blindly).
    func settledSurfaceFrame(name: String, before: Data?) async -> (frame: Data?, changed: Bool) {
        try? await Task.sleep(nanoseconds: Self.settleFloorNanos)
        guard let before else { return (await captureSurfaceData(name: name), true) }
        var waited = Self.settleFloorNanos
        var latest = await captureSurfaceData(name: name)
        while waited < Self.settleCapNanos {
            if let latest, latest != before { return (latest, true) }
            try? await Task.sleep(nanoseconds: Self.settlePollIntervalNanos)
            waited += Self.settlePollIntervalNanos
            latest = await captureSurfaceData(name: name)
        }
        return (latest ?? before, false)
    }

    /// Perform one computer-use action in the named guest and return the post-action screenshot
    /// (base64 JPEG, ready for the MCP image block) plus a short text summary. `screenshot` just
    /// captures; `cursor_position` returns the pointer location as text; `wait` pauses then captures.
    /// Invalid/missing coordinates yield a `denied`-style text with no image (the caller maps it).
    ///
    /// Routing (docs/MACOS_VM.md §12) — every path is network-free:
    ///   1. `ax_*` semantic actions → the **native in-guest agent** over vsock (needs the AX TCC
    ///      grants, written automatically during the base build). No host equivalent.
    ///   2. all **pixel** actions → the **host-side surface** (VZVirtualMachineView framebuffer capture
    ///      + synthesized HID) when the VM has one — the default, SIP-free path. `launch_app` has no
    ///      HID analogue, so it uses the agent's vsock exec `open -a` (which needs no TCC either).
    ///   3. legacy fallback (a VM booted without a surface): the native agent's pixel ops, else the
    ///      vsock-exec `screencapture`/`cliclick` path.
    public func performComputerAction(
        name: String, action: String, x: Int?, y: Int?, text: String?,
        ref: String? = nil, value: String? = nil, url: String? = nil,
        scrollDirection: String?, scrollAmount: Int?, durationMs: Int?
    ) async throws -> (imageBase64: String?, summary: String) {
        // Shortcut: open a well-known browser by short name (chrome/firefox/safari/edge), optionally
        // straight to a starting `url`. Launch is a shell op with no HID analogue, so it resolves the
        // right per-OS app/binary and runs over the vsock exec channel regardless of which capture path
        // this VM uses — a predictable way to get a browser on screen without the agent knowing install
        // paths (or having to type into the address bar).
        if action == "open_browser" {
            return await performOpenBrowser(
                name: name, requested: text, url: url, durationMs: durationMs)
        }
        if Self.axActions.contains(action) {
            // Linux: route to the in-guest **AT-SPI semantic agent** (docs/LINUX_VM_SEMANTIC_AGENT.md)
            // over its own vsock port. Same `ax_*` NDJSON wire as macOS, so the AX handler is reused; if
            // the agent isn't present (base built without it, or not up yet), fall back to pixels.
            if live[name]?.os == .linux {
                guard let client = await linuxA11yClient(name: name) else {
                    return (
                        nil,
                        "The '\(action)' semantic AX action needs the in-guest AT-SPI agent, which this "
                            + "Linux VM doesn't have (rebuild the base with the a11y agent). Use screenshot "
                            + "+ pixel actions (left_click / type / scroll) instead."
                    )
                }
                return await performAXComputerAction(
                    name: name, client: client, action: action, x: x, y: y, text: text,
                    ref: ref, value: value)
            }
            guard let client = await agentClient(name: name) else {
                return (
                    nil,
                    "The '\(action)' action needs the optional semantic AX agent, which this VM "
                        + "doesn't have (enable it in Settings ▸ Virtual Machines and rebuild the base). "
                        + "Use screenshot + pixel actions instead."
                )
            }
            return await performAXComputerAction(
                name: name, client: client, action: action, x: x, y: y, text: text,
                ref: ref, value: value)
        }
        // Default: host-side virtual-IO (no in-guest agent, no TCC, no SIP).
        if hasComputerSurface(name) {
            return await performSurfaceComputerAction(
                name: name, action: action, x: x, y: y, text: text,
                scrollDirection: scrollDirection, scrollAmount: scrollAmount, durationMs: durationMs)
        }
        // Legacy fallback: a VM booted without a surface (agent pixel ops, then the vsock-exec
        // screencapture/cliclick path).
        if let client = await agentClient(name: name),
            let native = await performAgentComputerAction(
                name: name, client: client, action: action, x: x, y: y, text: text,
                scrollDirection: scrollDirection, scrollAmount: scrollAmount,
                durationMs: durationMs)
        {
            return native
        }
        return try await performShellComputerAction(
            name: name, action: action, x: x, y: y, text: text,
            scrollDirection: scrollDirection, scrollAmount: scrollAmount, durationMs: durationMs)
    }

    /// The default computer-use path: drive the VM entirely host-side through its
    /// `VZVirtualMachineView` surface — framebuffer capture + synthesized keyboard/mouse HID. No guest
    /// software, no TCC, no SIP. Every screen-affecting action returns a fresh screenshot.
    func performSurfaceComputerAction(
        name: String, action: String, x: Int?, y: Int?, text: String?,
        scrollDirection: String?, scrollAmount: Int?, durationMs: Int?
    ) async -> (imageBase64: String?, summary: String) {
        // Grab a reference frame BEFORE injecting the input so the post-action capture can wait for the
        // framebuffer to actually change — repaint is async, and an immediate grab returns the
        // pre-action frame (the agent then thinks the action did nothing until it re-screenshots).
        let settle = Self.actionNeedsSettle(action)
        let before: Data? = settle ? await captureSurfaceData(name: name) : nil
        switch action {
        case "screenshot":
            break

        case "mouse_move":
            guard let px = x, let py = y else { return (nil, "mouse_move requires x and y.") }
            guard await sendSurfaceInputBounded(name: name, .move(x: px, y: py)) else {
                return surfaceActionTimedOut(action)
            }

        case "left_click", "right_click", "double_click":
            guard let px = x, let py = y else { return (nil, "\(action) requires x and y.") }
            let button: MacVMSurfaceInput.Button = action == "right_click" ? .right : .left
            let count = action == "double_click" ? 2 : 1
            guard await sendSurfaceInputBounded(
                name: name, .click(x: px, y: py, button: button, count: count))
            else { return surfaceActionTimedOut(action) }

        case "left_click_drag":
            guard let tx = x, let ty = y else {
                return (nil, "left_click_drag requires x and y (the drag target).")
            }
            let start = await surfaceCursorBounded(name: name) ?? Point(x: tx, y: ty)
            guard await sendSurfaceInputBounded(
                name: name, .drag(fromX: start.x, fromY: start.y, toX: tx, toY: ty))
            else { return surfaceActionTimedOut(action) }

        case "type":
            guard let text, !text.isEmpty else { return (nil, "type requires non-empty `text`.") }
            if text.count <= Self.directTypeMaxCharacters {
                // Short text keeps the low-latency direct path (one send, like a chord).
                guard await sendSurfaceInputBounded(name: name, .text(text)) else {
                    return surfaceActionTimedOut(action)
                }
            } else if let failure = await typeLongSurfaceText(name: name, text: text) {
                return failure
            }

        case "key":
            guard let chord = text, !chord.isEmpty else {
                return (nil, "key requires a chord in `text` (e.g. \"cmd+s\").")
            }
            guard await sendSurfaceInputBounded(name: name, .key(chord: chord)) else {
                return surfaceActionTimedOut(action)
            }

        case "scroll":
            let (dx, dy) = Self.scrollDeltas(
                direction: scrollDirection ?? "down", amount: max(1, min(scrollAmount ?? 3, 30)))
            // Where to scroll: the caller's point when it named one (so a specific pane can be
            // targeted), else the last known pointer, else the screen centre. The surface moves the
            // guest pointer there first — a wheel event carries no coordinates of its own, so the guest
            // routes it to whatever view the pointer happens to be over.
            var pos = x.flatMap { px in y.map { Point(x: px, y: $0) } }
            if pos == nil { pos = await surfaceCursorBounded(name: name) }
            let target = pos ?? Point(x: 960, y: 600)
            let scroll = MacVMSurfaceInput.scroll(x: target.x, y: target.y, dx: dx, dy: dy)
            guard await sendSurfaceInputBounded(name: name, scroll) else {
                return surfaceActionTimedOut(action)
            }
            return await scrollResult(name: name, before: before, retry: scroll)

        case "launch_app":
            guard let app = text, !app.isEmpty else {
                return (nil, "launch_app requires an app name in `text`.")
            }
            // macOS launches an `.app` by name via `open -a`; the Linux desktop has no `open`, so run
            // the named binary in the guest's GUI session instead (see `linuxLaunchBinaryCommand`).
            let launch = (live[name]?.os == .linux)
                ? Self.linuxLaunchBinaryCommand(candidates: [app])
                : Self.launchAppCommand(app)
            _ = try? await run(name: name, command: launch, workdir: nil)

        case "cursor_position":
            let pos = await surfaceCursorBounded(name: name)
            return (nil, "Cursor position: \(pos.map { "\($0.x),\($0.y)" } ?? "unknown")")

        case "wait":
            let ms = max(0, min(durationMs ?? 1000, 30_000))
            try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)

        default:
            return (nil, "Unknown action '\(action)'.")
        }

        let shot = await withTimeout(Self.surfaceComputerActionTimeoutSeconds) { [self] in
            settle
                ? await settledSurfaceCapture(name: name, before: before)
                : await captureSurfaceBase64(name: name)
        }
        if let shot {
            return (shot, screenSummary(for: action, name: name))
        }
        return (
            nil,
            "The action ran, but the host-side framebuffer capture returned no image or timed out."
        )
    }

    /// Capture the result of a surface scroll, re-sending the gesture ONCE if nothing moved.
    ///
    /// Even paced, a scroll is the one gesture a guest can legitimately swallow whole: the app may have
    /// been mid-animation, still latching the gesture to a target view, or busy when the notches
    /// arrived. Every other action lands or doesn't, but a dropped scroll is invisible — the agent gets
    /// an unchanged screenshot and cannot tell "ignored" from "already at the bottom". So: wait out a
    /// typical smooth-scroll animation, then check whether the framebuffer moved at all; if not, send
    /// the same gesture again (a repeated scroll is idempotent-ish — worst case the view moves twice as
    /// far, versus not at all). If it still hasn't moved, say so in the summary, which is the honest
    /// answer and tells the agent to stop retrying and look for a different target.
    private func scrollResult(
        name: String, before: Data?, retry: MacVMSurfaceInput
    ) async -> (imageBase64: String?, summary: String) {
        try? await Task.sleep(nanoseconds: Self.scrollAnimationSettleNanos)
        var settled = await withTimeout(Self.surfaceComputerActionTimeoutSeconds) { [self] in
            await settledSurfaceFrame(name: name, before: before)
        }
        if settled?.changed == false {
            guard await sendSurfaceInputBounded(name: name, retry) else {
                return surfaceActionTimedOut("scroll")
            }
            try? await Task.sleep(nanoseconds: Self.scrollAnimationSettleNanos)
            settled = await withTimeout(Self.surfaceComputerActionTimeoutSeconds) { [self] in
                await settledSurfaceFrame(name: name, before: before)
            }
        }
        guard let settled, let frame = settled.frame else {
            return (
                nil,
                "The scroll ran, but the host-side framebuffer capture returned no image or timed out."
            )
        }
        var summary = screenSummary(for: "scroll", name: name)
        if !settled.changed {
            summary += " NOTE: the screen did not change, across two attempts — the view is likely "
                + "already at its end stop, or the pointer is not over a scrollable area. Do not just "
                + "retry: click into the pane you mean to scroll first (or pass `x`,`y` to aim the "
                + "scroll at it), or check whether this content scrolls at all."
        }
        return (frame.base64EncodedString(), summary)
    }

    /// Send one surface input without letting an unresponsive AppKit/Virtualization call pin the MCP
    /// handler forever. `withTimeout` deliberately abandons a non-cooperative operation at the deadline.
    private func sendSurfaceInputBounded(name: String, _ input: MacVMSurfaceInput) async -> Bool {
        await withTimeout(Self.surfaceComputerActionTimeoutSeconds) { [self] () -> Bool? in
            await sendSurfaceInput(name: name, input)
            return true
        } == true
    }

    /// Type a LONG literal payload without synthesizing every character in one main-actor call
    /// (docs/MAIN_THREAD_PERFORMANCE_PLAN.md §5). Preference order:
    ///   1. the native in-guest agent's `type` op — the keystrokes are CGEvents synthesized *inside*
    ///      the guest, so the host main actor pays only a vsock round-trip;
    ///   2. host HID synthesis in ``typeChunkCharacters``-sized chunks with a real suspension between
    ///      them, so host scrolling/keystrokes interleave with the payload instead of waiting it out.
    ///
    /// Cancellation is cooperative: the chunk loop runs on the caller's task (the MCP handler task,
    /// which is cancelled when the session stops or its connection closes), so a stopped computer-use
    /// action stops at the next chunk boundary. Each character's modifiers press and release inside
    /// one synchronous stroke (`postStroke`), and chunks split only between strokes, so an early stop
    /// can never leave shift — or any modifier — held down in the guest.
    ///
    /// Returns `nil` on success, or the (image, summary) failure for the caller to hand back.
    private func typeLongSurfaceText(
        name: String, text: String
    ) async -> (imageBase64: String?, summary: String)? {
        // Guest-agent route (macOS guests only — the Linux main-port agent is exec-only). One op,
        // typed entirely in-guest; the deadline scales with the payload so a long-but-healthy typing
        // run isn't misread as a hung agent.
        if live[name]?.os != .linux, let client = await agentClient(name: name) {
            let deadline = max(
                MacVMAgentWire.requestTimeout,
                Double(text.count) / Self.agentTypeMinCharsPerSecond)
            do {
                _ = try await client.request(
                    op: "type", fields: ["text": .string(text)], timeout: deadline)
                return nil
            } catch let error as MacVMAgentError where !error.isTransportFailure {
                // The agent ran the op and reported failure (transport healthy) — nothing was typed,
                // so host synthesis below is a safe retry.
                await noteAgentFailure(name: name, client: client, error: error)
            } catch {
                // A transport failure or timeout may have landed AFTER the guest started typing.
                // Re-typing host-side could double the payload, so surface it instead (mirrors the
                // pixel path's "never re-run past this point" rule).
                await noteAgentFailure(name: name, client: client, error: error)
                return (
                    nil,
                    "The in-guest agent's typing did not confirm "
                        + "(\((error as? MacVMAgentError)?.description ?? String(describing: error))); "
                        + "part of the text may have been typed. Take a screenshot to check before "
                        + "retyping."
                )
            }
        }
        // Host synthesis, bounded: at most `typeChunkCharacters` per main-actor slice, suspending
        // between slices so the payload never monopolizes the main thread.
        for (index, chunk) in Self.typeChunks(text, size: Self.typeChunkCharacters).enumerated() {
            // A stopped/aborted computer-use action cancels its handler task — stop at the boundary
            // (chunks end on complete strokes, so no modifier is left held).
            if Task.isCancelled {
                return (nil, "Typing was cancelled before the full text was delivered.")
            }
            if index > 0 { try? await Task.sleep(nanoseconds: Self.typeChunkYieldNanos) }
            guard await sendSurfaceInputBounded(name: name, .text(chunk)) else {
                return surfaceActionTimedOut("type")
            }
        }
        return nil
    }

    /// Split a literal `type` payload into ≤`size`-character chunks, preserving order and content
    /// exactly (`Character` boundaries, so a grapheme never splits mid-sequence). Pure; unit-tested.
    static func typeChunks(_ text: String, size: Int) -> [String] {
        guard size > 0 else { return text.isEmpty ? [] : [text] }
        var chunks: [String] = []
        var index = text.startIndex
        while index < text.endIndex {
            let end = text.index(index, offsetBy: size, limitedBy: text.endIndex) ?? text.endIndex
            chunks.append(String(text[index..<end]))
            index = end
        }
        return chunks
    }

    /// Read the surface cursor under the same deadline as input and capture. A timeout is equivalent to
    /// an unavailable position; drag/scroll already have safe coordinate fallbacks.
    private func surfaceCursorBounded(name: String) async -> Point? {
        await withTimeout(Self.surfaceComputerActionTimeoutSeconds) { [self] in
            await surfaceCursor(name: name)
        }
    }

    private func surfaceActionTimedOut(
        _ action: String
    ) -> (imageBase64: String?, summary: String) {
        (
            nil,
            "The host-side '\(action)' action timed out after "
                + "\(Int(Self.surfaceComputerActionTimeoutSeconds)) seconds. It may still complete; "
                + "request a fresh screenshot before deciding whether to retry it."
        )
    }

    /// Map a scroll direction + line amount to `(dx, dy)` wheel deltas (positive dy = up, positive
    /// dx = left), for the surface's `scrollWheel` synthesis. This is the TOTAL for the gesture; it is
    /// delivered as a paced stream of ``scrollTicks``, never as one event.
    static func scrollDeltas(direction: String, amount: Int) -> (Int, Int) {
        switch direction.lowercased() {
        case "up": return (0, amount)
        case "left": return (amount, 0)
        case "right": return (-amount, 0)
        default: return (0, -amount)  // down
        }
    }

    // MARK: - Scroll pacing (a wheel is a STREAM of small notches, not one big delta)

    /// Lines per synthesized wheel notch. Real hardware — a wheel or a trackpad — reports a stream of
    /// small deltas, and apps with smooth scrolling are built for exactly that: each event starts a
    /// short animation, and whatever arrives while one is in flight is coalesced into it. So a single
    /// outsized event (the old behaviour: the whole `scroll_amount` in one 30-line event) animates
    /// about one notch's worth and the rest is discarded — the scroll "didn't take", or moved a
    /// fraction of what was asked, depending on the app. Two lines is roughly one physical notch.
    public static let scrollLinesPerTick = 2
    /// Gap between notches. Long enough that the guest delivers each wheel report separately and an
    /// animating app finishes consuming the previous one (below ~30 ms they coalesce again, which is
    /// the bug this fixes); short enough that a full 30-line scroll stays well inside
    /// ``surfaceComputerActionTimeoutSeconds``.
    public static let scrollTickIntervalMillis = 45

    /// Split a total signed line delta into the per-notch deltas to deliver in sequence, each at most
    /// ``scrollLinesPerTick`` and preserving sign. The ticks always sum back to `(dx, dy)`, so pacing
    /// changes the delivery shape, never the requested distance.
    public static func scrollTicks(dx: Int, dy: Int) -> [(dx: Int, dy: Int)] {
        let per = max(1, scrollLinesPerTick)
        let count = max(divideRoundingUp(abs(dx), per), divideRoundingUp(abs(dy), per))
        guard count > 0 else { return [] }
        var ticks: [(dx: Int, dy: Int)] = []
        var (remainingX, remainingY) = (dx, dy)
        for _ in 0..<count {
            let stepX = remainingX >= 0 ? min(remainingX, per) : max(remainingX, -per)
            let stepY = remainingY >= 0 ? min(remainingY, per) : max(remainingY, -per)
            ticks.append((dx: stepX, dy: stepY))
            remainingX -= stepX
            remainingY -= stepY
        }
        return ticks
    }

    private static func divideRoundingUp(_ value: Int, _ divisor: Int) -> Int {
        (value + divisor - 1) / divisor
    }

    /// The `launchctl asuser` + screencapture/cliclick implementation, driven over the **vsock exec
    /// channel** (``run``) — the last-resort fallback for a VM with the agent but no host surface and
    /// where the agent's native pixel ops didn't apply. Prefer the surface/agent paths (no TCC).
    func performShellComputerAction(
        name: String, action: String, x: Int?, y: Int?, text: String?,
        scrollDirection: String?, scrollAmount: Int?, durationMs: Int?
    ) async throws -> (imageBase64: String?, summary: String) {
        switch action {
        case "screenshot":
            break  // fall through to the capture below

        case "left_click", "right_click", "double_click", "mouse_move":
            guard let px = x, let py = y else {
                return (nil, "\(action) requires x and y coordinates.")
            }
            _ = try await run(
                name: name, command: Self.pointerCommand(action: action, x: px, y: py), workdir: nil)

        case "left_click_drag":
            guard let tx = x, let ty = y else {
                return (nil, "left_click_drag requires x and y coordinates (the drag target).")
            }
            // Drag from the CURRENT cursor to the target. `cliclick`'s `dd:`/`du:` need explicit
            // coordinates (they don't accept the `.` current-position placeholder), so read the start.
            let pos = try await run(name: name, command: Self.cursorPositionCommand(), workdir: nil)
            let (sx, sy) = Self.parseCursor(pos.stdout) ?? (tx, ty)
            _ = try await run(
                name: name, command: Self.dragCommand(fromX: sx, fromY: sy, toX: tx, toY: ty),
                workdir: nil)

        case "type":
            guard let text, !text.isEmpty else {
                return (nil, "type requires non-empty `text`.")
            }
            _ = try await run(name: name, command: Self.typeCommand(text), workdir: nil)

        case "key":
            guard let chord = text, !chord.isEmpty else {
                return (nil, "key requires a chord in `text` (e.g. \"cmd+s\").")
            }
            _ = try await run(name: name, command: Self.keyCommand(chord: chord), workdir: nil)

        case "scroll":
            let dir = scrollDirection ?? "down"
            let amount = max(1, min(scrollAmount ?? 3, 30))
            _ = try await run(
                name: name, command: Self.scrollCommand(direction: dir, amount: amount), workdir: nil)

        case "launch_app":
            guard let app = text, !app.isEmpty else {
                return (nil, "launch_app requires an app name in `text`.")
            }
            _ = try await run(name: name, command: Self.launchAppCommand(app), workdir: nil)

        case "cursor_position":
            let pos = try await run(name: name, command: Self.cursorPositionCommand(), workdir: nil)
            let trimmed = pos.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
            return (nil, "Cursor position: \(trimmed.isEmpty ? "unknown" : trimmed)")

        case "wait":
            let ms = max(0, min(durationMs ?? 1000, 30_000))
            try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)

        default:
            return (nil, "Unknown action '\(action)'.")
        }

        // Let the guest repaint before grabbing (async repaint, else the screenshot is a pre-action
        // frame). This path captures over the vsock exec channel — too costly to poll for a change — so
        // it uses one fixed settle rather than the surface path's adaptive wait.
        if Self.actionNeedsSettle(action) {
            try? await Task.sleep(nanoseconds: Self.settleFixedNanos)
        }
        // Every screen-affecting action returns a fresh screenshot so the agent can see the result.
        if let shot = try await captureScreenshotBase64(name: name) {
            return (shot, screenSummary(for: action, name: name))
        }
        return (
            nil,
            "The action ran, but no screenshot came back (empty or too large). The guest may be "
                + "missing Screen Recording permission (TCC), or the display may not be ready yet. If "
                + "screenshots stay blank or unavailable, use the semantic `ax_dump` + `ax_*` actions "
                + "instead (they read and drive the UI without pixels, via the VM's native agent; see "
                + "docs/MACOS_VM_NATIVE_AGENT.md).")
    }

    /// One-line summary accompanying a computer-use screenshot. Names the guest OS from the live
    /// registry rather than hardcoding "macOS" — the same computer-use surface drives Linux guests, and
    /// calling a Linux desktop a Mac is a small lie that makes an agent doubt what it's looking at.
    func screenSummary(for action: String, name: String) -> String {
        guard action == "screenshot" else { return "Did '\(action)'; here's the screen now." }
        return "Screenshot of the \(live[name]?.os == .linux ? "Linux" : "macOS") VM screen."
    }

    /// Capture the guest screen and return a pure (whitespace-stripped) base64 JPEG, or `nil` if the
    /// capture was empty or too large. JPEG (not PNG) keeps the per-action payload ~10× smaller so a
    /// screenshot→act loop doesn't blow up the model context; the bounded drain discards a pathological
    /// oversize capture (a truncated base64 would be unusable anyway).
    func captureScreenshotBase64(name: String) async throws -> String? {
        let (raw, truncated) = try await rawStdout(name: name, command: Self.screenshotCommand())
        if truncated { return nil }
        // `base64` wraps at 76 columns; strip all whitespace so the MCP image `data` is clean base64.
        let b64 = raw.filter { !$0.isWhitespace }
        return b64.isEmpty ? nil : b64
    }

    /// Parse `cliclick p` output ("x,y") into a point.
    static func parseCursor(_ s: String) -> (Int, Int)? {
        let parts = s.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: ",")
        guard parts.count == 2, let x = Int(parts[0]), let y = Int(parts[1]) else { return nil }
        return (x, y)
    }

    // MARK: - Pure command builders (in-guest shell, Aqua-dispatched). Unit-tested.

    /// Wrap a command so it runs in the console user's Aqua GUI session (required for screen/input).
    static func asuser(_ command: String) -> String { "launchctl asuser $(id -u) \(command)" }

    /// Capture the full screen to a temp JPEG and emit it base64 on stdout, then clean up. JPEG keeps
    /// the payload small enough to return on every action in a loop.
    static func screenshotCommand() -> String {
        let f = "/tmp/nucleic-shot-$$.jpg"
        return asuser("\(screencapturePath) -x -t jpg \(f)")
            + " >/dev/null 2>&1; base64 -i \(f) 2>/dev/null; rm -f \(f)"
    }

    /// `cliclick` pointer command for a click/move action at (x,y).
    static func pointerCommand(action: String, x: Int, y: Int) -> String {
        let verb: String
        switch action {
        case "right_click": verb = "rc:\(x),\(y)"
        case "double_click": verb = "dc:\(x),\(y)"
        case "mouse_move": verb = "m:\(x),\(y)"
        default: verb = "c:\(x),\(y)"  // left_click
        }
        return asuser("\(cliclickPath) \(verb)")
    }

    /// `cliclick` drag from an explicit start to a target (both coordinates required — the `dd:`/`du:`
    /// verbs don't accept the `.` current-position placeholder).
    static func dragCommand(fromX: Int, fromY: Int, toX: Int, toY: Int) -> String {
        asuser("\(cliclickPath) dd:\(fromX),\(fromY) du:\(toX),\(toY)")
    }

    /// Type a literal string (a small inter-keystroke delay improves reliability).
    static func typeCommand(_ text: String) -> String {
        asuser("\(cliclickPath) -w 20 t:\(shQuote(text))")
    }

    /// Translate a chord like `cmd+s`, `return`, `cmd+shift+4`, `tab` into a `cliclick` invocation.
    static func keyCommand(chord: String) -> String {
        let parts = chord.lowercased().split(separator: "+").map(String.init)
        guard let last = parts.last else { return asuser("\(cliclickPath) kp:return") }
        let modTokens = parts.dropLast().compactMap(modifierName)
        let keyTerm = keyTerm(for: last)
        if modTokens.isEmpty {
            return asuser("\(cliclickPath) \(keyTerm)")
        }
        let mods = modTokens.joined(separator: ",")
        return asuser("\(cliclickPath) kd:\(mods) \(keyTerm) ku:\(mods)")
    }

    /// Map a key name to a `cliclick` term: a `kp:` press for a named special key, else `t:` for a
    /// literal character.
    static func keyTerm(for key: String) -> String {
        let specials: [String: String] = [
            "return": "return", "enter": "return", "tab": "tab", "space": "space",
            "esc": "esc", "escape": "esc", "delete": "delete", "backspace": "delete",
            "up": "arrow-up", "down": "arrow-down", "left": "arrow-left", "right": "arrow-right",
            "arrowup": "arrow-up", "arrowdown": "arrow-down", "arrowleft": "arrow-left",
            "arrowright": "arrow-right", "home": "home", "end": "end",
            "pageup": "page-up", "pagedown": "page-down",
        ]
        if let s = specials[key] { return "kp:\(s)" }
        if key.count == 1 { return "t:\(shQuote(key))" }
        if key.hasPrefix("f"), Int(key.dropFirst()) != nil { return "kp:\(key)" }  // f1…f15
        return "t:\(shQuote(key))"
    }

    static func modifierName(_ token: String) -> String? {
        switch token {
        case "cmd", "command", "meta", "super": return "cmd"
        case "ctrl", "control": return "ctrl"
        case "alt", "opt", "option": return "alt"
        case "shift": return "shift"
        case "fn": return "fn"
        default: return nil
        }
    }

    /// Scroll is not a first-class `cliclick` verb, so approximate with repeated arrow/page keys.
    /// Best-effort — documented as an approximation.
    static func scrollCommand(direction: String, amount: Int) -> String {
        let key: String
        switch direction.lowercased() {
        case "up": key = "arrow-up"
        case "left": key = "arrow-left"
        case "right": key = "arrow-right"
        default: key = "arrow-down"
        }
        let presses = Array(repeating: "kp:\(key)", count: amount).joined(separator: " ")
        return asuser("\(cliclickPath) \(presses)")
    }

    /// `open -a <app> [url]` — a trailing URL is handed to the app as its document to open, which for a
    /// browser means "start on this page" (and, when the browser is already up, "open it in a new tab").
    static func launchAppCommand(_ app: String, url: String? = nil) -> String {
        var command = "\(openPath) -a \(shQuote(app))"
        if let url, !url.isEmpty { command += " \(shQuote(url))" }
        return asuser(command)
    }

    /// Launch the first of `apps` that `open -a` can actually resolve, optionally at `url`. Used for the
    /// browser shortcut, where one key maps to a preferred `.app` plus fallbacks (e.g. "Google Chrome
    /// for Testing" then stock "Google Chrome"); `open -a` fails with a non-zero exit when the named app
    /// isn't installed, so a `||` chain lands on whichever one this guest carries.
    static func launchAppCommand(_ apps: [String], url: String? = nil) -> String {
        guard let first = apps.first else { return asuser("true") }
        guard apps.count > 1 else { return launchAppCommand(first, url: url) }
        let urlArg = (url?.isEmpty == false) ? " \(shQuote(url!))" : ""
        // Each branch is separately `asuser`-wrapped rather than chaining inside one wrap: `asuser` is a
        // bare `launchctl asuser $(id -u) …` prefix, not an `sh -c`, so a `||` INSIDE it would be parsed
        // by the outer shell and the fallback would run in launchd's Background domain — the very thing
        // the wrapper exists to avoid. `launchctl asuser` exits with the spawned process's status, so
        // the `||` still chains correctly across the wrapped branches.
        return apps
            .map { asuser("\(openPath) -a \(shQuote($0))\(urlArg)") }
            .joined(separator: " || ")
    }

    // MARK: - `open_browser` shortcut

    /// The autologin desktop account on the Linux base (see `guest/linux-base/provision`). GUI apps
    /// must run inside ITS Wayland/D-Bus session, not as the root the exec agent runs as.
    static let linuxDesktopUser = "nucleic"

    /// Well-known browsers the `open_browser` shortcut resolves, so the agent can open one by a short,
    /// stable name instead of guessing the exact app or binary. `macApps` are the macOS `.app` names to
    /// try in order (first one `open -a` can launch wins); `linuxBins` are the Linux executables to try
    /// in order (first on `PATH` wins). An empty `linuxBins` means the browser isn't offered on Linux
    /// (e.g. Safari is macOS-only). `linuxArgs` are extra flags every launch of that browser gets.
    struct KnownBrowser: Sendable {
        let key: String
        let macApps: [String]
        let linuxBins: [String]
        let linuxArgs: [String]

        init(key: String, macApps: [String], linuxBins: [String], linuxArgs: [String] = []) {
            self.key = key
            self.macApps = macApps
            self.linuxBins = linuxBins
            self.linuxArgs = linuxArgs
        }
    }

    /// The `chrome` key resolves to **Chrome for Testing** on both guests — a pinned, non-auto-updating
    /// Chrome build, so an agent's run isn't perturbed mid-session by a background update. The macOS
    /// base installs the real thing (`Google Chrome for Testing.app`, see `provision-macos-guest.sh`);
    /// the arm64-only Linux base bakes Debian's `chromium` instead, because Google publishes CfT for
    /// linux64 (x86_64) only. Stock `Google Chrome` / `google-chrome` stay in the lists as fallbacks for
    /// guests that happen to carry one (e.g. an operator-staged app on an older base).
    static let knownBrowsers: [KnownBrowser] = [
        KnownBrowser(key: "safari", macApps: ["Safari"], linuxBins: []),
        KnownBrowser(
            key: "chrome", macApps: ["Google Chrome for Testing", "Google Chrome"],
            linuxBins: ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable"],
            // Chromium builds its AT-SPI tree lazily and reads as empty until an AT attaches, so force
            // it on at launch — otherwise `ax_dump` sees a bare window with no contents.
            linuxArgs: ["--force-renderer-accessibility"]),
        KnownBrowser(key: "firefox", macApps: ["Firefox"], linuxBins: ["firefox", "firefox-esr"]),
        KnownBrowser(
            key: "edge", macApps: ["Microsoft Edge"],
            linuxBins: ["microsoft-edge", "microsoft-edge-stable"]),
    ]

    /// Normalize an agent-supplied starting URL into something a browser will actually load. Returns
    /// nil for empty/whitespace input (open the browser at its own start page). A string that already
    /// carries a scheme is passed through as written; a scheme-less one gets `http://` when it points at
    /// the loopback host (a dev server on the guest, which almost never speaks TLS) and `https://`
    /// otherwise, so the agent can pass `localhost:3000` or `example.com` and get what it meant.
    static func normalizedBrowserURL(_ raw: String?) -> String? {
        let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return nil }
        // Any explicit scheme — https:, file:, about:blank, data:, chrome://… — is the agent's call.
        if trimmed.contains("://") || trimmed.hasPrefix("about:") || trimmed.hasPrefix("data:") {
            return trimmed
        }
        let authority = trimmed.split(separator: "/", maxSplits: 1).first.map(String.init) ?? trimmed
        let host = (authority.split(separator: ":").first.map(String.init) ?? authority).lowercased()
        let loopback = ["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]
        return (loopback.contains(host) ? "http://" : "https://") + trimmed
    }

    /// Launch a well-known browser in the guest's GUI session — optionally straight to a starting `url`
    /// — then return a screenshot once its window has had a moment to appear. `requested` is a short
    /// name (chrome/firefox/safari/edge); empty falls back to the guest's stock browser (Safari on
    /// macOS, Firefox on the Linux base). `url` is normalized by ``normalizedBrowserURL(_:)`` and handed
    /// to the browser as its first document, so the agent lands on the page instead of having to drive
    /// the address bar; omitting it opens the browser's own start page. An unrecognized name — or one
    /// not available on this guest — comes back as actionable text so the agent can retry with a
    /// supported option.
    func performOpenBrowser(
        name: String, requested: String?, url: String? = nil, durationMs: Int?
    ) async -> (imageBase64: String?, summary: String) {
        let isLinux = live[name]?.os == .linux
        let raw = (requested ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
        let key = raw.isEmpty ? (isLinux ? "firefox" : "safari") : raw
        guard let browser = Self.knownBrowsers.first(where: { $0.key == key }) else {
            let options = Self.knownBrowsers.map(\.key).joined(separator: ", ")
            return (nil, "Unknown browser '\(key)'. Supported: \(options).")
        }
        let startURL = Self.normalizedBrowserURL(url)
        let command: String
        if isLinux {
            guard !browser.linuxBins.isEmpty else {
                return (
                    nil,
                    "\(browser.key) isn't available on the Linux VM (it ships Firefox and Chromium). "
                        + "Use open_browser with \"firefox\" or \"chrome\", or install another browser "
                        + "first."
                )
            }
            command = Self.linuxLaunchBinaryCommand(
                candidates: browser.linuxBins, url: startURL, args: browser.linuxArgs)
        } else {
            command = Self.launchAppCommand(browser.macApps, url: startURL)
        }
        _ = try? await run(name: name, command: command, workdir: nil)
        // A browser cold-starts slower than the screen-affecting HID actions, so wait before capturing
        // (default 2.5s, bounded like `wait`) to give its window time to paint.
        let ms = max(0, min(durationMs ?? 2500, 30_000))
        try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)
        // Reuse the normal screenshot path so we capture with whatever this VM supports (surface,
        // native agent, or the vsock-exec fallback).
        let shot = try? await performComputerAction(
            name: name, action: "screenshot", x: nil, y: nil, text: nil,
            scrollDirection: nil, scrollAmount: nil, durationMs: nil)
        return (
            shot?.imageBase64,
            "Opened \(browser.key)\(startURL.map { " at \($0)" } ?? ""); here's the screen now. (It may "
                + "still be loading — `wait` then `screenshot` if the window isn't up yet.)"
        )
    }

    /// Launch the first available GUI binary from `candidates` in the Linux VM's desktop session,
    /// optionally passing `url` as its argument (every browser here takes a starting URL that way). The
    /// exec agent runs as root, but the GNOME/Mutter Wayland desktop belongs to the autologin
    /// `nucleic` user, so drop to that account with its session runtime dir + Wayland/X/D-Bus env and
    /// detach (`setsid`, backgrounded) so the launcher returns immediately. `args` are extra flags the
    /// binary always gets (e.g. Chromium's `--force-renderer-accessibility`), passed before `url` since
    /// the URL is positional. Emits `no browser found` on stderr and a non-zero exit when nothing
    /// matches.
    static func linuxLaunchBinaryCommand(
        candidates: [String], url: String? = nil, args: [String] = []
    ) -> String {
        let user = shQuote(linuxDesktopUser)
        let picks = candidates.map { shQuote($0) }.joined(separator: " ")
        let flags = args.map { " \(shQuote($0))" }.joined()
        let arg = flags + ((url.map { $0.isEmpty ? "" : " \(shQuote($0))" }) ?? "")
        return "for b in \(picks); do "
            + "if command -v \"$b\" >/dev/null 2>&1; then "
            + "uid=$(id -u \(user)); "
            + "setsid runuser -u \(user) -- env "
            + "XDG_RUNTIME_DIR=/run/user/$uid WAYLAND_DISPLAY=wayland-0 DISPLAY=:0 "
            + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus "
            + "\"$b\"\(arg) >/dev/null 2>&1 & exit 0; fi; done; "
            + "echo 'no browser found' >&2; exit 1"
    }

    static func cursorPositionCommand() -> String {
        asuser("\(cliclickPath) p")
    }
}
