import Foundation
import Testing

@testable import NucleicCore

@Suite("Container sandbox")
struct ContainerSandboxTests {

    // MARK: - stdio adapter (no VM required)

    /// The containerization stdout/stderr sink must reassemble the guest's raw byte chunks into
    /// newline-delimited lines — byte-for-byte the same framing as the host `ChildProcess` stream —
    /// holding partial lines across chunk boundaries and draining the trailing partial on close.
    @Test func streamWriterFramesGuestOutputIntoLines() async throws {
        let (stream, continuation) = AsyncThrowingStream<Data, Error>.makeStream()
        let writer = StreamWriter(continuation)
        try writer.write(Data("hel".utf8))          // partial line
        try writer.write(Data("lo\nwor".utf8))       // completes one, starts another
        try writer.write(Data("ld\n".utf8))          // completes the second
        try writer.write(Data("tail-no-newline".utf8))  // trailing partial, no newline
        try writer.close()                            // drains the partial, finishes the stream

        var lines: [String] = []
        for try await line in stream { lines.append(String(decoding: line, as: UTF8.self)) }
        #expect(lines == ["hello", "world", "tail-no-newline"])
    }

    /// The stdin source must newline-frame each pushed message (matching `ChildProcess.writeLine`)
    /// and end the stream on finish (guest-stdin EOF).
    @Test func streamReaderNewlineFramesStdin() async {
        let reader = StreamReader()
        reader.send(Data("line1".utf8))
        reader.send(Data("line2".utf8))
        reader.finish()

        var chunks: [String] = []
        for await chunk in reader.stream() { chunks.append(String(decoding: chunk, as: UTF8.self)) }
        #expect(chunks == ["line1\n", "line2\n"])
    }

    // MARK: - ContainerSpec control-socket relay plumbing (no VM required)

    /// The optional control-socket relay defaults off, survives the `renamed` copy the engine
    /// boundary makes for shared control containers, and exposes the agreed fixed guest mount point
    /// the in-guest bridge/shims connect to.
    @Test func controlSocketRelayPlumbing() {
        let base = ContainerSpec(
            name: "nucleic-control", image: "img", mounts: [], workdir: "/w", env: [:],
            idleTimeout: 60, claudeHomeStaging: "/s", claudeHomeWritable: "/h")
        #expect(base.controlSocketHostPath == nil)  // default: no relay (legacy TCP path)

        let wired = ContainerSpec(
            name: "nucleic-control", image: "img", mounts: [], workdir: "/w", env: [:],
            idleTimeout: 60, claudeHomeStaging: "/s", claudeHomeWritable: "/h",
            controlSocketHostPath: "/tmp/nuc-control.sock")
        #expect(wired.controlSocketHostPath == "/tmp/nuc-control.sock")
        // The randomized physical-name rename must carry the host socket path through unchanged.
        #expect(wired.renamed("nucleic-7f3a9c21").controlSocketHostPath == "/tmp/nuc-control.sock")
        #expect(ContainerSpec.controlSocketGuestPath == "/run/nucleic/control.sock")
    }

    // MARK: - stable naming

    @Test func containerNameDerivesFromSessionShortID() {
        let id = SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb")
        #expect(ContainerManager.containerName(for: id) == "nucleic-abcdef12\(ContainerManager.channelSuffix)")
    }

