import Foundation

/// 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
    public init(name: String, ipAddress: String?, os: GuestOS = .macOS) {
        self.name = name
        self.ipAddress = ipAddress
        self.os = os
    }
}

/// 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> = []

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

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

    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)
        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.
    public func run(
        name: String, command: String, workdir: String?, env: [String: String] = [:]
    ) async throws -> (exitCode: Int32, stdout: String, stderr: String) {
        // 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.
        execInFlight[name, default: 0] += 1
        lastExecActiveAt[name] = Date()
        defer {
            execInFlight[name] = max(0, (execInFlight[name] ?? 0) - 1)
            lastExecActiveAt[name] = Date()
        }
        return try await engine.run(name: name, command: command, workdir: workdir, env: env)
    }

    /// 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).
    /// No lifecycle effect.
    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) {
        // 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.
        computerInFlight[name, default: 0] += 1
        defer { computerInFlight[name] = max(0, (computerInFlight[name] ?? 0) - 1) }
        return try await engine.performComputerAction(
            name: name, action: action, x: x, y: y, text: text, ref: ref, value: value,
            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) }

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

    /// Pause any running guest(s) owned by a chat that just reached Done, while preserving the idle
    /// timer already armed by `finished`. A follow-up transparently resumes the guest through
    /// `ensureRunning`; if there is no follow-up, the unchanged timer still stops it at the current
    /// TTL. If completion races the final VM operation's bookkeeping, defer the pause 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)
        }
    }

    /// Pausing crosses into the engine actor and then into Virtualization, so this manager actor can
    /// accept a new `ensureRunning` while the pause is in flight. If that happened, thaw the guest
    /// again before returning. If the follow-up starts just after this check, the engine already
    /// records the VM as paused and its own `ensureRunning` fast path performs the transparent resume.
    private func suspendUnlessReactivated(_ name: String) async {
        let suspended = await engine.suspend(name: name)
        if suspended, (active[name] ?? 0) > 0 {
            _ = await engine.resume(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)
            suspendWhenIdle.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)
        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. The
    /// clone is rebooted later, so the guest is asked to halt itself before its power is cut.
    public func stop(name: String) async {
        cancelIdleTimer(name)
        suspendWhenIdle.remove(name)
        active[name] = nil
        await engine.shutdown(
            name: name, timeout: TimeInterval(MacVMSettings.gracefulShutdownTimeoutSeconds))
    }

    /// Suspend (freeze) the named VM in place: keeps its RAM but pauses CPU; ``resume(name:)`` thaws it
    /// instantly, as does the next exec/computer-use call (reuse transparently resumes). Disarms the
    /// idle timer so a suspended VM isn't stopped out from under the agent. Returns whether it paused
    /// (`false` when it wasn't running).
    @discardableResult
    public func suspend(name: String) async -> Bool {
        cancelIdleTimer(name)
        suspendWhenIdle.remove(name)
        return await engine.suspend(name: name)
    }

    /// Resume (thaw) a suspended VM. Returns whether it resumed (`false` when it wasn't suspended).
    @discardableResult
    public func resume(name: String) async -> Bool {
        await engine.resume(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)
        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)
        suspendWhenIdle.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 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] else { return false }
        return Date().timeIntervalSince(last) < Self.backgroundExecLinger
    }

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

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

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

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

    /// Filesystem path of the complete base bundle, for the Recovery helper (boot into recoveryOS to
    /// `csrutil disable`). `nil` when no base exists.
    public func baseBundleRootPath() async -> String? {
        await engine.baseBundleRootPath()
    }

    /// Claim the base for an exclusive Recovery boot; returns the base path. Throws if the base is
    /// busy (building/Recovery) or missing. Pair with ``endBaseRecovery()`` when the window closes.
    public func beginBaseRecovery() async throws -> String {
        try await engine.beginBaseRecovery()
    }

    /// Release the base after the Recovery window's VM has powered off.
    public func endBaseRecovery() async {
        await engine.endBaseRecovery()
    }

    /// 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).
    public func deleteBaseImage(includingRestoreImages: Bool) async throws {
        try await engine.deleteBaseImage(includingRestoreImages: includingRestoreImages)
    }

    // 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.
    public func deleteLinuxBaseImage() async throws {
        try await engine.deleteLinuxBaseImage()
    }

    /// 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)
        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 }
        // Keep the clone on disk — idle-restart reuses it — so let the guest halt itself rather than
        // cutting power out from under the filesystem the next boot will mount. For a Done chat the
        // guest is suspended by now; `shutdown` thaws it to deliver the request.
        await engine.shutdown(
            name: name, timeout: TimeInterval(MacVMSettings.gracefulShutdownTimeoutSeconds))
    }

    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
        }
    }
}
