import Foundation

// MARK: - Project domain model (RUNTIME §4, PLAN "Core domain model")
// ProjectID moved to NucleicProtocol/CoreIdentifiers.swift (shared with the iOS client).

/// A git ref: a branch name, tag, or commit-ish. A thin wrapper so call sites read
/// intentionally (`GitRef("main")`) rather than passing bare strings everywhere.
public struct GitRef: Hashable, Sendable, Codable, CustomStringConvertible, ExpressibleByStringLiteral {
    public let value: String
    public init(_ value: String) { self.value = value }
    public init(stringLiteral value: String) { self.value = value }
    public var description: String { value }
}

/// Setup-script failure policy (WORKTREE_MANAGER §5).
public enum SetupPolicy: String, Sendable, Codable {
    /// Session can't start if setup fails (default — don't run agents against a broken env).
    case block
    /// Proceed but flag the failure.
    case warn
}

/// Per-project execution-sandbox settings. When enabled, the project's sessions run
/// `claude` inside an isolated Linux VM (built on Apple's `containerization` framework) instead of
/// on the host, with the worktree bind-mounted (CONTAINER_SANDBOX). `nil` on a Project means
/// "off" — the default, host-spawned behavior.
public struct ProjectSandbox: Sendable, Codable, Equatable {
    public var enabled: Bool
    /// Image reference to run. `nil` → the bundled default image (built on first use).
    public var image: String?
    /// Stop the per-session container after this many seconds of inactivity; the next
    /// turn transparently restarts it.
    public var idleTimeoutSeconds: Int
    /// Allow the sandboxed agent to build and run executables on the *host* machine via the
    /// `host_exec` MCP tool, escaping the Linux container (e.g. to compile/run a macOS binary
    /// the container can't). On by default for newly configured sandboxes; every host command
    /// is approval-gated and is never auto-approved (HOST_EXEC). Only meaningful when `enabled`
    /// is true. (Sandboxes persisted before this field existed still decode to `false` below.)
    public var allowHostExec: Bool

    /// Per-session sandbox containers. When false (the default), a Nucleic Control project's
    /// sessions all share Nucleic's single **primary** managed container; when true, each
    /// session gets its own container (today's per-session model). The choice is fixed at
    /// project creation. NOT YET USER-SETTABLE — surfaced as a disabled placeholder toggle in
    /// a control project's Settings; the creation-time migration that lets a project opt into
    /// per-session containers is deferred. Only meaningful for Nucleic Control projects.
    public var perSessionContainers: Bool

    /// The default sandbox image, used when `image` is nil — a **registry reference** to the
    /// prebuilt `nucleic-sandbox` image (built in CI from `containers/nucleic-sandbox/Dockerfile`
    /// and pushed to GHCR; must be public for anonymous pulls). `ContainerEngine` pulls + unpacks it
    /// and caches the rootfs by this exact ref, so bump the version tag whenever the Dockerfile
    /// changes — the new tag has no cache yet, so the next session pulls it fresh and launch-time
    /// reconcile prunes the superseded cache. (`v2` added the cross-language build tools:
    /// build-essential/make, python3/pip. `v3` added the GitHub CLI `gh` plus `curl`. `v4` added the
    /// Codex + Grok CLIs and `control-bridge.js` — the latter is REQUIRED by the now-default vsock
    /// control plane, so `v4` must ship before `vsockControlPlaneEnabled` defaults on. `v5` added
    /// `openssh-client` for `ssh-keygen`, so the agent can sign commits with `gpg.format=ssh`.)
    /// Keep in lockstep with `.github/workflows/sandbox-image.yml`'s `IMAGE_TAG`.
    public static let defaultImage = "ghcr.io/abkslm/nucleic-sandbox:v5"

    /// Where the VM's Linux kernel is fetched from when it isn't already on disk. Published as a GHCR
    /// **package** — an OCI artifact carrying the single `vmlinux-arm64` blob, pushed by
    /// `.github/workflows/kernel-image.yml` (NOT a GitHub release asset, which would inherit the repo's
    /// visibility). A GHCR package's visibility is independent of the repo's, so this can be made
    /// **public for anonymous pulls** while the repo stays private — exactly like the sandbox image
    /// (`defaultImage`). When the package is private the app authenticates the pull with the user's
    /// GitHub token (`read:packages`), host-scoped by the same `ContainerEngine.registryAuth` rule.
    /// `ContainerEngine` first reuses a local/CLI kernel if present, so machines with Apple's
    /// `container` installed download nothing. Bump the tag when the kernel version changes (and
    /// publish the matching package).
    public static let kernelImage = "ghcr.io/abkslm/nucleic-kernel:6.18.15-186"

