import Foundation
import Containerization
import ContainerizationOCI
import ContainerizationExtras

/// Errors surfaced from the in-process container engine.
public enum ContainerError: Error, Sendable, CustomStringConvertible {
    /// Containerization isn't usable on this machine (not Apple silicon, missing the bundled
    /// kernel / vminitd initfs, or — surfaced later, at VM start — no virtualization entitlement).
    case unavailable(String)
    /// An operation referenced a container that the engine isn't currently running.
    case notRunning(String)
    /// Pulling the base image for the sandbox rootfs failed. Carries the underlying message.
    case imagePullFailed(String)
    /// Building the provisioned sandbox rootfs (unpack + in-VM provisioning) failed.
    case rootfsBuildFailed(String)
    /// The container VM failed to create/start. Carries the underlying message.
    case startFailed(String)
    /// Couldn't determine the host-gateway address for the container's network interface.
    case gatewayUnavailable

    public var description: String {
        switch self {
        case let .unavailable(why):
            return "Container runtime unavailable: \(why)"
        case let .notRunning(name):
            return "Container \"\(name)\" isn't running."
        case let .imagePullFailed(msg):
            return "Failed to pull the sandbox base image: \(msg)"
        case let .rootfsBuildFailed(msg):
            return "Failed to build the sandbox root filesystem: \(msg)"
        case let .startFailed(msg):
            return "Failed to start the container VM: \(msg)"
        case .gatewayUnavailable:
            return "Couldn't determine the host gateway address for the container."
        }
    }
}

/// A point-in-time CPU/memory reading for a running container, for the Control panel's resource
/// monitor. CPU is the container's aggregate utilization across its cores (0…100); memory is the
/// in-VM view — `total` is the configured memory ceiling the OOM-killer enforces, so `used / total`
/// is exactly "how close to the status-137 OOM kill." Sampled in-process via the framework's typed
/// `statistics()` (two reads around a short sleep for the CPU delta); absent when the container
/// isn't running.
public struct ContainerResourceSample: Sendable, Equatable {
    /// Aggregate CPU utilization over the sample window, clamped to 0…100.
    public let cpuPercent: Double
    /// Resident memory in use inside the container, bytes.
    public let memoryUsedBytes: UInt64
    /// Total memory visible inside the container (≈ the configured limit), bytes.
    public let memoryTotalBytes: UInt64

    public init(cpuPercent: Double, memoryUsedBytes: UInt64, memoryTotalBytes: UInt64) {
        self.cpuPercent = min(100, max(0, cpuPercent))
        self.memoryUsedBytes = memoryUsedBytes
        self.memoryTotalBytes = memoryTotalBytes
    }

    /// Memory utilization 0…100. Zero when the total is unknown (avoids a divide-by-zero).
    public var memoryPercent: Double {
        memoryTotalBytes == 0 ? 0 : min(100, Double(memoryUsedBytes) / Double(memoryTotalBytes) * 100)
    }
}

/// Post-mortem of an abnormal (137 / SIGKILL) agent exit, gathered by probing the container the
/// instant it happens. Distinguishes the three things that all surface as "status 137" but want
/// opposite responses: the whole container went down (→ every session in it dies at once, not an
/// OOM), a genuine kernel OOM kill (the persistent `oom_kill` counter proves it even after memory
/// reads low again), or a SIGKILL while the container is healthy and memory is low (something else
/// killed the process — not memory).
public struct ContainerKillDiagnosis: Sendable, Equatable {
    /// False when the probe couldn't reach the container — it was stopped, restarted, or crashed.
    public let containerRunning: Bool
    /// cgroup `oom_kill` counter (cumulative over the container's life). `> 0` is hard proof the
    /// kernel OOM-killed something here. `nil` when the counter is unavailable.
    public let oomKills: Int?
    /// Memory (and a zeroed CPU) at probe time, if the container answered. Memory can read low even
    /// for a real OOM, because the kill already freed it — hence `oomKills` is the OOM authority.
    public let sample: ContainerResourceSample?

    public init(containerRunning: Bool, oomKills: Int?, sample: ContainerResourceSample?) {
        self.containerRunning = containerRunning
        self.oomKills = oomKills
        self.sample = sample
    }
}