    /// `UUID().uuidString` is uppercase; the container name must be lowercased so it stays a stable,
    /// collision-free key for the engine's registry and on-disk rootfs clones.
    @Test func containerNameLowercasesUppercaseUUID() {
        let id = SessionID(rawValue: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")
        #expect(ContainerManager.containerName(for: id) == "nucleic-e621e1f8\(ContainerManager.channelSuffix)")
    }

    /// The shared control container names must be distinct from each other and from any per-session
    /// container name — otherwise a per-session `teardown` (which derives a `nucleic-<short>` name)
    /// or the disk-GC reconcile could clobber the shared container out from under live sessions.
    @Test func sharedControlContainerNamesAreDistinct() {
        let s = ContainerManager.channelSuffix
        #expect(ContainerManager.sharedControlContainerName == "nucleic-control\(s)")
        #expect(ContainerManager.claudeControlContainerName == "nucleic-control-claude\(s)")
        #expect(ContainerManager.codexControlContainerName == "nucleic-control-codex\(s)")
        #expect(ContainerManager.xaiControlContainerName == "nucleic-control-xai\(s)")
        // Every shared name is distinct (a duplicate would let one family's sweep clobber another's).
        #expect(Set(ContainerManager.allSharedControlContainerNames).count
            == ContainerManager.allSharedControlContainerNames.count)
        #expect(ContainerManager.allSharedControlContainerNames ==
            ["nucleic-control\(s)", "nucleic-control-claude\(s)",
             "nucleic-control-codex\(s)", "nucleic-control-xai\(s)"])

        // A per-session name never equals a shared name.
        let perSession = ContainerManager.containerName(
            for: SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb"))
        #expect(!ContainerManager.allSharedControlContainerNames.contains(perSession))
    }

    /// With splitting off, every backend shares `nucleic-control`; with it on, each agent family
    /// gets its own suffixed sandbox (Claude → `-claude`, GPT/Codex → `-codex`, xAI/Grok → `-xai`).
    /// Every name also carries the per-channel `channelSuffix`.
    @Test func sharedContainerNameSplitsByAgentFamily() {
        let s = ContainerManager.channelSuffix
        // Splitting off → one shared container regardless of backend.
        for backend in [BackendID.claudeCode, .codex, .codexExec, .grok] {
            #expect(ContainerManager.sharedContainerName(for: backend, split: false) == "nucleic-control\(s)")
        }
        // Splitting on → each family diverges to its own container.
        #expect(ContainerManager.sharedContainerName(for: .claudeCode, split: true) == "nucleic-control-claude\(s)")
        #expect(ContainerManager.sharedContainerName(for: .codex, split: true) == "nucleic-control-codex\(s)")
        #expect(ContainerManager.sharedContainerName(for: .codexExec, split: true) == "nucleic-control-codex\(s)")
        #expect(ContainerManager.sharedContainerName(for: .grok, split: true) == "nucleic-control-xai\(s)")
    }