    public static let defaultIdleTimeoutSeconds = 900

    public init(
        enabled: Bool = false,
        image: String? = nil,
        idleTimeoutSeconds: Int = ProjectSandbox.defaultIdleTimeoutSeconds,
        allowHostExec: Bool = true,
        perSessionContainers: Bool = false
    ) {
        self.enabled = enabled
        self.image = image
        self.idleTimeoutSeconds = idleTimeoutSeconds
        self.allowHostExec = allowHostExec
        self.perSessionContainers = perSessionContainers
    }

    // Tolerant decode: rows persisted before `allowHostExec` / `perSessionContainers` existed
    // decode with the default rather than failing the whole blob — the store decodes sandbox
    // config with `try?`, so a strict miss would silently drop the entire sandbox config.
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false
        image = try c.decodeIfPresent(String.self, forKey: .image)
        idleTimeoutSeconds =
            try c.decodeIfPresent(Int.self, forKey: .idleTimeoutSeconds)
            ?? ProjectSandbox.defaultIdleTimeoutSeconds
        allowHostExec = try c.decodeIfPresent(Bool.self, forKey: .allowHostExec) ?? false
        perSessionContainers =
            try c.decodeIfPresent(Bool.self, forKey: .perSessionContainers) ?? false
    }

    /// The image actually used to run containers (resolved override or bundled default).
    public var resolvedImage: String {
        if let image, !image.trimmingCharacters(in: .whitespaces).isEmpty { return image }
        return ProjectSandbox.defaultImage
    }
}

/// Per-project **nvrsion** settings (NVRSION, Beta). When active a control project's sessions
/// share a single `nucleic/trunk` checkout and land each edit into it immediately — instead of
/// each session forking an isolated worktree/branch held until ship (LOCKING). `nil` on a Project
/// (or `enabled == false`) means off — the default, classic per-session-worktree behavior.
///
/// Persisted as `nvrsion_config` JSON on the `project` row (mirrors `ProjectSandbox`), so new
/// fields are additive without a migration. Only meaningful for Nucleic Control projects that use
/// the shared control container — see `Project.nvrsionActive` (NVRSION §10).
public struct ProjectNvrsion: Sendable, Codable, Equatable {
    /// Master switch for the mode. Default off (Beta, opt-in).
    public var enabled: Bool
    /// The shared trunk branch all the project's nvrsion sessions edit and land into.
    public var trunkBranch: String
    /// Optional fast validation command run after an edit is written and **before** it lands in
    /// trunk (NVRSION §5). Receives the edited paths; non-zero exit rejects the edit back to the
    /// agent (kept warm, not committed). `nil`/empty → land immediately (the default).
    public var prelandHook: String?
    /// Keep-warm idle window in seconds (NVRSION §4): a held file untouched this long is released
    /// mid-turn so a waiter can take it. Lower = faster hand-off, more re-reads.
    public var keepWarmIdleSeconds: Int

    public static let defaultTrunkBranch = "nucleic/trunk"
    public static let defaultKeepWarmIdleSeconds = 4

    public init(
        enabled: Bool = false,
        trunkBranch: String = ProjectNvrsion.defaultTrunkBranch,
        prelandHook: String? = nil,
        keepWarmIdleSeconds: Int = ProjectNvrsion.defaultKeepWarmIdleSeconds
    ) {
        self.enabled = enabled
        self.trunkBranch = trunkBranch
        self.prelandHook = prelandHook
        self.keepWarmIdleSeconds = keepWarmIdleSeconds
    }

