import Foundation

/// App-level coordinator for per-session sandbox containers (injected like
/// `GitWorktreeManager`). Owns the lifecycle the `ContainerEngine` is too low-level to
/// track: keeping one long-lived container per session, an idle timer that stops it after
/// inactivity, teardown on session end, and on-disk reconciliation on launch.
///
/// Activity is reference-counted: `ensureRunning` (called at the start of every turn)
/// marks the container busy and disarms the idle timer; `finished` marks the turn done and
/// re-arms it. The container is only stopped when no turn is in flight, so a single
/// long-running turn is never killed mid-flight.
public actor ContainerManager {
    /// The sandbox **mechanism** this policy actor drives: the Apple-containerization
    /// `ContainerEngine` on macOS, the wslc broker engine on Windows (docs/WINDOWS_PORT.md §3.1).
    private let engine: any SandboxEngine

    /// Live per-container bookkeeping, keyed by **logical** container name (the stable name in this
    /// type's public API — `containerName(for:)` / `sharedContainerName(for:split:)`).
    private var active: [String: Int] = [:]              // in-flight turn count
    private var idleTimeouts: [String: TimeInterval] = [:]
    private var idleTimers: [String: Task<Void, Never>] = [:]
    /// Container 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-stop timer is suppressed (see ``armIdleTimer(_:)``) so a session that's only *waiting*
    /// keeps its sandbox. Set/cleared by ``holdForBackgroundWait(_:)`` / ``releaseBackgroundWait(_:)``
    /// and cleared by teardown. (A command monitor also holds its own `ensureRunning` ref for the
    /// watch's process; this is the broader, monitor-type-agnostic session guarantee.)
    private var pinned: Set<String> = []

    /// The in-flight destructive lifecycle op (idle stop / recovery restart) per logical name.
    /// `ensureRunning` joins this before touching the engine, which closes a TOCTOU race: actors
    /// are not FIFO, so without the join a higher-priority turn's `engine.ensureRunning` could be
    /// scheduled AHEAD of an already-committed idle stop, be handed the still-live container, and
    /// then have it stopped out from under it — killing every session in a shared box. Ops chain
    /// onto any predecessor so registration order is preserved.
    private var pendingLifecycle: [String: Task<Void, Never>] = [:]

    /// A destructive operation on the networking shared by ALL containers. New turns bump their
    /// activity count first, then join this barrier before touching the engine. Per-container
    /// lifecycle operations chain behind it as well, so replacing vmnet can neither overlap a
    /// start nor race an already-committed idle stop.
    private var pendingGlobalLifecycle: Task<Void, Never>?

    /// Logical names with a control-plane recovery already queued, so concurrent stall reports from
    /// several starving sessions collapse into ONE recovery instead of restarting the container
    /// once per session.
    private var recoveryPending: Set<String> = []

    /// DNS is supplied by one shared sandbox network, so failures reported by multiple agents are
    /// one incident. Keep one drain/probe/reset task in flight for the whole manager.
    private var networkRecoveryPending = false

    /// Register `operation` as `name`'s exclusive lifecycle op and wait for it. Chained behind any
    /// prior op for the name; `ensureRunning` awaits the registered task before it talks to the
    /// engine. Must only be *registered* while `active[name] == 0` (the caller checks, with no
    /// suspension between check and call) — that invariant is what guarantees a destructive op can
    /// never overlap a turn: a turn arriving later bumps `active` first and then blocks on the join.
    private func runExclusiveLifecycle(
        _ name: String, _ operation: @escaping @Sendable () async -> Void
    ) async {
        let global = pendingGlobalLifecycle
        let prior = pendingLifecycle[name]
        let task = Task {
            await global?.value
            await prior?.value
            await operation()
        }
        pendingLifecycle[name] = task
        await task.value
        // A completed task left in the map is harmless (a later join returns immediately), and the
        // map holds at most one entry per known container name — no unbounded growth to clean up.
    }

    /// Logical → **physical** container name for this run. The shared per-family control containers
    /// run under a randomized physical name (a stable base + a random fun word, re-rolled each time
    /// the sandbox is created) so an agent that glimpses another sandbox — via `ps`, a stray pid, a
    /// host-exec — can neither recognize it as a sibling nor guess/target it by name. Bookkeeping and
    /// every public method speak logical names; this map is applied only at the engine boundary.
    /// Non-randomized names (the primary shared container, per-session containers) map to themselves.
    /// Populated lazily on `ensureRunning`; an entry is dropped on teardown/recreate so the next
    /// creation re-randomizes, and the whole map resets each app launch.
    private var physicalNames: [String: String] = [:]

    /// Invoked with a shared control container's **logical** name the moment that container is
    /// *permanently* removed (``teardownShared()`` / ``recreateShared()``), so the owner can release
    /// the container-scoped approval server and its control socket. Idle-stop / restart deliberately do
    /// NOT fire it: the long-lived server is meant to survive those, and the reconnecting session's
    /// `MCPApprovalServer.start(unixSocketPath:)` re-validates the socket, so tearing it down there
    /// would be pure churn. Nil in tests / host-only setups.
    private let onSharedContainerRemoved: (@Sendable (String) async -> Void)?

    #if canImport(Containerization)
    public init(
        engine: any SandboxEngine = ContainerEngine(),
        onSharedContainerRemoved: (@Sendable (String) async -> Void)? = nil
    ) {
        self.engine = engine
        self.onSharedContainerRemoved = onSharedContainerRemoved
    }
    #else
    // Windows has no defaultable engine yet: hostd injects the wslc broker engine explicitly
    // once `Container/Windows/WslcContainerEngine.swift` lands (docs/WINDOWS_PORT.md §3.2).
    public init(
        engine: any SandboxEngine,
        onSharedContainerRemoved: (@Sendable (String) async -> Void)? = nil
    ) {
        self.engine = engine
        self.onSharedContainerRemoved = onSharedContainerRemoved
    }
    #endif

    /// Per-channel suffix appended to every container name on non-release builds, so several
    /// installed Nucleic builds (release + beta + local dev, etc.) running against the **shared**
    /// on-disk container store (`ContainerEngine.defaultStorageRoot`) don't collide on their
    /// per-container rootfs clones. Empty for the stable release; `-canary` / `-beta` / `-rc` /
    /// `-local` for the non-release channels. Baked in at build time from the `NUCLEIC_CHANNEL` define
    /// (mirrors NucleicApp's `BuildChannel`); NucleicCore is compiled with that define — see Package.swift.
    public nonisolated static let channelSuffix: String = {
        #if NUCLEIC_STABLE
        return ""
        #elseif NUCLEIC_RC
        return "-rc"
        #elseif NUCLEIC_BETA
        return "-beta"
        #elseif NUCLEIC_CANARY
        return "-canary"
        #else
        return "-local"
        #endif
    }()

    /// Every non-empty `channelSuffix` a build can carry. Used to tell a stable build's untagged
    /// containers apart from other channels' tagged ones during disk GC. Keep in lockstep with the
    /// `channelSuffix` cases above.
    public nonisolated static let nonReleaseSuffixes = ["-local", "-canary", "-beta", "-rc"]

    /// Whether a container/clone name belongs to THIS build channel's namespace. The on-disk
    /// container store is shared across installed builds (`ContainerEngine.defaultStorageRoot`), so
    /// the launch-time disk GC uses this to reap only its own channel's clones and never another
    /// build's. A non-release build owns names ending in its `channelSuffix`; the stable release owns
    /// names carrying none of the non-release tags.
    public nonisolated static func ownsContainer(named name: String) -> Bool {
        channelSuffix.isEmpty
            ? !nonReleaseSuffixes.contains(where: name.hasSuffix)
            : name.hasSuffix(channelSuffix)
    }

    /// Stable container name for a session. Lowercased because `UUID().uuidString` (hence
    /// `SessionID.short`) is uppercase, while container names are conventionally lowercase
    /// and our reconcile parser matches lowercase hex. Carries the per-channel `channelSuffix`.
    public nonisolated static func containerName(for session: SessionID) -> String {
        "nucleic-\(session.short.lowercased())\(channelSuffix)"
    }

    /// The single **primary** container shared by all Nucleic Control projects that haven't
    /// opted into per-session containers. Deliberately not of the `nucleic-<8 hex>` shape, so
    /// the reconcile/list parser never mistakes it for a per-session container (and so a
    /// per-session `teardown` — which computes a `nucleic-<short>` name — never removes it).
    /// Its busy/idle lifecycle is ref-counted by name across every control session that uses it.
    /// Used by every backend when backend-splitting is off; with splitting on, each agent family
    /// gets its own suffixed sibling (`-claude`/`-codex`/`-xai`) instead. Carries the per-channel
    /// `channelSuffix`.
    public static let sharedControlContainerName = "nucleic-control" + channelSuffix

    /// The per-family shared control containers, used only when
    /// `ContainerServiceSettings.splitControlContainersByBackend` is on — one sandbox per agent
    /// family so competitive Claude / GPT / xAI agents can't kill each other's processes
    /// ("agenticide") in a shared box. Same non-`nucleic-<8 hex>` shape as the primary, so they're
    /// likewise invisible to the per-session list parser. Each carries the `channelSuffix` at the
    /// very end (e.g. `nucleic-control-codex-beta`).
    public static let claudeControlContainerName = "nucleic-control-claude" + channelSuffix
    public static let codexControlContainerName = "nucleic-control-codex" + channelSuffix
    public static let xaiControlContainerName = "nucleic-control-xai" + channelSuffix
    public static let opencodeControlContainerName = "nucleic-control-opencode" + channelSuffix
    public static let openclawControlContainerName = "nucleic-control-openclaw" + channelSuffix
    public static let hermesControlContainerName = "nucleic-control-hermes" + channelSuffix
    public static let cursorControlContainerName = "nucleic-control-cursor" + channelSuffix

    /// Every shared control container name that can exist. Lifecycle sweeps (reconcile, teardown,
    /// status, recreate) iterate this so they stay correct no matter how the split setting is
    /// currently configured — e.g. turning splitting back off still reaps the per-family containers.
    public static let allSharedControlContainerNames =
        [sharedControlContainerName, claudeControlContainerName,
         codexControlContainerName, xaiControlContainerName,
         opencodeControlContainerName, openclawControlContainerName,
         hermesControlContainerName, cursorControlContainerName]

    /// The shared control container a session should use, given its backend and whether
    /// backend-splitting is on. With splitting off, every backend shares `nucleic-control`; with it
    /// on, each agent family gets its own sandbox: Claude → `nucleic-control-claude`, GPT/Codex →
    /// `nucleic-control-codex`, xAI/Grok → `nucleic-control-xai`.
    public nonisolated static func sharedContainerName(
        for backend: BackendID, split: Bool
    ) -> String {
        guard split else { return sharedControlContainerName }
        switch backend {
        case .claudeCode: return claudeControlContainerName
        case .codex, .codexExec: return codexControlContainerName
        case .grok: return xaiControlContainerName
        case .opencode: return opencodeControlContainerName
        case .openclaw: return openclawControlContainerName
        case .hermes: return hermesControlContainerName
        case .cursorAgent: return cursorControlContainerName
        case .acp: return sharedControlContainerName
        }
    }

    // MARK: - Randomized physical names

    /// The logical control-container names that run under a **randomized** physical name: the
    /// per-agent-family containers. The primary `sharedControlContainerName` is excluded — when
    /// splitting is off it's the lone control sandbox, so there's no sibling for an agent to target.
    public nonisolated static let randomizedControlNames: Set<String> =
        [claudeControlContainerName, codexControlContainerName, xaiControlContainerName,
         opencodeControlContainerName, openclawControlContainerName,
         hermesControlContainerName, cursorControlContainerName]

    /// Friendly type label for a shared control container's *logical* name (Control panel heading).
    public nonisolated static func controlTypeLabel(forLogical logical: String) -> String {
        switch logical {
        case claudeControlContainerName: return "Claude"
        case codexControlContainerName: return "Codex"
        case xaiControlContainerName: return "xAI"
        case opencodeControlContainerName: return "OpenCode"
        case openclawControlContainerName: return "OpenClaw"
        case hermesControlContainerName: return "Hermes"
        case cursorControlContainerName: return "Cursor"
        default: return "Shared"
        }
    }

    /// Dictionary of fun / curious / odd words the random sandbox suffix is drawn from. Lowercase,
    /// single tokens (container names are lowercase, hyphen-delimited), and deliberately free of the
    /// channel tags (`local`/`canary`/`beta`/`rc`) so a suffix can never look like a `channelSuffix` and
    /// confuse the disk-GC's channel scoping.
    public nonisolated static let funWords: [String] = [
        "wobble", "kerfuffle", "noodle", "quokka", "zephyr", "pickle", "bumble", "gizmo",
        "waffle", "snazzy", "doodad", "kumquat", "flapjack", "wombat", "gadget", "muffin",
        "pretzel", "narwhal", "tangelo", "bramble", "fiddle", "galoot", "hodgepodge", "jamboree",
        "kazoo", "lollop", "moxie", "nincompoop", "oodles", "persnickety", "quibble", "razzle",
        "skedaddle", "tumbleweed", "umpteen", "vortex", "whirligig", "yonder", "zigzag", "bonbon",
        "cattywampus", "discombobulate", "doozy", "flummox", "gobbledygook", "hullabaloo", "lollygag",
        "malarkey", "nifty", "obelisk", "pumpernickel", "quagmire", "rumpus", "snickerdoodle",
        "thingamajig", "widget", "zonk", "blunderbuss", "cahoots", "dingus", "epiphany", "frippery",
        "gubbins", "haberdash", "iguana", "junket", "kerplunk", "limerick", "mollusk", "nimbus",
        "ottoman", "platypus", "quasar", "rutabaga", "squiggle", "trundle", "ukulele", "vagabond",
        "walrus", "xylophone", "yodel", "ziggurat", "abacus", "bazooka", "conundrum", "dapper",
        "embiggen", "flabbergast", "gizzard", "hobnob", "inkling", "jubilee", "kerchief", "loofah",
        "marmalade", "nugget", "octopus", "paprika", "quokkas", "ricochet", "sasquatch", "tadpole",
        "umbrella", "verbena", "whatsit", "yowza", "zucchini", "bumblebee", "cucumber", "doohickey",
        "fandango", "gargoyle", "hiccup", "jellybean", "kookaburra", "lemur", "meerkat", "noggin",
    ]

    /// Pick a random fun word for a new sandbox.
    public nonisolated static func randomFunWord() -> String { funWords.randomElement() ?? "sandbox" }

    /// Compose the physical name for a randomized control container from a fun `word`:
    /// `nucleic-control-<word><channelSuffix>`. Crucially the agent *family is not encoded* — all
    /// split families share the `nucleic-control-…` shape — so an agent that glimpses a sibling's
    /// name can't tell which family (Claude / Codex / xAI) it serves, only the trusted host UI maps
    /// name → type. The channel tag stays last so the disk-GC's `ownsContainer` scoping still works.
    /// Pure, for testability.
    public nonisolated static func physicalControlName(word: String) -> String {
        "nucleic-control-\(word)\(channelSuffix)"
    }

    /// The physical name a logical name currently maps to (identity until materialized).
    private func physical(_ logical: String) -> String { physicalNames[logical] ?? logical }

    /// Resolve (allocating on first use) the physical name for `logical`. Randomized control names
    /// get a fresh fun-word physical name — distinct from every other live sandbox's, so the three
    /// split families never collide on a word; everything else maps to itself.
    private func materializePhysical(_ logical: String) -> String {
        if let existing = physicalNames[logical] { return existing }
        guard Self.randomizedControlNames.contains(logical) else {
            physicalNames[logical] = logical
            return logical
        }
        let used = Set(physicalNames.values)
        var phys = Self.physicalControlName(word: Self.randomFunWord())
        var tries = 0
        while used.contains(phys) && tries < 50 {
            phys = Self.physicalControlName(word: Self.randomFunWord())
            tries += 1
        }
        physicalNames[logical] = phys
        return phys
    }

    /// Ensure the session's container is up and return its name plus the host-gateway IP
    /// (the container's view of the Mac, for routing the approval MCP server). Marks a turn
    /// as in-flight; pair with `finished(name:)`.
    public func ensureRunning(_ spec: ContainerSpec) async throws
        -> (name: String, hostGateway: String)
    {
        cancelIdleTimer(spec.name)
        active[spec.name, default: 0] += 1
        idleTimeouts[spec.name] = spec.idleTimeout
        // Join any in-flight idle stop / recovery restart BEFORE touching the engine. The bump
        // above happens first, so no NEW destructive op can register while we wait (they all check
        // `active == 0` with no suspension before registering); the join then guarantees the engine
        // sees our create strictly after the destructive op finished — never a stale live container
        // that's about to be stopped underneath the turn.
        if let pending = pendingGlobalLifecycle {
            await pending.value
        }
        if let pending = pendingLifecycle[spec.name] {
            await pending.value
        }
        do {
            // The engine creates/starts the VM (or returns the already-live one) and hands back
            // the host-gateway address the guest reaches us on — known at interface-allocation
            // time, no in-container `ip route` probe needed. The engine runs it under the randomized
            // physical name; callers keep using the stable logical name (returned here).
            let phys = materializePhysical(spec.name)
            let (_, gateway) = try await engine.ensureRunning(spec.renamed(phys))
            return (spec.name, gateway)
        } catch {
            // Roll back the activity bump so a failed start doesn't pin the container busy.
            finishBookkeeping(spec.name)
            throw error
        }
    }

    /// Run `argv` inside the named container, returning a process handle (forwards to the engine;
    /// no lifecycle effect). The handle streams the guest process's stdio over vsock and satisfies
    /// the same `ProcessHandle` contract a host spawn does.
    public func exec(
        name: String, workdir: String, env: [String: String], argv: [String],
        uid: Int? = nil, gid: Int? = nil
    ) async throws -> any ProcessHandle {
        // Hard per-session memory ceiling, opt-in and ONLY for the shared control container (per-session
        // sandbox containers already run one session each, so a cap there would just throttle that one
        // session). When set, each control-session exec gets its own cgroup `memory.max` so one session
        // can't consume the whole shared box before its own OOM. 0 = off (see `controlPerSessionMemoryGiB`).
        var memoryLimitBytes: UInt64? = nil
        if Self.allSharedControlContainerNames.contains(name) {
            let gib = ContainerServiceSettings.controlPerSessionMemoryGiB
            if gib > 0 { memoryLimitBytes = UInt64(gib) * 1024 * 1024 * 1024 }
        }
        return try await engine.exec(
            name: physical(name), workdir: workdir, env: env, argv: argv, uid: uid, gid: gid,
            memoryLimitBytes: memoryLimitBytes)
    }

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

    /// Keep the session's container alive across a **background wait** — it armed a `nucleic_monitor`
    /// and ended the turn to be re-invoked when the watch fires. Pins the container name so the
    /// idle-stop timer is suppressed (and cancels any already-armed one) until ``releaseBackgroundWait(_:)``.
    /// Idempotent; does not start a container. Pair with the release.
    public func holdForBackgroundWait(_ session: SessionID) {
        let name = Self.containerName(for: session)
        pinned.insert(name)
        cancelIdleTimer(name)
    }

    /// Drop a background-wait pin (the monitor ended, or the session did other work). Re-arms the
    /// normal idle timer if the container is now idle. A no-op for a session that wasn't pinned.
    public func releaseBackgroundWait(_ session: SessionID) {
        let name = Self.containerName(for: session)
        guard pinned.remove(name) != nil else { return }
        if (active[name] ?? 0) == 0 { armIdleTimer(name) }
    }

    /// Whether the named container is currently **live** (running) in this process, as opposed to
    /// idle-stopped with only its on-disk clone kept. Lets the `linux_container exec` handler tell a
    /// cold restart (worth narrating) from a warm reuse, mirroring `MacVMManager.isRunning`. Maps the
    /// logical name to physical at the engine boundary like every other method; no lifecycle effect.
    public func isRunning(name: String) async -> Bool {
        await engine.isRunning(physical(name))
    }

    /// Stop a throwaway container the agent created via `linux_container` (keeps its clone on disk so
    /// a later `exec` can restart it). Clears its activity bookkeeping so no idle timer lingers.
    public func stopAgentContainer(name: String) async {
        cancelIdleTimer(name)
        active[name] = nil
        idleTimeouts[name] = nil
        await engine.stop(name: physical(name))
    }

    /// Stop and delete a throwaway container the agent created via `linux_container`. Returns true if
    /// it's gone (or never existed). Clears its bookkeeping and physical-name mapping.
    @discardableResult
    public func removeAgentContainer(name: String) async -> Bool {
        cancelIdleTimer(name)
        active[name] = nil
        idleTimeouts[name] = nil
        let removed = await engine.remove(name: physical(name))
        physicalNames[name] = nil
        return removed
    }

    private func finishBookkeeping(_ name: String) {
        let remaining = max(0, (active[name] ?? 0) - 1)
        active[name] = remaining
        if remaining == 0 { armIdleTimer(name) }
    }

    /// Stop and remove the session's container. When `waitForActive` is true, first wait for
    /// any in-flight turn in that container to finish (graceful — used when sandboxing is
    /// turned off for a project); otherwise remove immediately (archive/delete). Returns the
    /// container name if it could NOT be removed, so the caller can warn the user; `nil` on
    /// success (including when no such container existed).
    @discardableResult
    public func teardown(_ session: SessionID, waitForActive: Bool = false) async -> String? {
        let name = Self.containerName(for: session)
        if waitForActive { await waitUntilIdle(name) }
        cancelIdleTimer(name)
        pinned.remove(name)
        active[name] = nil
        idleTimeouts[name] = nil
        let removed = await engine.remove(name: physical(name))
        physicalNames[name] = nil
        return removed ? nil : name
    }

    /// Stop and remove the shared primary control container(s) and clear their bookkeeping. Skips
    /// any container with a turn still in flight (so a sibling control session isn't yanked out)
    /// and any that doesn't exist. Sweeps every shared name — both the Claude and the split-out
    /// Codex container — so a session of either family is reaped. Per-session `teardown` can't
    /// reach these (it derives a `nucleic-<short>` name), so AppStore calls this once the last
    /// control session is gone.
    public func teardownShared() async {
        for name in Self.allSharedControlContainerNames {
            guard (active[name] ?? 0) == 0 else { continue }
            cancelIdleTimer(name)
            active[name] = nil
            idleTimeouts[name] = nil
            await engine.remove(name: physical(name))
            physicalNames[name] = nil  // next creation re-randomizes
            await onSharedContainerRemoved?(name)  // release the container-scoped approval server + socket
        }
    }

    /// Health of the shared Nucleic Control container(s), for the Control panel. Reports running /
    /// exists if *any* shared control container (Claude or the split-out Codex one) is up. Best-
    /// effort: a running probe (`exec true`) first — the common case — falling back to an existence
    /// check only when none is running.
    public func controlContainerStatus() async -> (exists: Bool, running: Bool) {
        var exists = false
        for name in Self.allSharedControlContainerNames {
            let phys = physical(name)
            if await engine.isRunning(phys) { return (true, true) }
            if await engine.containerExists(phys) { exists = true }
        }
        return (exists, false)
    }

    /// The shared control containers that currently exist this run, each with its friendly type
    /// label and its **actual (randomized) name**, for the Control panel — which shows the type as
    /// the heading and the opaque name as subtext. Only materialized containers appear.
    public func controlContainers() async -> [ControlContainerEntry] {
        var out: [ControlContainerEntry] = []
        for logical in Self.allSharedControlContainerNames {
            let phys = physical(logical)
            let running = await engine.isRunning(phys)
            var exists = running
            if !exists { exists = await engine.containerExists(phys) }
            guard exists else { continue }
            out.append(ControlContainerEntry(
                typeLabel: Self.controlTypeLabel(forLogical: logical), name: phys, running: running))
        }
        return out
    }

    /// Live CPU/memory usage of the running shared control container, for the Control panel's
    /// resource monitor. Samples the first shared control container that's actually up (Claude's
    /// `nucleic-control` or the split-out Codex one) — the resources are shared, so either reading
    /// reflects the load. Best-effort: `nil` when none is running or the probe fails.
    public func controlContainerUsage() async -> ContainerResourceSample? {
        // Sample each shared name directly — the probe is an in-process `statistics()` read, which
        // returns nil against a stopped/absent container, so no separate running check is needed.
        // The first that yields a reading wins, so the common single-container case costs one read.
        for name in Self.allSharedControlContainerNames {
            if let sample = await engine.sampleResourceUsage(name: physical(name)) { return sample }
        }
        return nil
    }

    /// The shared engine's in-flight artifact download (kernel / init / image pull + unpack), for
    /// the Control panel's progress bar and the chat's delay hint. One engine backs every container,
    /// so this is a single, cheap read (no I/O); `nil` whenever everything is cached and nothing is
    /// downloading.
    public func controlDownloadProgress() async -> ContainerDownloadProgress? {
        await engine.currentDownloadProgress()
    }

    /// Post-mortem for an agent that exited 137/SIGKILL in container `name`: was the container
    /// itself taken down, did the kernel OOM-kill, or was it killed while healthy? Forwards to the
    /// runtime; pure lookup, no lifecycle effect. Best-effort.
    public func diagnoseContainerKill(name: String) async -> ContainerKillDiagnosis {
        await engine.diagnoseKill(name: physical(name))
    }

    /// End-to-end probe of the container's control plane (loopback bridge → relayed socket → host
    /// approval server), run from inside the guest. Pure lookup, no lifecycle effect. Used by the
    /// spawn-stall watchdog to identify which hop is dead instead of guessing.
    public func probeControlPlane(name: String) async -> ContainerEngine.ControlPlaneProbe {
        await engine.probeControlPlane(name: physical(name))
    }

    /// Recover a container whose control plane was probed dead — WITHOUT ever killing a live
    /// session. The old behavior restarted the container immediately, which SIGKILLed every other
    /// session's agent mid-turn (a mass 137); a single false-positive probe under load turned one
    /// slow spawn into a whole-box massacre. This version:
    ///
    ///   1. **Dedups** — concurrent stall reports from several starving sessions collapse into one
    ///      queued recovery.
    ///   2. **Waits until the container is idle** (no turn in flight). If the plane is truly dead,
    ///      every session stalls out and settles within its own watchdog window, so idleness
    ///      arrives quickly; if it was a false alarm, the other sessions keep working and are never
    ///      touched. Gives up (rather than kills) if the box never goes idle within the cap —
    ///      sessions still making progress are proof the plane isn't dead.
    ///   3. **Re-probes before acting** (with retries spaced past the in-guest bridge supervisor's
    ///      1s restart gap): a plane that answers now — it was momentarily slow, or the supervised
    ///      bridge already self-healed — cancels the restart entirely.
    ///
    /// Only a container that is idle AND still dead after all of that is restarted, and the restart
    /// is registered as the exclusive lifecycle op so a turn arriving mid-restart queues behind it
    /// and gets the fresh container. Callers still gate the *request* on a failed
    /// ``probeControlPlane(name:)``, never on a bare stall.
    public func requestControlPlaneRecovery(name: String) async {
        guard !recoveryPending.contains(name) else { return }
        recoveryPending.insert(name)
        defer { recoveryPending.remove(name) }

        await waitUntilIdle(name)
        guard (active[name] ?? 0) == 0 else { return }  // never idle: sessions are alive — no restart
        let phys = physical(name)
        // Idle-stopped or removed while we waited: the next `ensureRunning` boots a fresh VM (fresh
        // bridge, fresh relay) anyway — that IS the recovery, nothing left to do.
        guard await engine.isRunning(phys) else { return }

        // Confirm the plane is still dead now that the box is drained. Any responsive probe —
        // including one after a retry — means slowness, not death: skip the restart.
        for attempt in 0..<3 {
            if attempt > 0 { try? await Task.sleep(nanoseconds: 2_500_000_000) }
            if case .responsive = await engine.probeControlPlane(name: phys) { return }
            // A turn may have started while we probed; its agent is live — abort, don't kill it.
            guard (active[name] ?? 0) == 0 else { return }
        }
        guard await engine.isRunning(phys) else { return }  // stopped during the probes — recovered

        let engine = self.engine
        // Final synchronous check: the `isRunning` await above is a suspension a turn could start
        // during. From here to the registration inside `runExclusiveLifecycle` there is no further
        // suspension (same-actor call), so a turn arriving later bumps `active` and joins the
        // restart instead of racing it.
        guard (active[name] ?? 0) == 0 else { return }
        await runExclusiveLifecycle(name) {
            await engine.restart(name: phys)
        }
    }

    /// Recover the shared sandbox DNS/NAT service after a provider reports a concrete resolver
    /// failure (`getaddrinfo` / `EAI_AGAIN`). Unlike control-plane recovery, restarting only the
    /// affected container cannot help: every container points at the same vmnet gateway DNS proxy,
    /// and `ContainerEngine.restart` intentionally reuses that network object.
    ///
    /// The recovery is deliberately conservative:
    ///
    ///   1. Deduplicate reports globally.
    ///   2. Drain *all* managed turns, because rebuilding shared networking stops every attached VM.
    ///   3. Re-resolve from the affected guest; a responsive lookup means the host path transition
    ///      healed naturally and cancels the reset.
    ///   4. Register one global lifecycle barrier, chained after already-committed per-container
    ///      stops/restarts. A turn arriving after registration waits and starts on the fresh network.
    ///
    /// If any turn remains live for the safety window, do nothing rather than kill useful work.
    public func requestNetworkRecovery(name: String, hostname: String) async {
        guard !networkRecoveryPending else { return }
        networkRecoveryPending = true
        defer { networkRecoveryPending = false }

        await waitUntilAllIdle()
        guard allContainersIdle else { return }

        let phys = physical(name)
        if await engine.isRunning(phys) {
            if case .responsive = await engine.probeDNS(name: phys, hostname: hostname) { return }
            // The probe awaits outside this actor. A turn may have started in the meantime; never
            // stop its VM. If it already finished, resetting now remains safe.
            guard allContainersIdle else { return }
        }

        let engine = self.engine
        // Snapshot lifecycle work registered before this global reset. No suspension occurs from
        // the final idle check through publishing `pendingGlobalLifecycle`, so a later turn must see
        // the barrier, while an earlier turn would already make the check fail.
        guard allContainersIdle else { return }
        let priorGlobal = pendingGlobalLifecycle
        let priorPerContainer = Array(pendingLifecycle.values)
        let task = Task {
            await priorGlobal?.value
            for prior in priorPerContainer { await prior.value }
            await engine.resetNetwork()
        }
        pendingGlobalLifecycle = task
        await task.value
        // As with `pendingLifecycle`, retaining the completed task is intentional: later joins are
        // immediate, and keeping it avoids an actor-reentrancy race while this method unwinds.
    }

    /// Suspend until no turn is in flight in `name` (or a safety cap elapses, so a wedged
    /// turn can't block cleanup forever). Awaiting releases the actor, letting `finished`
    /// run and decrement the count.
    private func waitUntilIdle(_ name: String) async {
        let pollNanos: UInt64 = 200_000_000  // 0.2s
        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
        }
    }

    private var allContainersIdle: Bool {
        !active.values.contains(where: { $0 > 0 })
    }

    /// Shared networking cannot be replaced one container at a time, so wait for the manager-wide
    /// active count to drain. Same 10-minute safety cap as per-container recovery.
    private func waitUntilAllIdle() async {
        let pollNanos: UInt64 = 200_000_000
        let maxPolls = 3_000
        var polls = 0
        while !allContainersIdle && polls < maxPolls {
            try? await Task.sleep(nanoseconds: pollNanos)
            polls += 1
        }
    }

    /// On app launch, reconcile on-disk container artifacts. Daemonless: no VM survives the app
    /// process, so there are no live orphans to kill — this is on-disk GC. It keeps only the active
    /// sessions' rootfs clones and drops everything else, including the shared control containers'
    /// clones (so they rebuild fresh on next use, picking up the current bind-mount paths) and image
    /// caches superseded by a bundled-image bump.
    public func reconcile(activeSessions: [SessionID]) async {
        let keep = Set(activeSessions.map { Self.containerName(for: $0) })
        await engine.reconcileDisk(keepNames: keep)
    }

    /// Force a fresh shared Nucleic Control sandbox: stop and remove the shared container and
    /// delete the bundled default image, so the next control session rebuilds the image from the
    /// current Dockerfile and recreates the container. Unconditional (ignores the busy ref-count)
    /// — it's a deliberate "rebuild now" from Settings; a control session mid-turn will have its
    /// container yanked and must retry. Returns once teardown + image delete complete.
    public func recreateShared() async {
        for name in Self.allSharedControlContainerNames {
            cancelIdleTimer(name)
            active[name] = nil
            idleTimeouts[name] = nil
            await engine.remove(name: physical(name))
            physicalNames[name] = nil  // force a fresh random name on the next creation
            await onSharedContainerRemoved?(name)  // release the container-scoped approval server + socket
        }
        await engine.removeDefaultRootfs()
    }

    /// Restart the shared control container(s) in place to reclaim the VM memory the guest holds
    /// onto while running (freed pages aren't returned to the host until the VM is torn down). Keeps
    /// the cached rootfs and the per-container instrumentation (the engine reuses the existing rootfs
    /// clone and skips re-seeding), so it's much lighter than `recreateShared`. Skips any container
    /// with a turn in flight, so live work isn't
    /// yanked — the user can retry once it's idle. Re-arms nothing; the next turn's `ensureRunning`
    /// takes over the idle bookkeeping.
    public func restartShared() async {
        for name in Self.allSharedControlContainerNames {
            guard (active[name] ?? 0) == 0 else { continue }
            cancelIdleTimer(name)
            let engine = self.engine
            let phys = physical(name)
            // Through the lifecycle lock, like the idle stop: a turn starting mid-restart joins it
            // and recreates after, rather than being handed the container being torn down.
            await runExclusiveLifecycle(name) {
                await engine.restart(name: phys)  // same name — reuses the clone
            }
        }
    }

    /// Refresh the npm-based agent CLIs (Codex + Claude Code) **in place** inside every running
    /// shared control container — the "Check for updates" action. Runs `npm install -g …@latest`
    /// as root so a new Codex/Claude Code release (and the models it unlocks) is available
    /// immediately, without a full image re-pull or an app update. Only touches containers that are
    /// currently live — an in-place upgrade only makes sense against a running guest; a stopped one
    /// gets the fresh CLI the next time its rootfs is (re-)created from a rebuilt image. Grok is
    /// installed via its own curl installer into a root-only tree and isn't refreshed here; it rides
    /// image re-creation. Best-effort per container; returns a per-container summary. The busy
    /// ref-count is bumped for the duration so the idle timer can't stop a container mid-update.
    public func updateAgentCLIs() async -> [AgentCLIUpdateResult] {
        // Root env with a full PATH (npm lives at /usr/local/bin) and the update-notifier silenced.
        let env = [
            "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
            "HOME": "/root",
            "NO_UPDATE_NOTIFIER": "1",
        ]
        var results: [AgentCLIUpdateResult] = []
        for name in Self.allSharedControlContainerNames {
            let phys = physical(name)
            guard await engine.isRunning(phys) else { continue }
            let label = Self.controlTypeLabel(forLogical: name)

            cancelIdleTimer(name)
            active[name, default: 0] += 1
            let install = await engine.runCapturing(
                name: phys,
                argv: ["npm", "install", "-g", "@openai/codex@latest", "@anthropic-ai/claude-code@latest"],
                env: env, uid: 0, gid: 0)
            if let install, install.exitCode == 0 {
                // Report the resulting versions (best-effort — a missing `--version` doesn't fail it).
                let ver = await engine.runCapturing(
                    name: phys,
                    argv: ["sh", "-c", "codex --version 2>/dev/null; claude --version 2>/dev/null"],
                    env: env, uid: 0, gid: 0)
                let detail = ver?.output.trimmingCharacters(in: .whitespacesAndNewlines)
                results.append(AgentCLIUpdateResult(
                    container: label, success: true,
                    detail: (detail?.isEmpty == false) ? detail! : "Updated"))
            } else {
                results.append(AgentCLIUpdateResult(
                    container: label, success: false,
                    detail: install?.output.trimmingCharacters(in: .whitespacesAndNewlines)
                        ?? "Container not running"))
            }
            finishBookkeeping(name)
        }
        return results
    }

    /// Whether the registry serves a newer sandbox base image than the one currently unpacked (the
    /// asset auto-update check), compared cheaply by manifest digest. Forwards to the engine; no
    /// lifecycle effect and never re-pulls — the caller applies an available update via
    /// ``recreateShared``.
    public func checkImageUpdate() async -> ImageUpdateStatus {
        await engine.checkDefaultImageUpdate()
    }

    /// Force an immediate memory-reclaim (balloon) pass across all live containers instead of
    /// waiting for the autoballoon loop's next tick. A no-op for any container whose policy is disabled.
    public func reclaimMemoryNow() async {
        await engine.reclaimMemoryNow()
    }

    /// The pre-container host gateway, where the engine has one (the Windows wslc session's
    /// WSL-facing address; `nil` on macOS, whose gateway is per-container). Backends use it to
    /// bind the approval server before `ensureRunning` (docs/WINDOWS_PORT.md §5).
    public func sessionHostGateway() async throws -> String? {
        try await engine.sessionHostGateway()
    }

    // MARK: - Idle timer

    private func armIdleTimer(_ name: String) {
        cancelIdleTimer(name)
        // Pinned for a background wait — suppress idle-stop entirely until released.
        guard !pinned.contains(name) else { return }
        let timeout = idleTimeouts[name] ?? TimeInterval(ProjectSandbox.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 turn snuck in; leave it running
        let engine = self.engine
        let phys = physical(name)
        // Registered (not awaited raw) so a turn arriving mid-stop joins it and recreates after,
        // instead of racing the engine actor and getting the doomed live container handed back.
        // No suspension between the `active` check above and this registration (see the invariant
        // on `runExclusiveLifecycle`).
        await runExclusiveLifecycle(name) {
            await engine.stop(name: phys)  // keep the mapping — idle-restart reuses the clone
        }
    }
}
