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 {
    private let engine: ContainerEngine

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

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

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

    /// 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; `-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"
        #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", "-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

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

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

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

    /// 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"
        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`/`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
        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 {
        try await engine.exec(
            name: physical(name), workdir: workdir, env: env, argv: argv, uid: uid, gid: gid)
    }

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

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

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

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

    /// 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 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)
            await engine.restart(name: physical(name))  // same name — reuses the clone
        }
    }

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

    // MARK: - Idle timer

    private func armIdleTimer(_ name: String) {
        cancelIdleTimer(name)
        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
        await engine.stop(name: physical(name))  // keep the mapping — idle-restart reuses the clone
    }
}