    // Tolerant decode: rows persisted before a field existed decode to its default rather than
    // failing the whole blob (the store decodes with `try?`, so a strict miss drops the config).
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false
        trunkBranch =
            try c.decodeIfPresent(String.self, forKey: .trunkBranch).flatMap {
                $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0
            } ?? ProjectNvrsion.defaultTrunkBranch
        prelandHook = try c.decodeIfPresent(String.self, forKey: .prelandHook)
        keepWarmIdleSeconds =
            try c.decodeIfPresent(Int.self, forKey: .keepWarmIdleSeconds)
            ?? ProjectNvrsion.defaultKeepWarmIdleSeconds
    }

    /// The pre-land hook to actually run, or `nil` when unset/blank (→ land immediately).
    public var resolvedPrelandHook: String? {
        guard let h = prelandHook?.trimmingCharacters(in: .whitespaces), !h.isEmpty else { return nil }
        return h
    }
}

/// App-wide container preferences, persisted in `UserDefaults`. These gate and seed the
/// *per-project* `ProjectSandbox` config above — they never sandbox sessions on their own.
/// Surfaced in Settings → General; read here so `NucleicCore` (e.g. `SessionController`,
/// `AppStore`) and the SwiftUI layer agree on the same keys.
/// How a Nucleic Control container session authenticates `claude`.
public enum ControlAuthMode: String, CaseIterable, Sendable {
    /// Reuse the host's Claude subscription login — the macOS Keychain OAuth credentials,
    /// exported into the per-session claude-home as `.credentials.json`. The default.
    case oauth
    /// Use a user-supplied Anthropic API key (stored in the Keychain via `ControlAPIKeyStore`),
    /// forwarded as `ANTHROPIC_API_KEY`. For when the host OAuth login doesn't work in the
    /// container, or the user would rather bill an API key.
    case apiKey
}

/// How aggressively a running container's VM hands unused memory back to the host. The engine
/// attaches a virtio memory balloon to each VM and periodically retargets it from the guest's live
/// working set; this level chooses how much headroom to leave, how low it may shrink, and how often
/// it adjusts — single descriptive words rather than raw numeric knobs (those live in
/// ``BalloonPolicy``). Replaces the old "the VM never returns freed memory; restart to reclaim"
/// limitation with automatic reclamation.
public enum MemoryManagementLevel: String, CaseIterable, Sendable {
    /// The default — reclaim gently: generous headroom, a high floor, slow cadence. Safest against
    /// in-guest memory pressure; frees the least.
    case conservative
    /// Reclaim steadily with a comfortable safety margin.
    case balanced
    /// Reclaim tightly and react quickly — frees the most host memory, likeliest to squeeze a
    /// spiky workload.
    case aggressive

    /// User-facing label (single word, capitalized).
    public var label: String { rawValue.capitalized }

    /// The concrete autoballoon parameters this level maps to.
    public var policy: BalloonPolicy {
        switch self {
        case .conservative:
            return BalloonPolicy(
                enabled: true, headroom: 2.0, reserveBytes: 3 * 1_073_741_824 / 2,
                floorBytes: 2 * 1_073_741_824, cadenceSeconds: 30, hysteresisBytes: 256 * 1_048_576)
        case .balanced:
            return BalloonPolicy(
                enabled: true, headroom: 1.5, reserveBytes: 1_073_741_824,
                floorBytes: 1_073_741_824, cadenceSeconds: 10, hysteresisBytes: 128 * 1_048_576)
        case .aggressive:
            return BalloonPolicy(
                enabled: true, headroom: 1.25, reserveBytes: 3 * 1_073_741_824 / 4,
                floorBytes: 512 * 1_048_576, cadenceSeconds: 5, hysteresisBytes: 64 * 1_048_576)
        }
    }
}

/// The concrete memory-balloon parameters a ``MemoryManagementLevel`` maps to. A pure value type so
/// the target math is unit-testable without booting a VM.
public struct BalloonPolicy: Sendable, Equatable {
    /// `false` means no balloon is attached and memory is never reclaimed. Every current
    /// ``MemoryManagementLevel`` enables it; the flag remains so a policy can still be built disabled.
    public let enabled: Bool
    /// Multiplier applied to the working set when picking the target (>1 leaves slack so the guest
    /// isn't squeezed the instant it allocates again). Always ≥ 1, so the target never drops below
    /// the working set — inflating a balloon below resident memory would force guest reclaim/OOM.
    public let headroom: Double
    /// Absolute bytes always kept available **on top of** the working set. The working set we measure
    /// is the per-*container* cgroup figure, but the target is applied to the *whole VM*, which also
    /// holds the guest kernel, vminitd, the init, the virtio overhead and the virtiofs page cache —
    /// none of which are in that cgroup. Reserving a fixed whole-VM margin keeps the balloon from
    /// inflating below the guest's true footprint (the bug that drove a swapless guest into a
    /// direct-reclaim CPU spin). Sized to comfortably cover the kernel + vminitd + overhead + cache.
    public let reserveBytes: UInt64
    /// Never shrink the guest below this many bytes, however idle it looks — a floor against
    /// thrashing the page cache or starving the kernel.
    public let floorBytes: UInt64
    /// Seconds between adjustment passes.
    public let cadenceSeconds: UInt64
    /// Only move the target when it changes by at least this much, to avoid churning the balloon.
    public let hysteresisBytes: UInt64