    /// The per-channel suffix is one of the known channel tags — empty for the stable release, a
    /// `-`-prefixed tag otherwise — and it lands at the very end of every container name so several
    /// builds installed side-by-side don't collide on the shared on-disk container store. Channel is
    /// fixed at build time, so this is channel-agnostic (the default `dev` test build resolves
    /// `-local`; a `NUCLEIC_CHANNEL=stable` build resolves `""`).
    @Test func channelSuffixTagsNonReleaseNames() {
        let s = ContainerManager.channelSuffix
        #expect(["", "-beta", "-rc", "-local"].contains(s))
        // Only the stable release goes untagged; every other channel carries a tag.
        #expect(s.isEmpty || s.hasPrefix("-"))
        let perSession = ContainerManager.containerName(
            for: SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb"))
        #expect(perSession.hasSuffix(s))
        #expect(ContainerManager.sharedControlContainerName.hasSuffix(s))
    }

    /// Launch-time disk GC must reap only this channel's clones from the shared on-disk store, so
    /// `ownsContainer` claims names carrying this build's suffix and disowns every other channel's.
    @Test func ownsOnlyThisChannelsContainers() {
        // Names this build mints are always ours.
        #expect(ContainerManager.ownsContainer(named: ContainerManager.sharedControlContainerName))
        #expect(ContainerManager.ownsContainer(named: ContainerManager.claudeControlContainerName))
        #expect(ContainerManager.ownsContainer(named: ContainerManager.codexControlContainerName))
        #expect(ContainerManager.ownsContainer(named: ContainerManager.xaiControlContainerName))
        #expect(ContainerManager.ownsContainer(named: ContainerManager.containerName(
            for: SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb"))))

        // A clone tagged for any *other* channel is never ours.
        let foreign = (["", "-local", "-beta", "-rc"]).filter { $0 != ContainerManager.channelSuffix }
        for fs in foreign {
            #expect(!ContainerManager.ownsContainer(named: "nucleic-control\(fs)"))
            #expect(!ContainerManager.ownsContainer(named: "nucleic-abcdef12\(fs)"))
        }
    }

    // MARK: - randomized physical sandbox names

    /// Only the per-agent-family control containers get a randomized physical name; the primary
    /// shared container (the lone sandbox when splitting is off) does not.
    @Test func onlyPerFamilyControlNamesAreRandomized() {
        let r = ContainerManager.randomizedControlNames
        #expect(r.contains(ContainerManager.claudeControlContainerName))
        #expect(r.contains(ContainerManager.codexControlContainerName))
        #expect(r.contains(ContainerManager.xaiControlContainerName))
        #expect(!r.contains(ContainerManager.sharedControlContainerName))
        #expect(r.count == 3)
    }

    /// The physical name hides the agent family (so a glimpsed sibling can't be identified) and keeps
    /// the channel tag last (so the disk-GC's channel scoping still recognizes the clone).
    @Test func physicalControlNameHidesFamilyAndKeepsChannelLast() {
        let s = ContainerManager.channelSuffix
        let phys = ContainerManager.physicalControlName(word: "wombat")
        #expect(phys == "nucleic-control-wombat\(s)")
        // No family marker in the name — all split families share the `nucleic-control-…` shape.
        #expect(!phys.contains("codex") && !phys.contains("claude") && !phys.contains("xai"))
        #expect(phys.hasSuffix(s))
        #expect(ContainerManager.ownsContainer(named: phys))
        // Distinct from every logical family name (unguessable).
        #expect(phys != ContainerManager.codexControlContainerName)
    }

    /// The fun-word dictionary is non-trivial, lowercase, single-token, and never collides with a
    /// channel tag (which would make a physical name look like it belongs to another channel).
    @Test func funWordsAreCleanAndAvoidChannelTags() {
        let words = ContainerManager.funWords
        #expect(words.count >= 30)
        for w in words {
            #expect(w == w.lowercased())
            #expect(!w.contains("-") && !w.contains(" ") && !w.isEmpty)
        }
        let tags = Set(ContainerManager.nonReleaseSuffixes.map { String($0.dropFirst()) })  // local/beta/rc
        #expect(Set(words).isDisjoint(with: tags))
        #expect(words.contains(ContainerManager.randomFunWord()))
    }

    /// The Control panel maps each control container's logical name to a friendly type heading.
    @Test func controlTypeLabelsMapLogicalNames() {
        #expect(ContainerManager.controlTypeLabel(
            forLogical: ContainerManager.sharedControlContainerName) == "Shared")
        #expect(ContainerManager.controlTypeLabel(
            forLogical: ContainerManager.claudeControlContainerName) == "Claude")
        #expect(ContainerManager.controlTypeLabel(
            forLogical: ContainerManager.codexControlContainerName) == "Codex")
        #expect(ContainerManager.controlTypeLabel(
            forLogical: ContainerManager.xaiControlContainerName) == "xAI")
    }

    // MARK: - memory management (balloon)

    /// Every level enables ballooning, and they get progressively tighter (less headroom, lower
    /// floor, faster cadence) from conservative → balanced → aggressive.
    @Test func memoryLevelsMapToProgressivelyTighterPolicies() {
        let conservative = MemoryManagementLevel.conservative.policy
        let balanced = MemoryManagementLevel.balanced.policy
        let aggressive = MemoryManagementLevel.aggressive.policy
        for p in [conservative, balanced, aggressive] { #expect(p.enabled) }
        // Headroom is always ≥ 1 (never inflate below the working set) and tightens with aggression.
        #expect(conservative.headroom >= 1 && balanced.headroom >= 1 && aggressive.headroom >= 1)
        #expect(conservative.headroom > balanced.headroom)
        #expect(balanced.headroom > aggressive.headroom)
        #expect(conservative.floorBytes > balanced.floorBytes)
        #expect(balanced.floorBytes > aggressive.floorBytes)
        // The whole-VM reserve also tightens with aggression but stays positive on every enabled
        // level (so the target always clears the guest's non-cgroup footprint).
        #expect(conservative.reserveBytes > balanced.reserveBytes)
        #expect(balanced.reserveBytes > aggressive.reserveBytes)
        #expect(aggressive.reserveBytes > 0)
        #expect(conservative.cadenceSeconds > balanced.cadenceSeconds)
        #expect(balanced.cadenceSeconds > aggressive.cadenceSeconds)
        // Single-word labels (Off was removed; Conservative is the default and gentlest).
        #expect(MemoryManagementLevel.allCases.map(\.label) ==
            ["Conservative", "Balanced", "Aggressive"])
    }

    /// The balloon target leaves headroom over the working set, never drops below the working set
    /// (inflating below resident memory would force guest reclaim/OOM), keeps a fixed whole-VM
    /// reserve on top so the guest kernel/page-cache aren't squeezed, respects the floor, and never
    /// exceeds the configured ceiling.
    @Test func balloonTargetStaysWithinBounds() {
        let gib: UInt64 = 1_073_741_824
        let p = MemoryManagementLevel.balanced.policy  // headroom 1.5, reserve 1 GiB, floor 1 GiB

        // Typical: used well below ceiling, and used × headroom dominates used + reserve →
        // target = used × headroom, above used, below ceiling.
        let t1 = p.target(workingSetBytes: 4 * gib, ceilingBytes: 64 * gib)
        #expect(t1 == UInt64(Double(4 * gib) * 1.5))
        #expect(t1 >= 4 * gib && t1 <= 64 * gib)

        // Tiny working set: the additive whole-VM reserve dominates the multiplier (and clears the
        // floor), so the target is used + reserve — never squeezed down toward the floor where the
        // guest's non-cgroup footprint (kernel, vminitd, page cache) would be starved.
        let small: UInt64 = 100 * 1_048_576
        let t2 = p.target(workingSetBytes: small, ceilingBytes: 64 * gib)
        #expect(t2 == small + p.reserveBytes)
        #expect(t2 >= p.floorBytes && t2 > small)

        // Used near the ceiling → target clamps to the ceiling (can't exceed configured memory).
        let t3 = p.target(workingSetBytes: 60 * gib, ceilingBytes: 64 * gib)
        #expect(t3 == 64 * gib)
    }

    /// The setting defaults to Conservative (ballooning on out of the box — see the note on
    /// `memoryManagement`) and round-trips through UserDefaults; an unknown stored value falls back
    /// to the default.
    @Test func memoryManagementSettingDefaultsToConservativeAndRoundTrips() {
        let key = ContainerServiceSettings.memoryManagementKey
        let saved = UserDefaults.standard.string(forKey: key)
        defer {
            if let saved { UserDefaults.standard.set(saved, forKey: key) }
            else { UserDefaults.standard.removeObject(forKey: key) }
        }

        UserDefaults.standard.removeObject(forKey: key)
        #expect(ContainerServiceSettings.memoryManagement == .conservative)

        UserDefaults.standard.set(MemoryManagementLevel.aggressive.rawValue, forKey: key)
        #expect(ContainerServiceSettings.memoryManagement == .aggressive)

        UserDefaults.standard.set("nonsense", forKey: key)
        #expect(ContainerServiceSettings.memoryManagement == .conservative)
    }

    // MARK: - sandbox config persistence

    @Test func projectSandboxRoundTripsThroughStore() async throws {
        let store = try GRDBMetadataStore(path: nil)
        let project = Project(
            id: .generate(), name: "demo", rootPath: "/repos/demo", defaultBranch: "main",
            sandbox: ProjectSandbox(enabled: true, image: "custom:1", idleTimeoutSeconds: 600),
            createdAt: Date(timeIntervalSince1970: 1_700_000_000))
        try await store.saveProject(project)
        let loaded = try await store.loadProjects()
        #expect(loaded == [project])
        #expect(loaded.first?.sandbox?.enabled == true)
        #expect(loaded.first?.sandbox?.image == "custom:1")
        #expect(loaded.first?.sandbox?.idleTimeoutSeconds == 600)
    }

    @Test func hostExecRoundTripsAndLegacyJSONDefaultsToFalse() async throws {
        // New field survives a round-trip.
        let sandbox = ProjectSandbox(enabled: true, idleTimeoutSeconds: 600, allowHostExec: true)
        let data = try JSONEncoder().encode(sandbox)
        #expect(try JSONDecoder().decode(ProjectSandbox.self, from: data).allowHostExec == true)

        // Rows persisted before `allowHostExec` existed decode with the default (false) rather
        // than failing the whole blob (the store decodes sandbox config with `try?`).
        let legacy = Data(#"{"enabled":true,"idleTimeoutSeconds":900}"#.utf8)
        let decoded = try JSONDecoder().decode(ProjectSandbox.self, from: legacy)
        #expect(decoded.enabled == true)
        #expect(decoded.allowHostExec == false)
    }

    @Test func projectWithoutSandboxLoadsAsNil() async throws {
        let store = try GRDBMetadataStore(path: nil)
        let project = Project(name: "plain", rootPath: "/repos/plain", defaultBranch: "main")
        try await store.saveProject(project)
        let loaded = try await store.loadProjects()
        #expect(loaded.first?.sandbox == nil)
    }

    @Test func resolvedImageFallsBackToBundledDefault() {
        #expect(ProjectSandbox(enabled: true, image: nil).resolvedImage == ProjectSandbox.defaultImage)
        #expect(ProjectSandbox(enabled: true, image: "  ").resolvedImage == ProjectSandbox.defaultImage)
        #expect(ProjectSandbox(enabled: true, image: "x:2").resolvedImage == "x:2")
    }

    // MARK: - Nucleic Control sandbox defaulting

    private func controlProject(perSession: Bool = false, sandbox: ProjectSandbox? = nil) -> Project {
        Project(
            name: "ctl",
            rootPath: (ProjectCloner.controlBase as NSString).appendingPathComponent("repo"),
            defaultBranch: "main",
            sandbox: sandbox ?? (perSession ? ProjectSandbox(perSessionContainers: true) : nil))
    }

    @Test func controlProjectsAreSandboxedProjectWideByDefault() {
        // No stored sandbox → still effectively sandboxed (project-wide).
        let bare = controlProject()
        #expect(bare.effectiveSandbox?.enabled == true)
        #expect(bare.usesSharedControlContainer)

        // A stored-but-disabled sandbox is still forced on for a control project.
        let disabled = controlProject(sandbox: ProjectSandbox(enabled: false, idleTimeoutSeconds: 600))
        #expect(disabled.effectiveSandbox?.enabled == true)
        #expect(disabled.effectiveSandbox?.idleTimeoutSeconds == 600)  // other fields carry through

        // Opting into per-session containers keeps it sandboxed but off the shared container.
        let perSession = controlProject(perSession: true)
        #expect(perSession.effectiveSandbox?.enabled == true)
        #expect(!perSession.usesSharedControlContainer)
    }

    @Test func nonControlProjectsKeepExplicitSandboxOptIn() {
        let plain = Project(name: "p", rootPath: "/repos/plain", defaultBranch: "main")
        #expect(plain.effectiveSandbox == nil)
        #expect(!plain.usesSharedControlContainer)

        let opted = Project(
            name: "p", rootPath: "/repos/plain", defaultBranch: "main",
            sandbox: ProjectSandbox(enabled: true))
        #expect(opted.effectiveSandbox?.enabled == true)
        // A non-control project's sandbox is per-session, never the shared control container.
        #expect(!opted.usesSharedControlContainer)
    }

    @Test func perSessionContainersRoundTripsAndLegacyDefaultsFalse() {
        let sandbox = ProjectSandbox(enabled: true, perSessionContainers: true)
        let data = try! JSONEncoder().encode(sandbox)
        #expect(try! JSONDecoder().decode(ProjectSandbox.self, from: data).perSessionContainers == true)

        // Rows persisted before the field decode with the default (false), not a dropped blob.
        let legacy = Data(#"{"enabled":true,"idleTimeoutSeconds":900,"allowHostExec":false}"#.utf8)
        let decoded = try! JSONDecoder().decode(ProjectSandbox.self, from: legacy)
        #expect(decoded.enabled == true)
        #expect(decoded.perSessionContainers == false)
    }

    // MARK: - resource sample value type

    @Test func resourceSampleClampsCPUAndPercent() {
        // The initializer clamps CPU into 0…100 and memoryPercent never exceeds 100.
        let over = ContainerResourceSample(
            cpuPercent: 250, memoryUsedBytes: 9_000_000_000, memoryTotalBytes: 8_000_000_000)
        #expect(over.cpuPercent == 100)
        #expect(over.memoryPercent == 100)
        let under = ContainerResourceSample(cpuPercent: -5, memoryUsedBytes: 0, memoryTotalBytes: 0)
        #expect(under.cpuPercent == 0)
        #expect(under.memoryPercent == 0)  // zero total → 0, not a crash
    }

    // MARK: - kill diagnosis value type

    @Test func killDiagnosisCarriesOOMAndSample() {
        let sample = ContainerResourceSample(
            cpuPercent: 0, memoryUsedBytes: 6 * 1024 * 1024 * 1024,
            memoryTotalBytes: 8 * 1024 * 1024 * 1024)
        let running = ContainerKillDiagnosis(containerRunning: true, oomKills: 3, sample: sample)
        #expect(running.containerRunning)
        #expect(running.oomKills == 3)
        #expect(running.sample?.memoryUsedBytes == 6 * 1024 * 1024 * 1024)

        // The "container went down" finding: not running, nothing to sample.
        let down = ContainerKillDiagnosis(containerRunning: false, oomKills: nil, sample: nil)
        #expect(!down.containerRunning)
        #expect(down.oomKills == nil)
        #expect(down.sample == nil)
    }

    // MARK: - host-aware control container memory

    @Test func recommendedControlMemoryEqualsTotalSystemRAM() {
        func gib(_ n: UInt64) -> UInt64 { n * 1_073_741_824 }
        // The default is the host's full physical RAM — the memory cap is a ceiling the VM grows
        // into, not a reservation, so a high cap just stops the in-VM OOM-killer firing prematurely.
        #expect(ContainerServiceSettings.recommendedControlContainerMemoryGiB(physicalBytes: gib(8)) == 8)
        #expect(ContainerServiceSettings.recommendedControlContainerMemoryGiB(physicalBytes: gib(16)) == 16)
        #expect(ContainerServiceSettings.recommendedControlContainerMemoryGiB(physicalBytes: gib(36)) == 36)
        #expect(ContainerServiceSettings.recommendedControlContainerMemoryGiB(physicalBytes: gib(64)) == 64)
        #expect(ContainerServiceSettings.recommendedControlContainerMemoryGiB(physicalBytes: gib(128)) == 128)
        // A bogus 0 reading falls back to the documented 8 GiB rather than a zero ceiling.
        #expect(ContainerServiceSettings.recommendedControlContainerMemoryGiB(physicalBytes: 0) == 8)
    }

    // MARK: - container resource limits (CPUs / memory)

    @Test func containerResourceDefaultsAndSpecClamping() {
        // Two pools: the shared control container is more generous than a per-session one.
        #expect(ContainerServiceSettings.defaultContainerCPUs == 4)
        #expect(ContainerServiceSettings.defaultContainerMemoryGiB == 4)
        #expect(ContainerServiceSettings.defaultControlContainerCPUs == 8)
        #expect(ContainerServiceSettings.defaultControlContainerMemoryGiB == 8)

        // ContainerSpec floors cpus/memory at 1 so a bad/zero setting can't produce a zero-resource
        // VM configuration.
        let clamped = ContainerSpec(
            name: "n", image: "i", mounts: [], workdir: "/w", env: [:], idleTimeout: 1,
            claudeHomeStaging: "/s", claudeHomeWritable: "/cw", cpus: 0, memoryGiB: -3)
        #expect(clamped.cpus == 1)
        #expect(clamped.memoryGiB == 1)

        let configured = ContainerSpec(
            name: "n", image: "i", mounts: [], workdir: "/w", env: [:], idleTimeout: 1,
            claudeHomeStaging: "/s", claudeHomeWritable: "/cw", cpus: 8, memoryGiB: 16)
        #expect(configured.cpus == 8)
        #expect(configured.memoryGiB == 16)

        // Spec defaults track the app-wide defaults.
        let defaulted = ContainerSpec(
            name: "n", image: "i", mounts: [], workdir: "/w", env: [:], idleTimeout: 1,
            claudeHomeStaging: "/s", claudeHomeWritable: "/cw")
        #expect(defaulted.cpus == ContainerServiceSettings.defaultContainerCPUs)
        #expect(defaulted.memoryGiB == ContainerServiceSettings.defaultContainerMemoryGiB)
        // runAsUID/runAsGID default to nil (root) and round-trip when set.
        #expect(defaulted.runAsUID == nil)
        #expect(defaulted.runAsGID == nil)
        let nonRoot = ContainerSpec(
            name: "n", image: "i", mounts: [], workdir: "/w", env: [:], idleTimeout: 1,
            claudeHomeStaging: "/s", claudeHomeWritable: "/cw", runAsUID: 501, runAsGID: 20)
        #expect(nonRoot.runAsUID == 501)
        #expect(nonRoot.runAsGID == 20)
    }

    // MARK: - git interceptor op classification

    @Test func gitInterceptorClassifiesOpsWithCertainty() {
        // A merge is recognized as such — the canonical "detect merges with certainty" case.
        let merge = GitCommandSummary.classify(argv: ["merge", "--no-ff", "feature/x"])
        #expect(merge?.isMerge == true)
        #expect(merge?.isMutating == true)
        #expect(merge?.label == "Merge feature/x")

        // Global options before the subcommand are skipped (commit is mutating, not a merge).
        let commit = GitCommandSummary.classify(argv: ["-C", "/repo", "commit", "-m", "msg"])
        #expect(commit?.isMerge == false)
        #expect(commit?.isMutating == true)

        // Read-only ops are recognized but flagged non-mutating, so consumers can ignore them.
        #expect(GitCommandSummary.classify(argv: ["status"])?.isMutating == false)
        #expect(GitCommandSummary.classify(argv: ["log", "--oneline"])?.isMutating == false)

        // Not a git subcommand we model → nil.
        #expect(GitCommandSummary.classify(argv: ["frobnicate"]) == nil)
    }

    /// The git/gh interceptor env is always wired (the conflict/merge system depends on it), but the
    /// bash command tracer is opt-in: `BASH_ENV` (which activates the per-command DEBUG trap) and the
    /// command-hook vars appear only when `commandTracing` is on.
    @Test func hookEnvGatesCommandTracerButAlwaysWiresGitGh() {
        let id = SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb")

        let off = CommandInterceptor.hookEnv(
            host: "10.0.0.1", port: 9099, token: "tok", sessionID: id, commandTracing: false)
        // git/gh + session are always present.
        #expect(off["NUCLEIC_GIT_HOOK_URL"]?.contains("10.0.0.1:9099") == true)
        #expect(off["NUCLEIC_GH_HOOK_URL"]?.contains("10.0.0.1:9099") == true)
        #expect(off["NUCLEIC_SESSION_ID"] == id.rawValue)
        // Command tracer is NOT activated — no BASH_ENV, no command hook.
        #expect(off["BASH_ENV"] == nil)
        #expect(off["NUCLEIC_CMD_HOOK_URL"] == nil)
        #expect(off["NUCLEIC_HOOK_TOKEN"] == nil)

        let on = CommandInterceptor.hookEnv(
            host: "10.0.0.1", port: 9099, token: "tok", sessionID: id, commandTracing: true)
        #expect(on["NUCLEIC_GIT_HOOK_URL"]?.contains("10.0.0.1:9099") == true)  // still wired
        #expect(on["BASH_ENV"] == CommandInterceptor.traceScriptPath)  // tracer activated
        #expect(on["NUCLEIC_CMD_HOOK_URL"]?.contains(CommandInterceptor.eventPath) == true)
        #expect(on["NUCLEIC_HOOK_TOKEN"] == "tok")
    }

    // MARK: - sandbox build-environment guidance

    /// With host-exec available the guidance must (a) state the container lacks the Swift/Xcode
    /// toolchain and (b) route Swift/macOS builds to the `host_exec` tool — otherwise an agent
    /// runs `swift build` in the container and only learns "command not found".
    @Test func sandboxBuildGuidanceRoutesSwiftToHostWhenHostExecAllowed() {
        let text = SessionController.sandboxBuildGuidance(allowHostExec: true)
        #expect(text.contains("host_exec"))
        #expect(text.contains("Swift"))
        #expect(text.contains("swift build"))
        // The in-container toolchain is advertised so non-Swift work stays in the sandbox.
        #expect(text.contains("Node/npm"))
        #expect(text.contains("make"))
    }

    /// Without host-exec there is no way to reach the host toolchain, so the guidance must say
    /// Swift/macOS targets can't be built here rather than point at a tool that isn't exposed.
    @Test func sandboxBuildGuidanceSaysSwiftUnavailableWithoutHostExec() {
        let text = SessionController.sandboxBuildGuidance(allowHostExec: false)
        #expect(!text.contains("host_exec"))
        #expect(text.contains("cannot be built"))
        #expect(text.contains("Node/npm"))
    }

    // MARK: - nvrsion version-control guidance (NVRSION)

    /// An nvrsion chat edits a *shared, live-committed* trunk, so the guidance must correct the
    /// ordinary-git assumptions an agent would otherwise carry: edits are committed the instant they
    /// land (not "only in the working tree"), hand-run history-mutating git on the shared tree is off
    /// limits, and the only path to the real branch is the user-run promotion (named with the base).
    @Test func nvrsionGuidanceExplainsSharedLiveCommittedTrunk() {
        let text = SessionController.nvrsionVersionControlGuidance(
            base: "main", trunkBranch: "nucleic/trunk")
        #expect(text.contains("nvrsion"))
        #expect(text.contains("nucleic/trunk"))
        // The misconception this guidance exists to kill: that edits sit uncommitted in a worktree.
        #expect(text.contains("uncommitted changes in the working tree"))
        #expect(text.contains("already a commit"))
        // Don't hand-run history-mutating git on the shared tree; read-only git stays fine.
        #expect(text.contains("reset"))
        #expect(text.contains("git status"))
        // The real branch is reached only by the user's promotion, named with the actual base.
        #expect(text.contains("Integrate trunk → main"))
    }

    /// The base and trunk-branch names are interpolated, so a project that renamed either still gets
    /// accurate guidance (the promotion button label and branch name match what it actually sees).
    @Test func nvrsionGuidanceUsesTheProjectsOwnBranchNames() {
        let text = SessionController.nvrsionVersionControlGuidance(
            base: "dev", trunkBranch: "nucleic/shared")
        #expect(text.contains("nucleic/shared"))
        #expect(text.contains("Integrate trunk → dev"))
        #expect(!text.contains("main"))
    }

    // MARK: - approval MCP URL host rewrite

    @Test func mcpConfigRewritesHostForContainerGateway() {
        let server = MCPApprovalServer()
        let host = server.mcpConfigJSON(port: 5050, token: "tok")
        #expect(host.contains("http://127.0.0.1:5050/mcp"))

        let gateway = server.mcpConfigJSON(host: "192.168.64.1", port: 5050, token: "tok")
        #expect(gateway.contains("http://192.168.64.1:5050/mcp"))
        #expect(gateway.contains("Bearer tok"))
    }
}
