import Foundation
import NucleicProtocol

/// One running guest VM, for the VM settings panel and the Control panel's Virtual Machines section.
/// `os` lets the Control panel show whether the guest is a macOS or Linux VM as its subtext; it
/// defaults so the settings panel's existing call sites are unaffected.
public struct MacVMEntry: Sendable, Equatable {
    public let name: String
    public let ipAddress: String?
    /// Which guest OS this VM boots — surfaced as the Control panel VM row's "macOS"/"Linux" subtext.
    public let os: GuestOS
    /// True while the guest is suspended — either RAM-paused or suspended-to-disk. The live monitor lays
    /// a "Suspended" overlay over the frozen still; defaults so the settings panel's existing call sites
    /// are unaffected.
    public let paused: Bool
    /// True for the brief window while a suspended guest is thawing back — the resume call is in flight
    /// but the guest isn't live yet. Lets the Control panel show "Resuming…" instead of a stale reading;
    /// defaults so existing call sites are unaffected.
    public let resuming: Bool
    /// True when this VM is **suspended to disk** (its runtime state saved, its host RAM and concurrency
    /// slot released) rather than merely RAM-paused. Both report `paused == true`; this distinguishes the
    /// two for a UI that wants to say "Suspended (disk)" vs. "Paused". Defaults so existing call sites
    /// are unaffected.
    public let suspendedToDisk: Bool
    /// True while the guest is still coming up — booted but not yet usable (the in-guest agent hasn't
    /// answered over vsock / host paths aren't mounted). This is the Control panel's "Booting…" signal;
    /// it is deliberately **independent of `ipAddress`**, since the vsock control plane leaves a
    /// fully-ready guest with a nil NAT IP. Defaults so existing call sites are unaffected.
    public let booting: Bool
    public init(
        name: String, ipAddress: String?, os: GuestOS = .macOS,
        paused: Bool = false, resuming: Bool = false, suspendedToDisk: Bool = false,
        booting: Bool = false
    ) {
        self.name = name
        self.ipAddress = ipAddress
        self.os = os
        self.paused = paused
        self.resuming = resuming
        self.suspendedToDisk = suspendedToDisk
        self.booting = booting
    }
}

/// A **system-managed** base-image VM the app has booted for maintenance right now — currently the
/// brief boot that injects user apps into the golden base without a full rebuild
/// (``MacVMEngine/installAppsIntoBase(appPaths:)``). Surfaced to the Control panel's Virtual Machines
/// section so a running base is visible alongside the per-session guests, with a short `activity`
/// note explaining what it's doing. `nil` in the steady state (no base maintenance in flight).
public struct MacVMMaintenanceInfo: Sendable, Equatable {
    /// The opaque name the row keys on (its tooltip); not a per-session clone name.
    public let name: String
    /// Which base this is (macOS vs. Linux), for the row's OS subtext.
    public let os: GuestOS
    /// Short human note for the row's subtext, e.g. "Starting…" then "Installing apps…".
    public let activity: String
    /// The guest is still coming up (drives the amber "booting" dot before it's reachable).
    public let booting: Bool

    public init(name: String, os: GuestOS, activity: String, booting: Bool) {
        self.name = name
        self.os = os
        self.activity = activity
        self.booting = booting
    }
}