    public init(
        enabled: Bool, headroom: Double, reserveBytes: UInt64, floorBytes: UInt64,
        cadenceSeconds: UInt64, hysteresisBytes: UInt64
    ) {
        self.enabled = enabled
        self.headroom = headroom
        self.reserveBytes = reserveBytes
        self.floorBytes = floorBytes
        self.cadenceSeconds = cadenceSeconds
        self.hysteresisBytes = hysteresisBytes
    }

    /// Desired balloon target (guest memory in bytes) for a live container: leave the larger of a
    /// `headroom` multiple of the working set and the working set plus a fixed whole-VM `reserveBytes`
    /// margin — but never below `floorBytes` nor above the VM's configured `ceilingBytes`. The
    /// additive reserve is what keeps the whole-VM target above the guest's real footprint when the
    /// cgroup working set is small (where a pure multiplier would leave too little for the kernel/
    /// page-cache and force reclaim).
    public func target(workingSetBytes: UInt64, ceilingBytes: UInt64) -> UInt64 {
        let scaledDouble = Double(workingSetBytes) * headroom
        // Saturating cast: a huge product clamps to ceiling below anyway.
        let scaled = scaledDouble >= Double(UInt64.max) ? UInt64.max : UInt64(scaledDouble)
        let reserved = workingSetBytes.addingReportingOverflow(reserveBytes)
        let withReserve = reserved.overflow ? UInt64.max : reserved.partialValue
        return min(max(max(scaled, withReserve), floorBytes), ceilingBytes)
    }
}

public enum ContainerServiceSettings {
    /// Master switch. Off → the per-project "run sessions in a sandbox container" option is
    /// unavailable and no session runs in a container, regardless of its stored setting.
    /// Turning it on only enables the *ability* to opt a project in; it does not sandbox
    /// anything by itself. Off by default — the container runtime adds 2+ GB of memory
    /// overhead per running session.
    public static let serviceEnabledKey = "nucleic.container.serviceEnabled"

    /// When on, newly added projects start with sandboxing enabled. Only takes effect while
    /// the container service (above) is also on. Off by default.
    public static let sandboxByDefaultKey = "nucleic.container.sandboxByDefault"

    /// When on, newly *cloned* projects default to Nucleic Control (cloned under
    /// `~/.nucleic/control/`, managed by Nucleic — shared sandbox, git interceptor,
    /// autoship-eligible). Off by default. The Add-Project sheet seeds its Nucleic Control
    /// switch from this.
    public static let controlByDefaultKey = "nucleic.container.controlByDefault"

    /// When on, new Nucleic Control projects start with **nvrsion** enabled (NVRSION) — the
    /// shared-trunk versioning mode. Beta, off by default. Only meaningful for Control projects with
    /// the container service on; a per-project toggle still overrides it.
    public static let nvrsionByDefaultKey = "nucleic.container.nvrsionByDefault"

    /// CPU count and memory (GiB) for Nucleic container VMs, split into two pools: the shared
    /// primary Nucleic Control container serves *every* control session at once (so it wants
    /// more), while a per-session container serves one. Applied via `container run --cpus N
    /// --memory Ng`; `0`/unset → the sensible defaults. (`container`'s own defaults are 4 / 1 GiB.)
    public static let controlContainerCPUsKey = "nucleic.container.control.cpus"
    public static let controlContainerMemoryGiBKey = "nucleic.container.control.memoryGiB"
    public static let sessionContainerCPUsKey = "nucleic.container.session.cpus"
    public static let sessionContainerMemoryGiBKey = "nucleic.container.session.memoryGiB"
    /// Per-session defaults (one session per VM) — also the `ContainerSpec` fallback.
    public static let defaultContainerCPUs = 4
    public static let defaultContainerMemoryGiB = 4
    /// Shared primary control-container defaults (serves every control session) — more generous.
    /// The memory constant is the documented floor; the *effective* default is host-aware (see
    /// `recommendedControlContainerMemoryGiB`).
    public static let defaultControlContainerCPUs = 8
    public static let defaultControlContainerMemoryGiB = 8

