import Foundation

/// 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"

    /// 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,
        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). 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.
        if action == "open_browser" {
            return await performOpenBrowser(name: name, requested: text, 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) {
        switch action {
        case "screenshot":
            break

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

        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
            await sendSurfaceInput(name: name, .click(x: px, y: py, button: button, count: count))

        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 surfaceCursor(name: name) ?? Point(x: tx, y: ty)
            await sendSurfaceInput(name: name, .drag(fromX: start.x, fromY: start.y, toX: tx, toY: ty))

        case "type":
            guard let text, !text.isEmpty else { return (nil, "type requires non-empty `text`.") }
            await sendSurfaceInput(name: name, .text(text))

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

        case "scroll":
            let (dx, dy) = Self.scrollDeltas(
                direction: scrollDirection ?? "down", amount: max(1, min(scrollAmount ?? 3, 30)))
            let pos = await surfaceCursor(name: name) ?? Point(x: 960, y: 600)
            await sendSurfaceInput(name: name, .scroll(x: pos.x, y: pos.y, dx: dx, dy: dy))

        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 surfaceCursor(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)'.")
        }

        if let shot = await captureSurfaceBase64(name: name) {
            return (shot, screenSummary(for: action))
        }
        return (nil, "The action ran, but the host-side framebuffer capture came back empty.")
    }

    /// Map a scroll direction + line amount to `(dx, dy)` wheel deltas (positive dy = up, positive
    /// dx = left), for the surface's `scrollWheel` synthesis.
    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
        }
    }

    /// 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)'.")
        }

        // 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))
        }
        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).")
    }

    func screenSummary(for action: String) -> String {
        action == "screenshot" ? "Screenshot of the macOS VM screen." : "Did '\(action)'; here's the screen now."
    }

    /// 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)")
    }

    static func launchAppCommand(_ app: String) -> String {
        asuser("\(openPath) -a \(shQuote(app))")
    }

    // 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. `macApp` is the macOS `.app` name;
    /// `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).
    struct KnownBrowser: Sendable {
        let key: String
        let macApp: String
        let linuxBins: [String]
    }

    static let knownBrowsers: [KnownBrowser] = [
        KnownBrowser(key: "safari", macApp: "Safari", linuxBins: []),
        KnownBrowser(
            key: "chrome", macApp: "Google Chrome",
            linuxBins: ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]),
        KnownBrowser(key: "firefox", macApp: "Firefox", linuxBins: ["firefox", "firefox-esr"]),
        KnownBrowser(
            key: "edge", macApp: "Microsoft Edge",
            linuxBins: ["microsoft-edge", "microsoft-edge-stable"]),
    ]

    /// Launch a well-known browser in the guest's GUI session, 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). 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?, 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 command: String
        if isLinux {
            guard !browser.linuxBins.isEmpty else {
                return (
                    nil,
                    "\(browser.key) isn't available on the Linux VM (only Firefox ships in the base). "
                        + "Use open_browser with \"firefox\", or install another browser first."
                )
            }
            command = Self.linuxLaunchBinaryCommand(candidates: browser.linuxBins)
        } else {
            command = Self.launchAppCommand(browser.macApp)
        }
        _ = 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); 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. 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. Emits `no browser found`
    /// on stderr and a non-zero exit when nothing matches.
    static func linuxLaunchBinaryCommand(candidates: [String]) -> String {
        let user = shQuote(linuxDesktopUser)
        let picks = candidates.map { shQuote($0) }.joined(separator: " ")
        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\" >/dev/null 2>&1 & exit 0; fi; done; "
            + "echo 'no browser found' >&2; exit 1"
    }

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