/// App-level coordinator for per-session **macOS guest VMs** (injected like ``ContainerManager``).
/// Owns the lifecycle policy the low-level ``MacVMEngine`` doesn't track: one long-lived VM per
/// session, an idle timer that stops it after inactivity, teardown on session end, on-disk
/// reconciliation at launch, and — unique to macOS guests — a **concurrent-VM ceiling** (macOS caps
/// how many macOS guests run at once), so a fan-out of agents queues instead of failing a boot.
///
/// Activity is reference-counted exactly like the container manager: `ensureRunning` (start of every
/// turn that uses the VM) marks it busy and disarms the idle timer; `finished` marks the turn done and
/// re-arms it. The VM is only stopped when no turn is in flight.
public actor MacVMManager {
    private let engine: MacVMEngine

    private var active: [String: Int] = [:]              // in-flight turn count, by VM name
    private var idleTimeouts: [String: TimeInterval] = [:]
    private var idleTimers: [String: Task<Void, Never>] = [:]
    /// VMs whose owning chat reached Done while the manager still had an in-flight operation. The
    /// last matching `finished` pauses them after arming the existing idle timer. `ensureRunning`
    /// clears the request when a follow-up starts, so live work is never frozen underneath itself.
    private var suspendWhenIdle: Set<String> = []
    /// Deferred **full reclaim** of a Done chat's guests, by VM name: the grace timer armed by
    /// ``scheduleDoneReap(_:stillDone:)`` that stops the guest and deletes its on-disk clone once the chat
    /// has sat in Done long enough. Distinct from ``idleTimers`` in both effect and trigger — that one is
    /// keyed on *guest inactivity* and only cuts power (and deliberately spares a disk-suspended guest so
    /// resume keeps working), whereas this one is keyed on *chat state* and reclaims the disk too. Both can
    /// be armed for the same VM; whichever fires first makes the other's work a no-op.
    private var doneReapTimers: [String: Task<Void, Never>] = [:]
    /// VM names pinned alive because their session is in a **background wait** — it armed a
    /// `nucleic_monitor` and ended the turn to be re-invoked when the watch fires. While pinned, the
    /// idle timer is suppressed (see ``armIdleTimer(_:)``) so the guest isn't auto-killed out from
    /// under a session that's only *waiting*, not done. Set/cleared by ``holdForBackgroundWait(_:)`` /
    /// ``releaseBackgroundWait(_:)``; also cleared by teardown and the agent-driven stop/suspend/restart
    /// ops (the agent took explicit control). Does NOT boot a guest — it only protects a live one.
    private var pinned: Set<String> = []

    // Fine-grained activity, by VM name, that lets the live monitor tell *headless* work from
    // *screen-driving* work — the two `mac_vm_exec`/`mac_vm_computer` (and Linux siblings) paths.
    // `mac_vm_exec` runs a command over SSH: the guest is busy but its screen doesn't change, so an
    // onlooker sees a frozen desktop that reads as a hang. `mac_vm_computer` moves the cursor/types, so
    // the screen visibly changes and needs no such hint. `isWorkingInBackground(name:)` combines these
    // to drive the monitor's "Working in the background…" overlay.
    private var execInFlight: [String: Int] = [:]        // concurrent `run` (exec) calls, by VM name
    private var computerInFlight: [String: Int] = [:]    // concurrent computer-use actions, by VM name
    private var lastExecActiveAt: [String: Date] = [:]   // when a `run` last started/ended, by VM name
    private var lastComputerActiveAt: [String: Date] = [:] // when a computer-use action last ran, by VM name

    /// How long the "working in the background" signal lingers past the last `mac_vm_exec` command, so
    /// the brief gap between back-to-back commands (while the agent thinks) doesn't flicker the overlay
    /// off and on. Comfortably longer than the monitor's ~1.2 s poll so a single missed poll can't drop it.
    private static let backgroundExecLinger: TimeInterval = 2.5

    /// How long a VM keeps reading as *in use by computer-use* past its last `mac_vm_computer` action, so
    /// the monitor holds the live screen through the think-gap between an agent's back-to-back actions
    /// instead of flashing the "Waiting…" overlay. An agent that just drove the screen almost always
    /// drives it again in short order, so we treat a lull as continued interactive use and only fall back
    /// to "Waiting…" once computer use has been quiet this long (the agent has moved on to headless or
    /// background work). Far longer than the exec linger: inter-action gaps span a full model round-trip
    /// (screenshot → think → next action), which can run to tens of seconds.
    private static let computerUseLinger: TimeInterval = 45

    public init(engine: MacVMEngine = MacVMEngine()) {
        self.engine = engine
    }

    /// Whether the macOS-guest runtime is usable on this machine (Apple silicon). A `nil`
    /// ``MacVMEngine/unsupportedReason``.
    public nonisolated static var isSupported: Bool { MacVMEngine.isSupported }

    /// Stable macOS-VM name for a session — the `nucleic-mac-<short>` sibling of a container's
    /// `nucleic-<short>`. Lowercased (matches the container/disk-GC convention) and carrying the same
    /// per-channel suffix as containers so several installed Nucleic builds don't collide on the shared
    /// on-disk clone store.
    public nonisolated static func vmName(for session: SessionID) -> String {
        "nucleic-mac-\(session.short.lowercased())\(ContainerManager.channelSuffix)"
    }

    /// Stable **Linux**-VM name for a session — the `nucleic-lin-<short>` sibling of ``vmName(for:)``.
    /// A distinct prefix so a session's Linux and macOS clones never collide on the shared clone store.
    public nonisolated static func linuxVMName(for session: SessionID) -> String {
        "nucleic-lin-\(session.short.lowercased())\(ContainerManager.channelSuffix)"
    }

    /// Ensure the session's macOS VM is up and return its name plus SSH-reachable IP. Marks a turn as
    /// in-flight; pair with ``finished(name:)``. The concurrent-guest ceiling is enforced *inside* the
    /// engine (atomically, on the actor that owns the live registry), which throws
    /// ``MacVMError/concurrencyLimit(_:)`` when a new boot would exceed ``MacVMSettings/maxConcurrentVMs``
    /// — so a fan-out of agents surfaces "the Macs are busy, retry" rather than the OS rejecting a start
    /// opaquely. (Enforcing it here would be a TOCTOU: the check and the boot aren't atomic across actors.)
    public func ensureRunning(_ spec: MacVMSpec) async throws -> (name: String, ipAddress: String) {
        cancelIdleTimer(spec.name)
        cancelDoneReap(spec.name)  // real work arrived: the chat is no longer Done
        suspendWhenIdle.remove(spec.name)
        active[spec.name, default: 0] += 1
        idleTimeouts[spec.name] = spec.idleTimeout
        do {
            return try await engine.ensureRunning(spec)
        } catch {
            await finishBookkeeping(spec.name)  // roll back so a failed boot doesn't pin busy
            throw error
        }
    }

    /// Run `argv` inside the named VM over SSH, returning a process handle (forwards to the engine; no
    /// lifecycle effect). Satisfies the same ``ProcessHandle`` contract a host/container exec does.
    public func exec(
        name: String, workdir: String?, env: [String: String], argv: [String]
    ) async throws -> any ProcessHandle {
        try await engine.exec(name: name, workdir: workdir, env: env, argv: argv)
    }

    /// One-shot: run a shell `command` in the named VM and capture exit code + stdout/stderr (the
    /// `mac_vm_exec` tool path). No lifecycle effect. `timeoutNanos` bounds the wait so a command that
    /// never exits can't hang the tool call forever (see ``MacVMEngine/run``); `nil` waits unbounded.
    ///
    /// `asBackgroundWork` controls how the run reads on the live monitor. `true` (the tool path): the
    /// command is headless work — it drives the "Working in the background…" overlay and echoes into
    /// the background-output console. `false` (e.g. `mac_vm_clear_notifications`'s in-guest `killall`):
    /// the command is part of the *screen-driving* flow, so it must not flip the monitor into
    /// background mode mid-computer-use — it skips the exec bookkeeping and the console tap, and
    /// stamps computer-use activity instead (its effect is on the screen the user is watching).
    public func run(
        name: String, command: String, workdir: String?, env: [String: String] = [:],
        timeoutNanos: UInt64? = nil, asBackgroundWork: Bool = true
    ) async throws -> (exitCode: Int32, stdout: String, stderr: String) {
        guard asBackgroundWork else {
            lastComputerActiveAt[name] = Date()
            defer { lastComputerActiveAt[name] = Date() }
            return try await engine.run(
                name: name, command: command, workdir: workdir, env: env, tap: false,
                timeoutNanos: timeoutNanos)
        }
        // Track this as headless (screen-idle) work for the monitor's background overlay: mark it busy
        // for the whole command and stamp the end time so the overlay lingers briefly after it returns.
        // On the idle→busy edge (no exec was in flight), reset the guest's background-output ring so this
        // episode's console starts clean instead of trailing whatever the last one left behind — the ring
        // is otherwise only cleared when the VM stops, so stale output would resurface every time the
        // monitor re-reads it (e.g. clicking back into the session).
        let beginsEpisode = (execInFlight[name] ?? 0) == 0
        execInFlight[name, default: 0] += 1
        lastExecActiveAt[name] = Date()
        defer {
            execInFlight[name] = max(0, (execInFlight[name] ?? 0) - 1)
            lastExecActiveAt[name] = Date()
        }
        if beginsEpisode { await engine.resetBackgroundLog(name: name) }
        return try await engine.run(
            name: name, command: command, workdir: workdir, env: env, tap: true,
            timeoutNanos: timeoutNanos)
    }

    /// Drain nash's spooled shell-event batches from a Linux guest (docs/NASH.md §6.2, §7.4; forwards to
    /// ``MacVMEngine/drainShellEvents(name:)``). The `linux_vm_exec` handler polls this after each run to
    /// pull nash's observations out of the VM and into the feed. Best-effort, no lifecycle effect;
    /// returns `[]` when there's nothing to drain.
    public func drainShellEvents(name: String) async -> [JSONValue] {
        await engine.drainShellEvents(name: name)
    }

    /// Perform one computer-use action (screenshot / click / type / ax_dump / ax_press / …) in the
    /// named VM and return the post-action screenshot (base64 JPEG) + a summary (the
    /// `mac_vm_computer` tool path). `ref`/`value` feed the AX-semantic actions (native agent); `url` is
    /// the optional starting page for `open_browser`. No lifecycle effect.
    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) {
        // A computer-use action drives the screen directly, so suppress the background overlay while one
        // is in flight — the user is watching the guest change, not waiting on invisible work. Stamp the
        // action's start and end so ``isWaiting(name:)`` keeps the monitor's live screen up across the
        // think-gap before the agent's next action instead of flashing "Waiting…".
        computerInFlight[name, default: 0] += 1
        lastComputerActiveAt[name] = Date()
        defer {
            computerInFlight[name] = max(0, (computerInFlight[name] ?? 0) - 1)
            lastComputerActiveAt[name] = Date()
        }
        return try await engine.performComputerAction(
            name: name, action: action, x: x, y: y, text: text, ref: ref, value: value, url: url,
            scrollDirection: scrollDirection, scrollAmount: scrollAmount, durationMs: durationMs)
    }

    /// Copy `.app` bundles into a running guest's `/Applications` over vsock (forwards to the engine;
    /// no lifecycle effect) — pushing Settings' "Included apps" into a live clone without a base
    /// rebuild. Returns the bundle names that landed.
    @discardableResult
    public func installApps(name: String, appPaths: [String]) async throws -> [String] {
        try await engine.installApps(name: name, appPaths: appPaths)
    }

    /// Inject `.app` bundles into the golden base image without a full rebuild: the engine briefly boots
    /// the base, copies the apps into `/Applications`, and powers it off (forwards to the engine).
    /// Returns the bundle names that landed; new session clones include them afterward.
    @discardableResult
    public func installAppsIntoBase(appPaths: [String]) async throws -> [String] {
        try await engine.installAppsIntoBase(appPaths: appPaths)
    }

    /// Install "Common packages" (``MacVMPackage``) into a running guest over vsock (forwards to the
    /// engine; no lifecycle effect) — pushing Settings' selection into a live clone without a rebuild.
    /// Returns the package ids that landed.
    @discardableResult
    public func installPackages(name: String, packageIDs: [String]) async throws -> [String] {
        try await engine.installPackages(name: name, packageIDs: packageIDs)
    }

    /// Install "Common packages" into the golden base image without a full rebuild: the engine briefly
    /// boots the base, runs the installer, and powers it off (forwards to the engine). Returns the ids
    /// that landed; new session clones include them afterward.
    @discardableResult
    public func installPackagesIntoBase(packageIDs: [String]) async throws -> [String] {
        try await engine.installPackagesIntoBase(packageIDs: packageIDs)
    }

    /// Mark a turn finished. When the last in-flight turn ends, arm the idle timer.
    public func finished(name: String) async { await finishBookkeeping(name) }

    /// Keep the session's guest(s) alive across a **background wait** — it armed a `nucleic_monitor`
    /// and ended the turn to be re-invoked when the watch fires. Pins both the macOS and Linux guest
    /// names so the idle/auto-kill timer is suppressed (and cancels any already-armed one) until
    /// ``releaseBackgroundWait(_:)``. Idempotent. Does NOT boot a guest that isn't running — a session
    /// that never used a VM just pins a name no timer will ever target. Pair with the release.
    public func holdForBackgroundWait(_ session: SessionID) {
        for name in [Self.vmName(for: session), Self.linuxVMName(for: session)] {
            pinned.insert(name)
            cancelIdleTimer(name)
            // A chat that armed a monitor is *waiting*, not done — its guest must survive the wait, so drop
            // any Done reap a `.waitingBackground` turn end may have raced in.
            cancelDoneReap(name)
        }
    }

    /// Drop a background-wait pin (the monitor ended, or the session did other work). Re-arms the
    /// normal idle timer for any guest that's now idle so auto-kill resumes. A no-op for a session
    /// that wasn't pinned.
    public func releaseBackgroundWait(_ session: SessionID) {
        for name in [Self.vmName(for: session), Self.linuxVMName(for: session)] {
            guard pinned.remove(name) != nil else { continue }
            if (active[name] ?? 0) == 0 { armIdleTimer(name) }
        }
    }

    private func finishBookkeeping(_ name: String) async {
        let remaining = max(0, (active[name] ?? 0) - 1)
        active[name] = remaining
        if remaining == 0 {
            armIdleTimer(name)
            if suspendWhenIdle.remove(name) != nil {
                await suspendUnlessReactivated(name)
            }
        }
    }

    /// Suspend any running guest(s) owned by a chat that just reached Done, while preserving the idle
    /// timer already armed by `finished`. Suspend saves to disk and **frees the concurrency slot**,
    /// so a Done chat's VM stops blocking other agents the moment it goes idle (the key win here). A
    /// follow-up transparently resumes the guest through `ensureRunning` — however much later it comes:
    /// the saved state has no TTL (`stopIfIdle` deliberately skips a disk-suspended guest, and only
    /// stops one left RAM-paused by the fallback path). If completion races the final VM operation's
    /// bookkeeping, defer the suspend until that operation calls `finished` instead of freezing work
    /// that is technically still active.
    public func suspendIfIdle(_ session: SessionID) async {
        for name in [Self.vmName(for: session), Self.linuxVMName(for: session)] {
            if (active[name] ?? 0) > 0 {
                suspendWhenIdle.insert(name)
                continue
            }
            // Don't create timers for the session's unused guest type (most chats boot only one).
            guard await engine.isRunning(name) else { continue }
            // The engine hop above is a suspension point; a follow-up may have started meanwhile.
            // Defer to its eventual `finished` rather than pausing an operation in flight.
            guard (active[name] ?? 0) == 0 else {
                suspendWhenIdle.insert(name)
                continue
            }
            // Keep an existing deadline exactly as-is. A manually-disarmed VM still gets the current
            // configured TTL starting now, so every untouched Done chat eventually powers down.
            if idleTimers[name] == nil { armIdleTimer(name) }
            await suspendUnlessReactivated(name)
        }
    }

    /// Suspending crosses into the engine actor and then into Virtualization, so this manager actor can
    /// accept a new `ensureRunning` while the suspend is in flight. If that happened, resume the guest
    /// again before returning. If the follow-up starts just after this check, the engine already
    /// records the VM as suspended and its own `ensureRunning` path performs the transparent resume
    /// (restoring from disk when it was suspended to disk).
    private func suspendUnlessReactivated(_ name: String) async {
        let suspended = await engine.suspend(name: name)
        if suspended, (active[name] ?? 0) > 0 {
            _ = await engine.resume(name: name)
        }
    }

    /// Arm the **Done reap**: once the chat has been Done for ``MacVMSettings/doneReapGraceSeconds`` (5
    /// minutes), stop its guests and delete their on-disk clones — the full reclaim that
    /// ``suspendIfIdle(_:)``'s freeze deliberately stops short of.
    ///
    /// Suspend alone frees RAM and the concurrency slot but leaves tens of GB of clone on disk *forever*:
    /// nothing else ever collects it, because launch reconcile keeps every live session's clone and the
    /// idle timer skips a disk-suspended guest by design. So a Mac that ran a week of VM chats accumulated
    /// every one of their disks. The grace period is what makes the delete safe to automate — the "one more
    /// thing" follow-up that lands seconds after Done still thaws the original guest; only a chat the user
    /// has genuinely walked away from is reclaimed, and its next turn transparently cold-boots a fresh
    /// clone from the base.
    ///
    /// `stillDone` is re-evaluated when the timer fires, not just when it's armed: a follow-up turn that
    /// never touches a VM (so nothing calls ``ensureRunning(_:)`` to cancel this) must not have its guest
    /// deleted out from under it. Both the macOS and Linux names are armed; each is independently skipped
    /// at fire time if it turned busy or pinned. Idempotent — re-arming restarts the grace period.
    public func scheduleDoneReap(
        _ session: SessionID, stillDone: @escaping @Sendable () async -> Bool
    ) {
        let grace = TimeInterval(MacVMSettings.doneReapGraceSeconds)
        for name in [Self.vmName(for: session), Self.linuxVMName(for: session)] {
            cancelDoneReap(name)
            doneReapTimers[name] = Task { [weak self] in
                try? await Task.sleep(nanoseconds: UInt64(grace * 1_000_000_000))
                guard !Task.isCancelled else { return }
                await self?.reapIfStillDone(name, stillDone: stillDone)
            }
        }
    }

    /// Drop a pending Done reap. Deliberately per-*name* and internal: the eager cancels that matter are
    /// the ones this actor already sees (``ensureRunning(_:)``, a background-wait pin, teardown/remove).
    /// A chat that leaves Done **without** touching a VM has no cancel edge here at all — it's caught by
    /// `stillDone` at fire time instead, which is why that re-check, not this, is the load-bearing guard.
    private func cancelDoneReap(_ name: String) {
        doneReapTimers[name]?.cancel()
        doneReapTimers[name] = nil
    }

    private func reapIfStillDone(_ name: String, stillDone: @Sendable () async -> Bool) async {
        doneReapTimers[name] = nil
        // Busy or pinned: a turn or a background wait claimed the guest after the timer was armed. Leave it
        // alone — the next Done transition re-arms a fresh grace period.
        guard (active[name] ?? 0) == 0, !pinned.contains(name) else { return }
        // Nothing to reclaim for the guest type this chat never booted (most boot only one).
        guard await engine.vmExists(name) else { return }
        guard await stillDone() else { return }
        // Re-check across the two awaits above: either could have let a follow-up turn in.
        guard (active[name] ?? 0) == 0, !pinned.contains(name) else { return }
        _ = await remove(name: name)
    }

    /// Stop and remove the session's VM (and its on-disk clone). Returns the name if it could NOT be
    /// removed, else `nil` (including when no such VM existed).
    @discardableResult
    public func teardown(_ session: SessionID, waitForActive: Bool = false) async -> String? {
        // A session may have booted a macOS VM, a Linux VM, or neither — tear down both clone names.
        var failed: String?
        for name in [Self.vmName(for: session), Self.linuxVMName(for: session)] {
            if waitForActive { await waitUntilIdle(name) }
            cancelIdleTimer(name)
            cancelDoneReap(name)
            suspendWhenIdle.remove(name)
            pinned.remove(name)
            active[name] = nil
            idleTimeouts[name] = nil
            let removed = await engine.remove(name: name)
            if !removed, await engine.vmExists(name) { failed = name }
        }
        return failed
    }

    /// Restart the session's VM in place (reclaims host RAM). Skips if a turn is in flight.
    public func restart(_ session: SessionID) async {
        let name = Self.vmName(for: session)
        guard (active[name] ?? 0) == 0 else { return }
        cancelIdleTimer(name)
        suspendWhenIdle.remove(name)
        pinned.remove(name)
        await engine.restart(name: name)
    }

    // MARK: - Agent-driven lifecycle (the `mac_vm_control` / `linux_vm_control` tools)
    //
    // These let an agent explicitly manage the VM it booted (stop it to free RAM, suspend/resume it,
    // restart it, or kill it), by VM name rather than by session. They're the manual counterpart to the
    // automatic idle-timer policy: each disarms the idle timer (the agent is now driving the lifecycle)
    // and, for stop/remove, clears the busy bookkeeping so the explicit action isn't second-guessed.

    /// Stop the named VM now (frees its host RAM), keeping the on-disk clone so the next exec reboots
    /// it. Disarms the idle timer and clears the busy count — the agent explicitly took it down. Power
    /// is cut immediately (no graceful halt): the guest's runtime state isn't retained, so there's
    /// nothing worth a grace period.
    public func stop(name: String) async {
        cancelIdleTimer(name)
        suspendWhenIdle.remove(name)
        pinned.remove(name)
        active[name] = nil
        await engine.stop(name: name)
    }

    /// Suspend the named VM, **freeing its concurrency slot** so another VM can boot in its place: the
    /// engine saves the guest's runtime state to disk and releases its RAM + slot (degrading to a plain
    /// RAM-pause only when the guest's config can't be saved). ``resume(name:)`` — or the next
    /// exec/computer-use call (reuse transparently resumes) — reconstructs it from the saved state.
    /// Disarms the idle timer so a suspended VM isn't stopped out from under the agent. Returns whether
    /// it suspended (`false` when it wasn't running).
    @discardableResult
    public func suspend(name: String) async -> Bool {
        cancelIdleTimer(name)
        suspendWhenIdle.remove(name)
        pinned.remove(name)
        return await engine.suspend(name: name)
    }

    /// Resume a suspended VM. A disk-suspended VM is reconstructed from its saved state (re-acquiring a
    /// concurrency slot, so it may queue/evict like a boot); a RAM-paused VM thaws instantly. Returns
    /// whether it resumed (`false` when it wasn't suspended).
    @discardableResult
    public func resume(name: String) async -> Bool {
        await engine.resume(name: name)
    }

    /// Take (and clear) the notice that this VM's suspended state could not be restored, so its last
    /// boot was a cold start. `nil` when nothing was lost. Forwards to the engine; see
    /// ``MacVMEngine/takeRestoreFailure(name:)`` for why this is consume-once.
    public func takeRestoreFailure(name: String) async -> String? {
        await engine.takeRestoreFailure(name: name)
    }

    /// Restart the named VM in place (stop → reboot; reclaims held RAM). Disarms the idle timer. No-op
    /// if the engine has no spec to re-boot it from (e.g. it was never booted this process).
    public func restart(name: String) async {
        cancelIdleTimer(name)
        suspendWhenIdle.remove(name)
        pinned.remove(name)
        await engine.restart(name: name)
    }

    /// Stop and delete the named VM's on-disk clone. Disarms the idle timer and clears its bookkeeping.
    /// Returns `true` once the clone is gone (including when none existed).
    @discardableResult
    public func remove(name: String) async -> Bool {
        cancelIdleTimer(name)
        cancelDoneReap(name)  // the clone is going away; a pending reap has nothing left to reclaim
        suspendWhenIdle.remove(name)
        pinned.remove(name)
        active[name] = nil
        idleTimeouts[name] = nil
        return await engine.remove(name: name)
    }

    /// Whether the named VM has a live or on-disk presence (running, suspended, or stopped-but-cloned).
    public func exists(name: String) async -> Bool {
        await engine.vmExists(name)
    }

    /// Whether the named VM is currently suspended (paused). `false` when it isn't running.
    public func isPaused(name: String) async -> Bool {
        await engine.isPaused(name)
    }

    /// Whether the named VM is already booted and ready (forwards to the engine; no lifecycle effect).
    /// Lets a caller tell a cold boot — which can take a couple of minutes for a fresh clone — from a
    /// quick reuse before calling ``ensureRunning(_:)``, e.g. to narrate the wait into the transcript.
    public func isRunning(name: String) async -> Bool {
        await engine.isRunning(name)
    }

    /// Whether an agent operation currently holds the named VM. `ensureRunning` reference-counts the
    /// full lifetime of every VM tool call, so this includes headless commands, computer-use actions,
    /// and lifecycle work without coupling the monitor to an owning chat's potentially stale snapshot.
    /// No lifecycle effect.
    public func isInUse(name: String) -> Bool {
        (active[name] ?? 0) > 0
    }

    /// Whether the named VM should read as **Waiting…** on the live monitor: powered on but idle — no
    /// agent operation currently holds it (`!isInUse`) *and* no `mac_vm_computer` action ran within the
    /// last ``computerUseLinger``. That second clause is the gate: an agent stepping through the guest
    /// releases the VM between each action while it thinks, so a bare `!isInUse` check flashes the overlay
    /// on every sub-second lull. Treating a recent action as continued interactive use holds the live
    /// screen across those gaps, so "Waiting…" surfaces only once the agent has genuinely stopped driving
    /// the screen (e.g. it's now working headless in the background, or done with the VM). No lifecycle
    /// effect.
    public func isWaiting(name: String) -> Bool {
        if isInUse(name: name) { return false }
        if let last = lastComputerActiveAt[name],
            Date().timeIntervalSince(last) < Self.computerUseLinger { return false }
        return true
    }

    /// Whether the named VM is doing headless `mac_vm_exec` work right now (a command over SSH) with no
    /// computer-use action in flight — i.e. the guest is busy but its screen isn't changing. The live
    /// monitor uses this to lay a translucent "Working in the background…" overlay over the frozen-looking
    /// frame so an idle screen doesn't read as a hang. A computer-use action in flight suppresses it (the
    /// screen is visibly changing), and the signal lingers briefly past the last command so back-to-back
    /// commands don't flicker it. No lifecycle effect.
    public func isWorkingInBackground(name: String) -> Bool {
        guard (computerInFlight[name] ?? 0) == 0 else { return false }
        if (execInFlight[name] ?? 0) > 0 { return true }
        guard let last = lastExecActiveAt[name],
            Date().timeIntervalSince(last) < Self.backgroundExecLinger else { return false }
        // The linger only exists to bridge back-to-back exec commands. If the agent has driven the
        // screen since the last exec ended, the flow is interactive again — don't let the tail of a
        // finished command lay the background overlay over a screen the user is watching change.
        if let computer = lastComputerActiveAt[name], computer > last { return false }
        return true
    }

    /// A rolling tail of the named VM's recent `mac_vm_exec` output (stdout, stderr, and `$ command`
    /// echoes), newest last. The live monitor shows this in a console under the guest screen while
    /// ``isWorkingInBackground(name:)`` holds, so the user can see *what* a headless-busy guest is doing.
    /// Empty when the VM has run nothing or has stopped. No lifecycle effect.
    public func backgroundLog(name: String) async -> [MacVMLogLine] {
        await engine.backgroundLog(name: name)
    }

    /// The macOS VMs currently running this process, for the settings panel.
    public func runningVMs() async -> [MacVMEntry] {
        await engine.runningVMs()
    }

    /// Live fan-out of VM-registry changes — a fresh ``runningVMs()``-shaped snapshot per boot,
    /// readiness flip, stop, suspend, or resume, primed with the current state on subscription
    /// (plan item 7, docs/MAIN_THREAD_PERFORMANCE_PLAN.md). The engine owns the registry and every
    /// lifecycle path — including this manager's idle-stop timers, teardown, and slot eviction —
    /// funnels through its mutation points, so the engine stream is the single subscription point;
    /// this is a plain re-export for consumers holding the manager.
    public func vmChanges() async -> AsyncStream<[MacVMEntry]> {
        await engine.changes()
    }

    /// The system-managed base-image VM booted for maintenance right now (app injection), for the
    /// Control panel's Virtual Machines section, or `nil` when none is running. Kept separate from
    /// ``runningVMs()`` (see the engine method).
    public func baseMaintenanceVM() async -> MacVMMaintenanceInfo? {
        await engine.baseMaintenanceVM()
    }

    /// Current GUI screenshot (base64 JPEG) of the named VM for the live monitor panel, or `nil` when
    /// it isn't running / can't be captured. No lifecycle effect (doesn't boot or keep the VM alive).
    public func captureScreen(name: String) async -> String? {
        await engine.captureScreen(name: name)
    }

    /// Raw JPEG `Data` of the named VM's screen for the live monitor's fast path (no base64 round-trip),
    /// or `nil` when it isn't running / can't be captured. No lifecycle effect.
    public func captureScreenData(name: String) async -> Data? {
        await engine.captureScreenData(name: name)
    }

    /// Best-effort CPU/RAM sample of the named running VM for the Control panel's resource meters, or
    /// `nil` when it isn't running. No lifecycle effect. Probes the guest over SSH, so callers should
    /// refresh it off their critical path (see `AppStore.refreshMacVMUsageInBackground`).
    public func sampleResourceUsage(name: String) async -> MacVMResourceSample? {
        await engine.sampleResourceUsage(name: name)
    }

    /// The one-time base-image build progress (download/install/provision), for the settings panel.
    public func baseProgress() async -> MacVMBaseProgress? {
        await engine.currentBaseProgress()
    }

    /// Which guest OS the in-flight base build is for (`nil` when none is), so the settings panel can
    /// route the shared ``baseProgress()`` to the right section even after its view-local state was
    /// reset by navigating out of and back into Settings.
    public func baseBuildGuest() async -> GuestOS? {
        await engine.currentBaseBuildGuest()
    }

    /// The installed guest macOS version of the current base bundle, for the settings panel.
    public func baseImageOSVersion() async -> String? {
        await engine.baseImageOSVersion()
    }

    /// The base image's provisioning status (installed / exec-ready / computer-use-ready), for the
    /// settings panel — drives the "needs SIP disabled once" guidance.
    public func baseStatus() async -> MacVMBaseStatus {
        await engine.currentBaseStatus()
    }

    /// Inject the app-layer host-side computer-use surface (VZVirtualMachineView capture + HID). Called
    /// once at startup; without it, computer use falls back to the SSH path.
    public func setSurfaceHost(_ host: any MacVMSurfaceHost) async {
        await engine.setSurfaceHost(host)
    }

    /// Inject the app-layer admission/eviction policy for the concurrent-VM ceiling (called once at
    /// startup, like ``setSurfaceHost(_:)``). Given the VM names currently holding slots, the closure
    /// returns those whose owning chat is *done* (no turn in flight), stalest-interaction first — so a
    /// boot that would exceed the ceiling reclaims the stalest done chat's VM instead of failing, and a
    /// session queues only when every slot is a busy chat. The `AppStore` supplies it (it alone knows
    /// chat state). Without it, the engine keeps the old hard throw-on-full behavior. See
    /// ``MacVMEngine/ensureRunning(_:)``.
    public func setEvictionPolicy(_ ranker: @escaping @Sendable ([String]) async -> [String]) async {
        await engine.setEvictionRanker(ranker)
    }

    /// Kick off (or no-op if already present) the golden base image build. Long-running; progress is
    /// polled via ``baseProgress()``.
    public func buildBaseImage(localRestoreImagePath: String? = nil) async throws {
        try await engine.buildBaseImage(localRestoreImagePath: localRestoreImagePath)
    }

    /// Delete the built base image so the next ``buildBaseImage`` reinstalls macOS from scratch.
    /// `includingRestoreImages` also clears the cached `.ipsw` (forces a fresh download). Throws while
    /// the base is busy (building / Recovery) unless `force` is set, which force-cancels the holder first.
    public func deleteBaseImage(includingRestoreImages: Bool, force: Bool = false) async throws {
        try await engine.deleteBaseImage(
            includingRestoreImages: includingRestoreImages, force: force)
    }

    /// Forcibly abandon whatever is holding the base image — a hung provisioning pass or a guest held
    /// open by ``MacVMSettings/holdBaseOnMDMFailure`` — and release its claim, without deleting anything.
    /// Returns a summary of what was released.
    @discardableResult
    public func forceCancelBaseWork() async -> String {
        await engine.forceCancelBaseWork()
    }

    /// Re-run only the mandatory MDM enrollment/policy pass against the already-built base — the fast
    /// iteration loop for the enrollment repair (docs/MACOS_VM_PROVISIONING_REPAIR_PLAN.md). Minutes
    /// rather than the ~40 a full rebuild costs, and it exercises the identical Mode A code path.
    /// Does **not** stamp the provisioning recipe: the toolchain and readiness passes have not run, so
    /// a success means "enrollment works", not "this base is publishable".
    @discardableResult
    public func revalidateBaseMDMPolicy() async throws -> MacVMEngine.MDMOutcome {
        #if arch(arm64)
        return try await engine.revalidateBaseMDMPolicy()
        #else
        throw MacVMError.unavailable(MacVMEngine.unsupportedReason ?? "requires Apple silicon")
        #endif
    }

    // MARK: - Linux base image

    /// Kick off (or no-op if already built) the golden **Linux** base image build (download kernel +
    /// rootfs, assemble in-guest, provision the desktop + toolchain). Long-running; progress is polled
    /// via ``baseProgress()`` (shared with the macOS build — only one base builds at a time).
    public func buildLinuxBaseImage() async throws {
        try await engine.buildLinuxBaseImage()
    }

    /// The Linux base image's provisioning status (installed / desktop+toolchain provisioned).
    public func linuxBaseStatus() async -> MacVMBaseStatus {
        await engine.currentLinuxBaseStatus()
    }

    /// Delete the built Linux base so the next ``buildLinuxBaseImage`` rebuilds from scratch. `force`
    /// force-cancels whatever is holding the base first instead of refusing.
    public func deleteLinuxBaseImage(force: Bool = false) async throws {
        try await engine.deleteLinuxBaseImage(force: force)
    }

    /// Show or hide the diagnostic monitor window for the in-flight base build (watch a stuck build).
    public func setBaseProvisionObserver(visible: Bool) async {
        await engine.setBaseProvisionObserver(visible: visible)
    }

    /// Show or hide the **operator-assist viewer** for the named session VM: bring its live, interactive
    /// guest window on-screen so the user can operate it by hand (`mac_vm_request_operator`), or send it
    /// back off-screen. No lifecycle effect.
    public func presentOperatorAssist(name: String, visible: Bool) async {
        await engine.presentOperatorAssist(name: name, visible: visible)
    }

    /// On launch, reconcile on-disk clone bundles — keep only the active sessions' clones (daemonless,
    /// so this is on-disk GC).
    public func reconcile(activeSessions: [SessionID]) async {
        var keep = Set(activeSessions.map { Self.vmName(for: $0) })
        keep.formUnion(activeSessions.map { Self.linuxVMName(for: $0) })
        await engine.reconcileDisk(keepNames: keep)
    }

    /// On launch, force a re-provision of any engine-built base whose provisioning recipe is older than
    /// the one this app version ships — i.e. a base whose baked-in toolchain/agent an app update has
    /// since changed. Long-running when it fires (it re-runs the provisioning pass), so callers run it
    /// off the launch path. See ``MacVMEngine/reprovisionStaleBaseIfNeeded(for:)``.
    public func reprovisionStaleBaseImagesIfNeeded() async {
        await engine.reprovisionStaleBaseIfNeeded(for: .macOS)
        await engine.reprovisionStaleBaseIfNeeded(for: .linux)
    }

    // MARK: - Idle timer

    private func armIdleTimer(_ name: String) {
        cancelIdleTimer(name)
        // Pinned for a background wait — suppress the auto-kill timer entirely until released.
        guard !pinned.contains(name) else { return }
        let timeout = idleTimeouts[name] ?? TimeInterval(MacVMSettings.defaultIdleTimeoutSeconds)
        idleTimers[name] = Task { [weak self] in
            try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
            guard !Task.isCancelled else { return }
            await self?.stopIfIdle(name)
        }
    }

    private func cancelIdleTimer(_ name: String) {
        idleTimers[name]?.cancel()
        idleTimers[name] = nil
    }

    private func stopIfIdle(_ name: String) async {
        idleTimers[name] = nil
        guard (active[name] ?? 0) == 0 else { return }
        // A guest suspended **to disk** is already fully reclaimed — no RAM, no concurrency slot — so
        // this timer has nothing left to free. Stopping it would only discard the saved state a
        // follow-up resumes from, silently turning every resume after the TTL into a cold boot that
        // loses all in-guest state ("my VM never resumes"). Leave it suspended: the state persists
        // until it's restored, explicitly stopped/killed, or the session is torn down/reconciled.
        // (A RAM-paused guest still holds both RAM and its slot, so the timer must still stop it.)
        if await engine.isPaused(name), !(await engine.isRunning(name)) { return }
        // Cut power immediately — an idled-out VM's runtime state isn't retained, so there's nothing
        // worth a graceful halt. The clone stays on disk for idle-restart.
        await engine.stop(name: name)
    }

    private func waitUntilIdle(_ name: String) async {
        let pollNanos: UInt64 = 200_000_000
        let maxPolls = 3_000  // ~10 min ceiling
        var polls = 0
        while (active[name] ?? 0) > 0 && polls < maxPolls {
            try? await Task.sleep(nanoseconds: pollNanos)
            polls += 1
        }
    }
}