    /// Username for pulling the sandbox image from a private registry. The image is pulled with
    /// HTTP Basic credentials = (this username, a token). On GHCR the username can be the GitHub
    /// account (or any non-empty value with a valid token); when empty, the engine falls back to the
    /// image-reference owner (e.g. `abkslm` from `ghcr.io/abkslm/…`). The token itself is the
    /// app's GitHub token (`GitHubCredentialStore`, needs `read:packages` for GHCR) unless overridden
    /// by the `NUCLEIC_REGISTRY_USER`/`NUCLEIC_REGISTRY_TOKEN` env. Public images (incl. Apple's
    /// vminitd) are pulled anonymously regardless.
    public static let registryUsernameKey = "nucleic.container.registry.username"

    /// Configured private-registry username (UserDefaults), or `nil`/empty when unset.
    public static var registryUsername: String? {
        let v = (UserDefaults.standard.string(forKey: registryUsernameKey) ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return v.isEmpty ? nil : v
    }

    /// Default memory (GiB) for the shared Nucleic Control container: the host's *total* physical
    /// RAM. It serves every Control session's `claude` process at once, so a fixed 8 GiB is the
    /// most OOM-prone limit in the app — the in-VM Linux OOM-killer SIGKILLs a process the moment
    /// the combined RSS hits the ceiling ("exited with status 137"). `--memory` is a *ceiling*, not
    /// a reservation: Apple's `container` VM only consumes what it actually uses, so setting it to
    /// the full system RAM just stops the in-VM OOM-killer from firing prematurely. The trade-off
    /// (Apple's docs): the VM doesn't relinquish freed pages to the host while running, so a
    /// long-lived busy container's footprint only ratchets up — reclaim it with `restartShared`
    /// (Settings → Control → "Restart container"). The default argument reads the live host, so
    /// call sites use `()`; tests pass `physicalBytes` explicitly.
    public static func recommendedControlContainerMemoryGiB(
        physicalBytes: UInt64 = ProcessInfo.processInfo.physicalMemory
    ) -> Int {
        let physGiB = Int(physicalBytes / 1_073_741_824)
        // = total system memory; fall back to the documented floor only on a bogus (0) reading.
        return physGiB > 0 ? physGiB : defaultControlContainerMemoryGiB
    }

    /// Whether the container service is enabled app-wide.
    public static var serviceEnabled: Bool {
        UserDefaults.standard.bool(forKey: serviceEnabledKey)
    }

    /// Whether new projects should default to sandboxing on. Only meaningful — and only
    /// reported true — while the container service is enabled.
    public static var sandboxByDefault: Bool {
        serviceEnabled && UserDefaults.standard.bool(forKey: sandboxByDefaultKey)
    }

    /// Whether newly cloned projects default to Nucleic Control. Nucleic Control requires the
    /// container service (its whole point is the shared sandbox container + git interceptor), so
    /// this is only reported true while the service is on.
    public static var controlByDefault: Bool {
        serviceEnabled && UserDefaults.standard.bool(forKey: controlByDefaultKey)
    }

    /// Whether new Nucleic Control projects should start with nvrsion on (NVRSION). Beta; only
    /// reported true while the container service is enabled (nvrsion needs the shared control
    /// container). A per-project setting still overrides this default.
    public static var nvrsionByDefault: Bool {
        serviceEnabled && UserDefaults.standard.bool(forKey: nvrsionByDefaultKey)
    }

    /// A stored integer for `key`, clamped to ≥ 1, falling back when unset/invalid.
    private static func resource(_ key: String, default fallback: Int) -> Int {
        let v = UserDefaults.standard.integer(forKey: key)
        return v > 0 ? v : fallback
    }

    /// CPUs / memory (GiB) for the shared primary Nucleic Control container.
    public static var controlContainerCPUs: Int {
        resource(controlContainerCPUsKey, default: defaultControlContainerCPUs)
    }
    public static var controlContainerMemoryGiB: Int {
        // Unset → the host-aware recommendation (not the flat floor), so the OOM-prone shared
        // container gets headroom by default on capable hosts.
        resource(controlContainerMemoryGiBKey, default: recommendedControlContainerMemoryGiB())
    }

    /// CPUs / memory (GiB) for a per-session container.
    public static var sessionContainerCPUs: Int {
        resource(sessionContainerCPUsKey, default: defaultContainerCPUs)
    }
    public static var sessionContainerMemoryGiB: Int {
        resource(sessionContainerMemoryGiBKey, default: defaultContainerMemoryGiB)
    }

    /// How Nucleic Control container sessions authenticate `claude` — the host subscription
    /// login (OAuth, default) or a user-supplied API key (`ControlAPIKeyStore`). App-wide for
    /// now; a per-project override is planned (see the hint in project settings).
    public static let controlAuthModeKey = "nucleic.container.control.authMode"

    public static var controlAuthMode: ControlAuthMode {
        ControlAuthMode(rawValue: UserDefaults.standard.string(forKey: controlAuthModeKey) ?? "")
            ?? .oauth
    }

    /// When on, each agent family gets its *own* shared Nucleic Control container instead of all
    /// piling into the one `nucleic-control`. The model families can otherwise turn competitive in
    /// a shared sandbox — killing each other's processes ("agenticide") — so this isolates them into
    /// `nucleic-control-claude` (Claude), `nucleic-control-codex` (GPT/Codex), and
    /// `nucleic-control-xai` (xAI/Grok). Off by default; only consulted for shared-container control
    /// sessions (per-session containers are already isolated).
    /// See `ContainerManager.sharedContainerName(for:split:)`.
    public static let splitControlContainersByBackendKey = "nucleic.container.control.splitByBackend"

    public static var splitControlContainersByBackend: Bool {
        UserDefaults.standard.bool(forKey: splitControlContainersByBackendKey)
    }

    /// When on, a shared Nucleic Control container's control plane (the approval MCP server + the
    /// git/gh/command interceptor endpoint) runs over a **vsock-relayed unix socket** instead of
    /// TCP/HTTP on the VM gateway — so macOS raises no incoming-connection / local-network prompts.
    /// The agent and the interceptor shims reach the host through an in-container loopback bridge
    /// that forwards to the relayed socket (see `docs/VSOCK_CONTROL_PLANE.md`). **On by default** as
    /// of sandbox image `v4` (which ships `control-bridge.js`); set the key to `false` to fall back to
    /// the legacy gateway-TCP path. Because it requires the bridge in the image, the default must move
    /// in lockstep with `ProjectSandbox.defaultImage` being a bridge-bearing tag (≥ `v4`).
    public static let vsockControlPlaneEnabledKey = "nucleic.container.vsockControlPlane"

    /// Defaults to `true` when unset (so a fresh install gets the vsock control plane); an explicit
    /// stored `false` still wins. `object(forKey:)` distinguishes "unset" from a stored `false`,
    /// which `UserDefaults.bool(forKey:)` (always `false` when absent) cannot.
    public static var vsockControlPlaneEnabled: Bool {
        UserDefaults.standard.object(forKey: vsockControlPlaneEnabledKey) as? Bool ?? true
    }

    /// Whether the in-container **bash command tracer** is active. The tracer installs a `DEBUG`/
    /// `EXIT` trap (sourced via `BASH_ENV`) that records EVERY command the agent runs inside a Bash
    /// tool call and batch-posts the metadata to the GUI command feed. Because the `DEBUG` trap fires
    /// before every simple command, it adds per-command shell overhead during build/test-heavy turns
    /// — so it is **opt-in (default off)**. This gates only the command tracer: the git/gh
    /// interception the conflict-detection / merge-queue / autoship system relies on is always on.
    /// Gated at the env level (the `BASH_ENV`/command-hook vars are injected only when this is on),
    /// so toggling takes effect for agent turns started after the change — no container rebuild.
    public static let commandTracingEnabledKey = "nucleic.container.commandTracing"

    public static var commandTracingEnabled: Bool {
        UserDefaults.standard.bool(forKey: commandTracingEnabledKey)
    }

    /// How aggressively running container VMs reclaim unused memory to the host (via each VM's
    /// virtio memory balloon, driven by `ContainerEngine`'s autoballoon loop). Defaults to
    /// `.conservative`.
    ///
    /// Conservative by default — the gentlest reclaiming level (generous headroom, a high floor, slow
    /// cadence). Ballooning was historically off out of the box because the balloon's target is
    /// derived from the per-**container** cgroup working set but applied to the **whole VM**
    /// (`targetVirtualMachineMemorySize`), which also hosts the guest kernel, vminitd, init and the
    /// virtiofs page cache. With no guest swap, a turn that allocated past the (cadence-stale) target
    /// sent the guest into a perpetual direct-reclaim spin — a guest vCPU pegged at ~100% and the
    /// agent wedged mid-allocation (output froze and never recovered, ending in OOM-137).
    /// ``BalloonPolicy/target(workingSetBytes:ceilingBytes:)`` now reserves a whole-VM margin so every
    /// level clears the guest's true footprint, so the default reclaims conservatively rather than not
    /// at all. Unset/invalid → the default.
    public static let memoryManagementKey = "nucleic.container.memoryManagement"

    public static var memoryManagement: MemoryManagementLevel {
        MemoryManagementLevel(rawValue: UserDefaults.standard.string(forKey: memoryManagementKey) ?? "")
            ?? .conservative
    }
}

/// A registered git repository plus per-project configuration.
public struct Project: Identifiable, Sendable, Codable, Equatable {
    public let id: ProjectID
    public var name: String
    public var rootPath: String
    public var defaultBranch: GitRef
    /// The branch autoship merges this project's sessions into. `nil` → the project root
    /// repo's `defaultBranch` (the default). A per-session override (`Session.shipBranch`)
    /// takes precedence over this. See `resolvedAutoShipBranch`.
    public var autoShipBranch: GitRef?
    public var defaultBackend: BackendID?
    /// Override for where session worktrees are created. `nil` → the default
    /// sibling dir on the same volume (WORKTREE_MANAGER §2).
    public var worktreeBase: String?
    public var setupScript: String?
    public var setupPolicy: SetupPolicy
    /// Execution-sandbox settings. `nil` → sessions run on the host (default).
    public var sandbox: ProjectSandbox?
    /// nvrsion (Beta) settings. `nil`/`enabled == false` → classic per-session-worktree behavior
    /// (the default). See `nvrsionActive` for the full gate (NVRSION §10).
    public var nvrsion: ProjectNvrsion?
    public var createdAt: Date
    /// When the project was archived, or `nil` if it's active. Archiving hides the project
    /// from the active list and stops its live sessions, but leaves all files and records in
    /// place so it can be restored (see `AppStore.setProjectArchived`).
    public var archivedAt: Date?