/// In-process container runtime built directly on Apple's `containerization` framework — the same
/// framework Apple's `container` CLI is built on. Replaces the old `container`-CLI wrapper: instead
/// of shelling out to a daemon, this actor owns the runtime itself — one shared
/// `VZVirtualMachineManager` (bundled Linux kernel + vminitd initfs), an `ImageStore` (for pulling
/// the sandbox base image), a `VmnetNetwork` (NAT networking + the host gateway the guest calls
/// back on), and a registry of live `LinuxContainer`s keyed by name.
///
/// **Daemonless ⇒ ephemeral.** There is no external daemon, so every VM's lifetime is bounded by
/// the app process: the registry starts empty each launch and containers are created on demand.
/// `reconcile`/orphan-reaping therefore becomes on-disk GC (there are no surviving VMs to kill).
/// The per-container writable rootfs (a clone of the cached provisioned image) persists on disk, so
/// an idle-stopped or app-restarted container recreates fast and skips re-seeding.
///
/// Lifecycle *policy* (busy ref-counting, idle timers, shared-container naming) lives one layer up
/// in `ContainerManager`; this engine is the mechanism it drives.
///
/// > Note: `containerization` also exposes a type named `ContainerManager`; this is **not** it —
/// > Nucleic's `ContainerManager` is the policy actor, and this engine drives `LinuxContainer`
/// > directly (mirroring how `apple/container`'s `RuntimeService` consumes the framework).
public actor ContainerEngine {
    /// One live container the engine is currently running.
    struct LiveContainer {
        let container: LinuxContainer
        let gateway: String
        /// True when the rootfs clone was freshly created (so seeding must run); false when an
        /// existing clone was reused (idle-restart / relaunch — instrumentation already installed).
        let freshlyCloned: Bool
        /// The VM's memory balloon, when memory management is enabled at create time (else `nil`).
        /// Driven by the engine's autoballoon loop to return idle guest memory to the host.
        let balloon: MemoryBalloon?
        /// The VM's configured memory ceiling in bytes (`spec.memoryGiB`). Kept here so the autoballoon
        /// loop can fully deflate the balloon (target → ceiling) when a `statistics()` read fails,
        /// rather than leaving the guest pinned at a stale, inflated target it can never recover from.
        let memoryCeilingBytes: UInt64
    }

    /// On-disk root for engine artifacts (rootfs cache, per-container clones, image content store).
    let storageRoot: URL
    /// Live containers keyed by name. Empty at launch (nothing survives the app process).
    var live: [String: LiveContainer] = [:]
    /// Last spec seen per name, so `restart` (which only has a name) can recreate from disk.
    var lastSpec: [String: ContainerSpec] = [:]

    /// Held while at least one VM is live, to keep the host app from being App-Napped, automatically
    /// terminated, or suddenly killed while a container is running. The packaged app opts into
    /// `NSSupportsSuddenTermination`/`NSSupportsAutomaticTermination` (see `scripts/package-app.sh`),
    /// so a backgrounded, window-hidden app is a candidate for the OS to quietly quit — and because
    /// the runtime is daemonless (every VM's lifetime is bounded by this process), that takes every
    /// container down with it. This assertion suspends those two opt-ins plus App Nap for as long as a
    /// VM is up, so a container survives the app sitting in the background. `nil` when nothing is live.
    private var backgroundActivity: (any NSObjectProtocol)?

    /// Lazily-built shared VM manager (kernel + vminitd initfs) — one per app run.
    private var vmm: VirtualMachineManager?

    /// The autoballoon driver — one repeating task that retargets every live container's memory
    /// balloon from its working set. Started lazily when the first balloon-enabled container comes
    /// up; cancelled when none remain. `nil` when not running.
    private var balloonLoop: Task<Void, Never>?
    /// Lazily-built image store for pulling the sandbox base image.
    private var imageStore: ImageStore?
    /// Lazily-built NAT network; `var` because `createInterface` mutates it.
    private var network: VmnetNetwork?

    /// The artifact acquisition currently in flight (kernel / init / image pull + unpack), surfaced
    /// to the Control panel's progress bar and the chat's delay hint. `nil` whenever everything is
    /// cached and nothing is downloading — the steady state. Mutated only on the actor; read via
    /// ``currentDownloadProgress()``.
    private var downloadProgress: ContainerDownloadProgress?
    /// Nesting depth of in-flight acquisitions, so the *last* one to finish clears the indicator even
    /// when two containers provision concurrently against the shared on-disk cache.
    private var downloadDepth = 0
    /// Running byte accumulators for the active phase (reset at each ``beginDownload``); the registry
    /// progress handler reports increments, which we fold into these to drive a determinate bar.
    private var downloadReceived: Int64 = 0
    private var downloadTotal: Int64 = 0

    public init(storageRoot: URL? = nil) {
        self.storageRoot = storageRoot ?? Self.defaultStorageRoot()
    }

    /// The vminitd initial-filesystem OCI image — the guest init (PID 1, gRPC over vsock). Pulled
    /// at runtime and materialized to an ext4, rather than bundled or cross-compiled. **Must track
    /// the pinned `containerization` version** (Package.swift) so the host framework and the guest
    /// vminitd speak the same vsock protocol.
    public static let vminitReference = "ghcr.io/apple/containerization/vminit:0.34.0"

    /// In-guest path of the control bridge the init launches when a control socket is relayed in
    /// (loopback TCP → the relayed socket). Shipped by `containers/nucleic-sandbox/Dockerfile`; its
    /// port + socket defaults match `ContainerSpec.controlBridgePort` / `controlSocketGuestPath`.
    static let controlBridgeGuestPath = "/opt/nucleic/control-bridge.js"

    // MARK: - Capability

    /// Whether the in-process container runtime can run on this machine. `true` iff
    /// ``unsupportedReason`` is `nil`.
    public static var isSupported: Bool { unsupportedReason == nil }

    /// A specific, actionable reason the runtime is unavailable, or `nil` when supported. The only
    /// thing this static check gates on is the CPU architecture (Apple silicon). Everything else is
    /// acquired automatically on first use — the kernel, the vminitd initfs, and the sandbox image
    /// all download + cache in the background — so a missing network surfaces as a pull error then,
    /// not as an "unsupported machine". It also can't detect the virtualization *entitlement*
    /// (undetectable here; a missing one surfaces as a `startFailed` when the first VM boots). The
    /// deployment floor (macOS 26) is enforced by the build itself.
    public static var unsupportedReason: String? {
        #if arch(arm64)
        return nil
        #else
        return "It requires Apple silicon."
        #endif
    }

    private static func defaultStorageRoot() -> URL {
        let base = (try? FileManager.default.url(
            for: .applicationSupportDirectory, in: .userDomainMask,
            appropriateFor: nil, create: true))
            ?? URL(fileURLWithPath: NSTemporaryDirectory())
        return base.appendingPathComponent("Nucleic/containers", isDirectory: true)
    }

    // MARK: - Download progress

    /// The artifact acquisition currently in flight, for the Control panel's progress bar and the
    /// chat's "why is this slow" hint. `nil` once every artifact is cached. A trivial property read
    /// (no I/O), so callers can poll it on the snapshot's critical path without risk of a stall.
    public func currentDownloadProgress() -> ContainerDownloadProgress? { downloadProgress }

    /// Mark the start of acquiring `phase`'s artifact and reset its byte accumulators. Paired with
    /// ``endDownload()`` (use `defer`) so the indicator clears even on a thrown pull. Internal (not
    /// private) so the rootfs builder in the extension file can drive the image-pull phase.
    func beginDownload(_ phase: ContainerDownloadProgress.Phase) {
        downloadDepth += 1
        downloadReceived = 0
        downloadTotal = 0
        downloadProgress = ContainerDownloadProgress(phase: phase)
    }

    /// Move the active acquisition to a later stage *under the same* begin/end (e.g. image-pull →
    /// unpack), resetting the byte accumulators so the new stage starts from zero.
    func setDownloadPhase(_ phase: ContainerDownloadProgress.Phase) {
        downloadReceived = 0
        downloadTotal = 0
        downloadProgress = ContainerDownloadProgress(phase: phase)
    }

    /// Fold incremental registry progress events into the current phase's running byte totals and
    /// republish. Called from the `ProgressHandler` the pull APIs invoke as layers stream in.
    func advanceDownload(_ events: [ProgressEvent], phase: ContainerDownloadProgress.Phase) {
        for event in events {
            switch event {
            case .addSize(let v): downloadReceived += v
            case .addTotalSize(let v): downloadTotal += v
            case .addItems, .addTotalItems: break  // item counts aren't surfaced; bytes drive the bar
            }
        }
        downloadProgress = ContainerDownloadProgress(
            phase: phase, receivedBytes: downloadReceived, totalBytes: downloadTotal)
    }

    /// Mark one acquisition done; clears the indicator once the last concurrent one finishes.
    func endDownload() {
        downloadDepth = max(0, downloadDepth - 1)
        if downloadDepth == 0 { downloadProgress = nil }
    }

    // MARK: - Shared runtime (lazy)

    /// Build (once) the shared VM manager from the kernel + vminitd initfs — both acquired
    /// automatically on first use (the kernel is downloaded/reused, the initfs pulled). Mirrors the
    /// wiring `apple/container`'s `RuntimeService` uses: a couple of hardening args appended to the
    /// kernel command line, then `VZVirtualMachineManager(kernel:initialFilesystem:)`. Internal (not
    /// private) so the extension file can reuse the same manager.
    func sharedVMM() async throws -> VirtualMachineManager {
        if let vmm { return vmm }
        guard Self.isSupported else {
            throw ContainerError.unavailable("requires Apple silicon")
        }
        let kernelURL = try await ensureKernel()
        let initfs = try await ensureInitfs()
        var kernel = Kernel(path: kernelURL, platform: .linuxArm)
        // Match the CLI's guest hardening (see apple/container RuntimeService).
        kernel.commandLine.kernelArgs.append("oops=panic")
        kernel.commandLine.kernelArgs.append("lsm=lockdown,capability,landlock,yama,apparmor")
        let vmm = VZVirtualMachineManager(kernel: kernel, initialFilesystem: initfs)
        self.vmm = vmm
        return vmm
    }

    /// Resolve the Linux kernel, acquiring it automatically the first time so users run no setup
    /// step. Resolution order (first hit wins, cached after):
    ///   1. `NUCLEIC_KERNEL_PATH` env (dev override).
    ///   2. The engine's on-disk cache.
    ///   3. A kernel bundled in the app's Resources (optional dev/offline fast-path).
    ///   4. Apple `container` CLI's installed kernel, if present — reused with no download.
    ///   5. Pull the kernel from the `ProjectSandbox.kernelImage` GHCR package — anonymously when the
    ///      package is public, else authenticated with the app's GitHub token (as the sandbox image).
    private func ensureKernel() async throws -> URL {
        if let p = ProcessInfo.processInfo.environment["NUCLEIC_KERNEL_PATH"],
            FileManager.default.fileExists(atPath: p)
        {
            return URL(fileURLWithPath: p)
        }
        let dir = storageRoot.appendingPathComponent("kernel", isDirectory: true)
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        let cacheURL = dir.appendingPathComponent("vmlinux-arm64")
        if FileManager.default.fileExists(atPath: cacheURL.path) { return cacheURL }

        // `NUCLEIC_KERNEL_NO_REUSE` forces the download path, skipping the local fast-paths — for
        // testing the download, or when a local/CLI kernel is present but incompatible.
        if ProcessInfo.processInfo.environment["NUCLEIC_KERNEL_NO_REUSE"] == nil {
            // Bundled (optional) — copy into the cache so the path is stable.
            if let bundled = Bundle.main.url(forResource: "vmlinux-arm64", withExtension: nil),
                (try? FileManager.default.copyItem(at: bundled, to: cacheURL)) != nil
            {
                return cacheURL
            }
            // Opportunistic: reuse Apple `container` CLI's kernel if it's already on disk (no download).
            let cliKernel = FileManager.default.homeDirectoryForCurrentUser
                .appending(path: "Library/Application Support/com.apple.container/kernels/default.kernel-arm64")
                .resolvingSymlinksInPath()
            if FileManager.default.fileExists(atPath: cliKernel.path),
                (try? FileManager.default.copyItem(at: cliKernel, to: cacheURL)) != nil
            {
                return cacheURL
            }
        }
        // Download it (background, one-time, cached). The raw blob GET gives no incremental size,
        // so this phase shows as an indeterminate "Downloading runtime kernel" bar — fine, since the
        // kernel is small next to the sandbox image that follows.
        beginDownload(.kernel)
        defer { endDownload() }
        try await downloadKernel(to: cacheURL)
        return cacheURL
    }

    /// Download the kernel from the `ProjectSandbox.kernelImage` GHCR package to `dest`. The kernel
    /// ships as an OCI artifact (one raw `vmlinux-arm64` blob), so this speaks the registry's
    /// distribution API directly: exchange credentials for a pull token, fetch the manifest, then
    /// fetch the single layer blob. A token is **optional** — when the package is public the pull is
    /// anonymous (the point of shipping the kernel as a package: it can be public while the repo stays
    /// private). When the package is private it authenticates with the same GitHub token used for the
    /// sandbox image. Resolution mirrors `registryAuth`.
    private func downloadKernel(to dest: URL) async throws {
        let (host, repo, tag) = try Self.parseImageRef(ProjectSandbox.kernelImage)

        let env = ProcessInfo.processInfo.environment
        let token: String? = env["NUCLEIC_REGISTRY_TOKEN"].flatMap { $0.isEmpty ? nil : $0 }
            ?? GitHubCredentialStore.loadToken().flatMap { $0.isEmpty ? nil : $0 }
        let owner = repo.split(separator: "/").first.map(String.init)
        let username = env["NUCLEIC_REGISTRY_USER"].flatMap { $0.isEmpty ? nil : $0 }
            ?? ContainerServiceSettings.registryUsername
            ?? owner
            ?? "x-access-token"

        // 1. Exchange credentials for a pull-scoped bearer token (anonymous when none are supplied —
        //    GHCR hands back an anonymous read token for a public package).
        var tokenReq = URLRequest(url: URL(string:
            "https://\(host)/token?service=\(host)&scope=repository:\(repo):pull")!)
        if let token {
            let basic = Data("\(username):\(token)".utf8).base64EncodedString()
            tokenReq.setValue("Basic \(basic)", forHTTPHeaderField: "Authorization")
        }
        let (tokData, tokResp) = try await URLSession.shared.data(for: tokenReq)
        guard (tokResp as? HTTPURLResponse)?.statusCode == 200,
            let tokJSON = try? JSONSerialization.jsonObject(with: tokData) as? [String: Any],
            let bearer = (tokJSON["token"] ?? tokJSON["access_token"]) as? String
        else {
            throw ContainerError.unavailable(token != nil
                ? "could not authenticate to \(host)/\(repo) for the sandbox kernel — check the token's "
                    + "`read:packages` scope, or set NUCLEIC_KERNEL_PATH to a local kernel."
                : "the sandbox kernel package \(host)/\(repo) appears private and no GitHub token is "
                    + "configured. Make the package public, add a token (Settings → Git access, "
                    + "`read:packages`), or set NUCLEIC_KERNEL_PATH to a local kernel.")
        }

        func authed(_ url: URL, accept: String) -> URLRequest {
            var r = URLRequest(url: url)
            r.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization")
            r.setValue(accept, forHTTPHeaderField: "Accept")
            return r
        }

        // 2. Fetch the manifest and resolve the single layer's digest. ORAS pushes a plain image
        //    manifest for a one-file artifact; tolerate a multi-platform index by following its first
        //    entry.
        let manifestAccept = "application/vnd.oci.image.manifest.v1+json,"
            + "application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json"
        func fetchManifest(_ ref: String) async throws -> [String: Any] {
            let url = URL(string: "https://\(host)/v2/\(repo)/manifests/\(ref)")!
            let (data, resp) = try await URLSession.shared.data(for: authed(url, accept: manifestAccept))
            guard (resp as? HTTPURLResponse)?.statusCode == 200,
                let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
            else {
                throw ContainerError.imagePullFailed(
                    "kernel manifest \(repo):\(ref) not found "
                    + "(HTTP \((resp as? HTTPURLResponse)?.statusCode ?? -1))")
            }
            return json
        }
        var manifest = try await fetchManifest(tag)
        if let entries = manifest["manifests"] as? [[String: Any]],
            let first = entries.first?["digest"] as? String
        {
            manifest = try await fetchManifest(first)
        }
        guard let layers = manifest["layers"] as? [[String: Any]],
            let digest = layers.first?["digest"] as? String
        else {
            throw ContainerError.imagePullFailed("kernel package \(repo):\(tag) has no layer to download")
        }

        // 3. Download the layer blob — the raw kernel bytes (URLSession follows the redirect to storage).
        let blobURL = URL(string: "https://\(host)/v2/\(repo)/blobs/\(digest)")!
        let (tmpFile, dlResp) = try await URLSession.shared.download(
            for: authed(blobURL, accept: "application/octet-stream"))
        guard (dlResp as? HTTPURLResponse)?.statusCode == 200 else {
            throw ContainerError.imagePullFailed(
                "kernel blob download failed (HTTP \((dlResp as? HTTPURLResponse)?.statusCode ?? -1))")
        }
        try? FileManager.default.removeItem(at: dest)
        try FileManager.default.moveItem(at: tmpFile, to: dest)
    }

    /// Split a registry reference `host/path…/name[:tag]` into its host, repository (`path…/name`),
    /// and tag (defaulting to `latest`). The host is everything before the first `/`; once it's
    /// stripped, any remaining `:` is the tag separator (a `host:port` colon precedes the first `/`).
    static func parseImageRef(_ ref: String) throws -> (host: String, repo: String, tag: String) {
        guard let firstSlash = ref.firstIndex(of: "/") else {
            throw ContainerError.imagePullFailed("malformed image reference: \(ref)")
        }
        let host = String(ref[..<firstSlash])
        let remainder = String(ref[ref.index(after: firstSlash)...])
        if let colon = remainder.lastIndex(of: ":") {
            let tag = String(remainder[remainder.index(after: colon)...])
            return (host, String(remainder[..<colon]), tag.isEmpty ? "latest" : tag)
        }
        return (host, remainder, "latest")
    }

    /// The vminitd initial filesystem, pulled (once, from `vminitReference`) and materialized to a
    /// cached ext4. Mirrors the framework `ContainerManager`'s own caching: materialize via
    /// `InitImage.initBlock` when the cache is absent, otherwise reconstruct the read-only block
    /// mount over the cached file. No bundling, no cross-compile — just a registry pull.
    private func ensureInitfs() async throws -> Containerization.Mount {
        let dir = storageRoot.appendingPathComponent("initfs", isDirectory: true)
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        let cacheURL = dir.appendingPathComponent("vminit.ext4")
        if FileManager.default.fileExists(atPath: cacheURL.path) {
            return .block(format: "ext4", source: cacheURL.path, destination: "/", options: ["ro"])
        }
        beginDownload(.initfs)
        defer { endDownload() }
        let store = try sharedImageStore()
        let initImage: InitImage
        do {
            initImage = try await store.getInitImage(
                reference: Self.vminitReference,
                progress: { [self] events in await advanceDownload(events, phase: .initfs) })
        } catch {
            throw ContainerError.imagePullFailed("vminit (\(Self.vminitReference)): \(error)")
        }
        return try await initImage.initBlock(at: cacheURL, for: .linuxArm)
    }

    /// Build (once) the image store, content-addressed under the engine's storage root.
    func sharedImageStore() throws -> ImageStore {
        if let imageStore { return imageStore }
        let dir = storageRoot.appendingPathComponent("imagestore", isDirectory: true)
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        let store = try ImageStore(path: dir)
        self.imageStore = store
        return store
    }

    /// HTTP Basic credentials for pulling `reference`, or `nil` for an anonymous pull. Credentials
    /// are scoped to the **registry that hosts the sandbox image** (the host of
    /// `ProjectSandbox.defaultImage`) — so they're never attached to a *different* registry such as
    /// `docker.io`, which pulls public bases anonymously and rejects unrelated credentials (401).
    /// This lets the `nucleic-sandbox` package stay private without breaking public base-image pulls.
    ///
    /// Token precedence (for that host): the `NUCLEIC_REGISTRY_TOKEN` env, else — when the private
    /// host is `ghcr.io` — the app's GitHub token (`GitHubCredentialStore`, needs `read:packages`).
    /// Username: `NUCLEIC_REGISTRY_USER` env, else the configured `registryUsername`, else the
    /// reference's owner segment (GHCR ignores the username when the token is valid).
    func registryAuth(for reference: String) -> (any ContainerizationOCI.Authentication)? {
        let env = ProcessInfo.processInfo.environment
        let segments = reference.split(separator: "/").map(String.init)
        let host = segments.first ?? ""

        // Only authenticate against the registry our private sandbox image lives on.
        let privateHost = ProjectSandbox.defaultImage.split(separator: "/").first.map(String.init) ?? "ghcr.io"
        guard host == privateHost else { return nil }

        let token: String?
        if let t = env["NUCLEIC_REGISTRY_TOKEN"], !t.isEmpty {
            token = t
        } else if privateHost == "ghcr.io", let t = GitHubCredentialStore.loadToken(), !t.isEmpty {
            token = t
        } else {
            token = nil
        }
        guard let token else { return nil }

        let owner = segments.count >= 2 ? segments[1] : nil
        let username = env["NUCLEIC_REGISTRY_USER"].flatMap { $0.isEmpty ? nil : $0 }
            ?? ContainerServiceSettings.registryUsername
            ?? owner
            ?? "x-access-token"
        return BasicAuthentication(username: username, password: token)
    }

    /// Allocate a NAT-mode network interface for a container, returning the interface and the host
    /// gateway address the guest reaches the Mac on (the subnet `.1`). The shared `VmnetNetwork` is
    /// built on first use. Internal (not private) so the rootfs-builder in the extension can use it.
    func makeInterface(for name: String) throws -> (any Interface, String) {
        var net = try network ?? VmnetNetwork(mode: .VMNET_SHARED_MODE)
        guard let iface = try net.createInterface(name) else {
            throw ContainerError.gatewayUnavailable
        }
        self.network = net  // write back the mutation (createInterface is `mutating`)
        guard let gateway = iface.ipv4Gateway?.description else {
            throw ContainerError.gatewayUnavailable
        }
        return (iface, gateway)
    }

    // MARK: - Lifecycle

    /// Ensure the named container is running and return its name plus the host-gateway IP (the
    /// container's view of the Mac, for routing the approval MCP server). Idempotent: a container
    /// already live in the registry is returned as-is; otherwise it is created from a clone of the
    /// cached provisioned rootfs, started, and (when freshly cloned) seeded.
    public func ensureRunning(_ spec: ContainerSpec) async throws
        -> (name: String, hostGateway: String)
    {
        lastSpec[spec.name] = spec
        if let existing = live[spec.name] {
            return (spec.name, existing.gateway)
        }

        let vmm = try await sharedVMM()
        let (rootfs, freshlyCloned) = try await instanceRootfs(for: spec)
        let (iface, gateway) = try makeInterface(for: spec.name)

        let memoryBytes = UInt64(spec.memoryGiB) * 1024 * 1024 * 1024
        // Attach a memory balloon unless the policy is disabled, so the autoballoon loop can return
        // idle guest memory to the host (see MemoryBalloon / ContainerServiceSettings.memoryManagement).
        let balloon: MemoryBalloon? =
            ContainerServiceSettings.memoryManagement.policy.enabled ? MemoryBalloon() : nil
        let container: LinuxContainer
        do {
            container = try LinuxContainer(spec.name, rootfs: rootfs, vmm: vmm) { config in
                // Keep the VM alive for `exec`: a long-lived no-op init process (the agent and all
                // tooling run as subsequent `exec`s, exactly like `container exec` did). When a
                // control socket is relayed in, the init ALSO launches the in-container control
                // bridge (loopback TCP → the relayed socket), backgrounded under init (PID 1) so it
                // lives exactly as long as the container; the root init can reach the root-owned
                // relayed socket while the non-root agent only ever touches loopback TCP.
                if spec.controlSocketHostPath != nil {
                    config.process.arguments = [
                        "/bin/sh", "-c",
                        "node \(Self.controlBridgeGuestPath) & exec /bin/sleep infinity",
                    ]
                } else {
                    config.process.arguments = ["/bin/sleep", "infinity"]
                }
                config.cpus = spec.cpus
                config.memoryInBytes = memoryBytes
                config.interfaces = [iface]
                // NAT DNS lives at the gateway; the agent's tooling needs name resolution.
                config.dns = DNS(nameservers: [gateway])
                // Bind mounts: host dir → guest path at identical paths (virtiofs preserves the
                // host owner, so the agent — run as the host uid via `exec --user` — owns them).
                for mount in spec.mounts {
                    config.mounts.append(
                        .share(
                            source: mount.host, destination: mount.container,
                            options: mount.readOnly ? ["ro"] : []))
                }
                // Relay the per-container control socket in over vsock when set: the host approval
                // server + interceptor endpoint appears in the guest at `controlSocketGuestPath`,
                // so the agent reaches the host with no IP listener (no macOS network prompts). The
                // host side is dialed lazily per guest connection, so the server need only be
                // listening before the agent execs. `permissions` is left default here; the guest
                // socket's access for the non-root agent uid is set when the bridge lands.
                if let controlSocket = spec.controlSocketHostPath {
                    config.sockets = [
                        UnixSocketConfiguration(
                            source: URL(fileURLWithPath: controlSocket),
                            destination: URL(fileURLWithPath: ContainerSpec.controlSocketGuestPath),
                            direction: .into)
                    ]
                }
                if let balloon { config.vmExtensions = [balloon] }  // [vendored patch] attach balloon
            }
            try await container.create()
            try await container.start()
        } catch {
            // The interface was allocated above but the VM never came up, so it'll never be released
            // via `stop` (nothing landed in `live`). Release it here so a retry can re-allocate the
            // same name instead of failing with "allocation with id … already exists".
            try? network?.releaseInterface(spec.name)
            throw ContainerError.startFailed(String(describing: error))
        }

        live[spec.name] = LiveContainer(
            container: container, gateway: gateway, freshlyCloned: freshlyCloned, balloon: balloon,
            memoryCeilingBytes: memoryBytes)
        syncBackgroundActivity()
        if balloon != nil { startBalloonLoopIfNeeded() }

        if freshlyCloned {
            await seed(spec, in: container)
        }
        return (spec.name, gateway)
    }

    /// Run `argv` inside the named container as a fresh `exec`, returning a ``ProcessHandle`` whose
    /// stdio is the guest process's stdio (over vsock). The agent process flows through this — the
    /// backend treats it identically to a host spawn. A unique exec id is generated per call.
    public func exec(
        name: String, workdir: String, env: [String: String], argv: [String],
        uid: Int? = nil, gid: Int? = nil
    ) async throws -> any ProcessHandle {
        guard let live = live[name] else { throw ContainerError.notRunning(name) }

        let (outStream, outCont) = AsyncThrowingStream<Data, Error>.makeStream()
        let (errStream, errCont) = AsyncThrowingStream<Data, Error>.makeStream()
        let stdin = StreamReader()
        let outWriter = StreamWriter(outCont)
        let errWriter = StreamWriter(errCont)
        let execID = "exec-\(UUID().uuidString.prefix(8))"

        let process = try await live.container.exec(execID) { p in
            p.arguments = argv  // argv[0] is the executable
            p.environmentVariables = env.map { "\($0)=\($1)" }
            p.workingDirectory = workdir
            p.user = ContainerizationOCI.User(uid: UInt32(uid ?? 0), gid: UInt32(gid ?? 0))
            p.terminal = false  // pipe mode: keep stdout and stderr separable
            p.stdin = stdin
            p.stdout = outWriter
            p.stderr = errWriter
        }
        try await process.start()
        return ContainerizedProcessHandle(
            process: process, stdin: stdin, stdoutWriter: outWriter, stderrWriter: errWriter,
            stdoutLines: outStream, stderrLines: errStream)
    }

    /// Stop the container's VM (freeing its memory) but keep its on-disk rootfs clone, so the next
    /// `ensureRunning` recreates it fast and skips re-seeding. Drops the registry entry; best-effort.
    public func stop(name: String) async {
        guard let entry = live[name] else { return }
        try? await entry.container.stop()
        live[name] = nil
        // Release the container's vmnet address allocation so the same name can get a fresh
        // interface on the next `ensureRunning`. The allocator keys reservations by container id;
        // without this release it still holds `name`, and the recreate's `createInterface(name)`
        // throws "allocation with id \(name) already exists" — turning every idle-stop / restart
        // into a container that can't be brought back up (e.g. sending a chat after an idle stop).
        try? network?.releaseInterface(name)
        syncBackgroundActivity()
    }

    /// Begin/end the process-activity assertion so it tracks `live`: hold it while any VM is running,
    /// release it once the last one stops. Idempotent — safe to call after every `live` mutation.
    /// Deliberately *allows* idle system sleep: a sleeping Mac suspends and later resumes the VM,
    /// which is fine, and blocking sleep would needlessly drain the battery. It only blocks App Nap
    /// and the app being terminated out from under a live container.
    private func syncBackgroundActivity() {
        if !live.isEmpty, backgroundActivity == nil {
            backgroundActivity = ProcessInfo.processInfo.beginActivity(
                options: [
                    .userInitiatedAllowingIdleSystemSleep, .suddenTerminationDisabled,
                    .automaticTerminationDisabled,
                ],
                reason: "Nucleic sandbox container running")
        } else if live.isEmpty, let activity = backgroundActivity {
            ProcessInfo.processInfo.endActivity(activity)
            backgroundActivity = nil
        }
    }

    /// Reclaim VM memory by stopping then recreating the container in place. The rootfs clone and
    /// its instrumentation persist on disk, so the recreate reuses them (no re-seed). No-op if the
    /// engine never saw this container (no spec cached). Best-effort.
    public func restart(name: String) async {
        guard let spec = lastSpec[name] else {
            await stop(name: name)
            return
        }
        await stop(name: name)
        _ = try? await ensureRunning(spec)
    }

    /// Stop the container and delete its on-disk rootfs clone. Returns `true` once it's gone
    /// (including when it never existed). Best-effort.
    @discardableResult
    public func remove(name: String) async -> Bool {
        await stop(name: name)
        lastSpec[name] = nil
        let clone = instanceRootfsURL(for: name)
        try? FileManager.default.removeItem(at: clone)
        return !FileManager.default.fileExists(atPath: clone.path)
    }

    /// True if the named container is currently running in this process.
    public func isRunning(_ name: String) async -> Bool {
        live[name] != nil
    }

    /// True if the container is running, or has a persisted rootfs clone on disk (stopped but
    /// recreatable without a rebuild).
    public func containerExists(_ name: String) async -> Bool {
        live[name] != nil || FileManager.default.fileExists(atPath: instanceRootfsURL(for: name).path)
    }

    /// Names of all containers currently running in this process.
    public func list() async -> [String] {
        Array(live.keys)
    }

    // MARK: - Resource usage

    /// Sample the container's live CPU and memory for the Control panel's resource monitor. CPU is a
    /// real delta: two `statistics()` reads around a ~200 ms sleep, normalized across the container's
    /// cores to 0…100. Best-effort: `nil` when the container isn't running or the probe fails.
    public func sampleResourceUsage(name: String) async -> ContainerResourceSample? {
        guard let entry = live[name] else { return nil }
        guard let s1 = try? await entry.container.statistics() else { return nil }
        let t1 = DispatchTime.now().uptimeNanoseconds
        try? await Task.sleep(nanoseconds: 200_000_000)
        guard let s2 = try? await entry.container.statistics() else { return nil }
        let t2 = DispatchTime.now().uptimeNanoseconds

        // `cpu.usageUsec` is cumulative CPU time across all cores. Busy fraction over the window,
        // normalized by the core count, gives an aggregate 0…100 utilization. Divide by the ACTUAL
        // elapsed wall-clock (the sleep plus scheduling/await latency), not the requested 200 ms —
        // under load the real interval is longer, and assuming 200 ms over-reads (a true 60% can
        // show as 100%). Guard against a zero/negative interval.
        let windowUsec = max(1.0, Double(t2 &- t1) / 1000.0)
        let cores = Double(max(1, entry.container.cpus))
        let deltaUsec = max(0, Double(s2.cpu?.usageUsec ?? 0) - Double(s1.cpu?.usageUsec ?? 0))
        let cpuPercent = deltaUsec / (windowUsec * cores) * 100
        return ContainerResourceSample(
            cpuPercent: cpuPercent,
            memoryUsedBytes: s2.memory?.usageBytes ?? 0,
            memoryTotalBytes: s2.memory?.limitBytes ?? 0)
    }

    // MARK: - Automatic memory reclamation (balloon)

    /// Start the autoballoon driver if it isn't already running. Idempotent.
    private func startBalloonLoopIfNeeded() {
        guard balloonLoop == nil else { return }
        balloonLoop = Task { [weak self] in
            while !Task.isCancelled {
                guard let self else { return }
                let nextDelaySeconds = await self.adjustBalloons()
                if nextDelaySeconds == 0 { return }  // nothing left to drive — stop
                try? await Task.sleep(nanoseconds: nextDelaySeconds * 1_000_000_000)
            }
        }
    }

    /// One autoballoon pass: under the current memory-management policy, retarget every live
    /// container's balloon from its live working set. Returns the seconds to wait before the next
    /// pass, or `0` to stop the loop (no live containers — it restarts on the next `ensureRunning`).
    /// The target always leaves headroom over the working set (never inflates below resident memory,
    /// which would force guest reclaim/OOM) and never drops below the policy floor.
    private func adjustBalloons() async -> UInt64 {
        if live.isEmpty {
            balloonLoop = nil
            return 0
        }
        let policy = ContainerServiceSettings.memoryManagement.policy
        // Policy is read each pass, so it can change live. A disabled policy idle-polls so a later
        // change resumes without needing a container restart.
        guard policy.enabled else { return 30 }
        for entry in live.values {
            guard let balloon = entry.balloon else { continue }
            // A failed/empty stats read must NEVER leave the balloon at a stale inflated target —
            // that pins the guest below its footprint with no way out (the non-recovering freeze).
            // Fully deflate (target → the configured ceiling) so the guest gets all its memory back,
            // then try again next pass.
            guard let stats = try? await entry.container.statistics(),
                let used = stats.memory?.usageBytes, used > 0,
                let ceiling = stats.memory?.limitBytes, ceiling > 0
            else {
                balloon.setTarget(entry.memoryCeilingBytes, hysteresis: 0)
                continue
            }
            let target = policy.target(workingSetBytes: used, ceilingBytes: ceiling)
            balloon.setTarget(target, hysteresis: policy.hysteresisBytes)
        }
        return max(1, policy.cadenceSeconds)
    }

    /// Force an immediate balloon pass (the "Reclaim memory now" action), so reclamation doesn't
    /// wait for the next scheduled tick. No-op when ballooning is off.
    public func reclaimMemoryNow() async {
        _ = await adjustBalloons()
    }

    /// Post-mortem probe the moment an agent exits 137/SIGKILL, to tell apart the causes that all
    /// look like "status 137". One typed `statistics()` read: the cgroup `oom_kill` counter is the
    /// persistent, authoritative count of kernel OOM kills (it survives the kill). If the container
    /// is no longer in the registry — or `statistics()` fails — that *is* the finding (it went down,
    /// taking every session with it), reported as `containerRunning: false`.
    public func diagnoseKill(name: String) async -> ContainerKillDiagnosis {
        guard let entry = live[name], let stats = try? await entry.container.statistics() else {
            return ContainerKillDiagnosis(containerRunning: false, oomKills: nil, sample: nil)
        }
        var sample: ContainerResourceSample?
        if let mem = stats.memory {
            sample = ContainerResourceSample(
                cpuPercent: 0, memoryUsedBytes: mem.usageBytes, memoryTotalBytes: mem.limitBytes)
        }
        return ContainerKillDiagnosis(
            containerRunning: true,
            oomKills: stats.memoryEvents.map { Int($0.oomKill) },
            sample: sample)
    }
}