    public init(
        id: ProjectID = .generate(),
        name: String,
        rootPath: String,
        defaultBranch: GitRef,
        autoShipBranch: GitRef? = nil,
        defaultBackend: BackendID? = .claudeCode,
        worktreeBase: String? = nil,
        setupScript: String? = nil,
        setupPolicy: SetupPolicy = .block,
        sandbox: ProjectSandbox? = nil,
        nvrsion: ProjectNvrsion? = nil,
        createdAt: Date = Date(),
        archivedAt: Date? = nil
    ) {
        self.id = id
        self.name = name
        self.rootPath = rootPath
        self.defaultBranch = defaultBranch
        self.autoShipBranch = autoShipBranch
        self.defaultBackend = defaultBackend
        self.worktreeBase = worktreeBase
        self.setupScript = setupScript
        self.setupPolicy = setupPolicy
        self.sandbox = sandbox
        self.nvrsion = nvrsion
        self.createdAt = createdAt
        self.archivedAt = archivedAt
    }

    /// Whether this project is currently archived (hidden from the active list).
    public var isArchived: Bool { archivedAt != nil }

    /// The branch autoship merges this project's sessions into, resolved: the configured
    /// `autoShipBranch` if set, else the project root repo's `defaultBranch`.
    public var resolvedAutoShipBranch: GitRef { autoShipBranch ?? defaultBranch }

    /// Whether this repo lives under Nucleic's control directory (`~/.nucleic/control/`) —
    /// a repo Nucleic clones and manages itself, kept out of iCloud and away from manual
    /// edits. Derived from `rootPath`, so a move into/out of the control dir flips it.
    public var isNucleicControlled: Bool {
        ProjectCloner.isControlled(rootPath)
    }

    /// The sandbox config that actually governs this project's sessions, resolving the Nucleic
    /// Control default: a **controlled** project is sandboxed project-wide by default — even
    /// with no stored `sandbox`, and even if a stored one is disabled — so it always runs in
    /// Nucleic's managed container (its `image`/`idleTimeout`/`perSessionContainers` carry
    /// through when set). A non-controlled project keeps the explicit opt-in: its stored
    /// `sandbox` when enabled, else `nil` (host-spawned).
    ///
    /// This is the single source of truth both the spawn gate (`SessionController`) and the
    /// teardown paths (`AppStore`) read, so they can't drift. It is still **subordinate to**
    /// the app-wide `ContainerServiceSettings.serviceEnabled` master switch, which callers
    /// check first — so with the container service off, even a control project runs on the host.
    public var effectiveSandbox: ProjectSandbox? {
        if isNucleicControlled {
            var s = sandbox ?? ProjectSandbox()
            s.enabled = true
            return s
        }
        if let sandbox, sandbox.enabled { return sandbox }
        return nil
    }

    /// Whether this project's sandboxed sessions share Nucleic's single **primary** managed
    /// container rather than each getting their own. True only for a Nucleic Control project
    /// that hasn't opted into per-session containers — the default for control projects.
    public var usesSharedControlContainer: Bool {
        isNucleicControlled && !(effectiveSandbox?.perSessionContainers ?? false)
    }

    /// Whether **nvrsion** (Beta) governs this project's sessions — the single gate both the spawn
    /// path and teardown read so they can't drift (NVRSION §10). Requires a Nucleic Control project,
    /// the mode enabled, and the shared control container (v0 doesn't support per-session
    /// containers). Like `effectiveSandbox` it stays subordinate to the app-wide container-service
    /// master switch, which callers check first.
    public var nvrsionActive: Bool {
        isNucleicControlled && (nvrsion?.enabled ?? false) && usesSharedControlContainer
    }

    /// Host path of the shared nvrsion trunk checkout: `<repoRoot>/.nucleic/trunk` — kept inside the
    /// project under `.nucleic/` (git-excluded) on the same volume, beside the worktrees dir.
    public var resolvedTrunkPath: String {
        let nucleicDir = (rootPath as NSString).appendingPathComponent(".nucleic")
        return (nucleicDir as NSString).appendingPathComponent("trunk")
    }

    /// `<repoSlug>` used in the worktree path and branch derivation.
    public var repoSlug: String {
        let last = (rootPath as NSString).lastPathComponent
        return Slug.sanitize(last, fallback: "repo")
    }

    /// Resolved base directory for this project's session worktrees:
    /// `<override>/<repoSlug>` or the default `<repoRoot>/.nucleic/worktrees` — kept
    /// inside the project under `.nucleic/` (git-excluded so the primary checkout never
    /// sees it) on the same volume (WORKTREE_MANAGER §2).
    public var resolvedWorktreeBase: String {
        if let worktreeBase {
            return (worktreeBase as NSString).appendingPathComponent(repoSlug)
        }
        let nucleicDir = (rootPath as NSString).appendingPathComponent(".nucleic")
        return (nucleicDir as NSString).appendingPathComponent("worktrees")
    }
}

// MARK: - Slug derivation (WORKTREE_MANAGER §2)

public enum Slug {
    /// Lowercased, kebab-cased, alphanumerics + dashes only, collapsed and trimmed.
    public static func sanitize(_ raw: String, fallback: String = "session") -> String {
        var out = ""
        var lastWasDash = false
        for scalar in raw.lowercased().unicodeScalars {
            if scalar == "-" || scalar == "_" || scalar == " " || scalar == "." || scalar == "/" {
                if !lastWasDash && !out.isEmpty { out.append("-"); lastWasDash = true }
            } else if scalar.properties.isAlphabetic || ("0"..."9").contains(scalar) {
                out.unicodeScalars.append(scalar)
                lastWasDash = false
            }
            // anything else is dropped
        }
        while out.hasSuffix("-") { out.removeLast() }
        return out.isEmpty ? fallback : out
    }
}
