import Foundation
import Testing

@testable import NucleicCore

@Suite struct ProcessHostTests {
    /// A large Write arrives as one newline-free JSON object split across many pipe reads. Framing
    /// must inspect each byte once rather than rescanning the growing prefix after every chunk.
    /// Keep this direct (rather than relying on OS pipe chunk sizes) so the regression reliably
    /// exercises thousands of boundaries, then prove the delimiter and following event stay intact.
    @Test(.timeLimit(.minutes(1))) func lineSplitterHandlesMultiMegabyteWriteIncrementally() throws {
        let splitter = LineSplitter()
        let chunk = Data(repeating: UInt8(ascii: "W"), count: 1_024)
        let chunkCount = 4_096  // 4 MiB spread across 4,096 native-stream chunks

        for _ in 0..<chunkCount {
            #expect(splitter.push(chunk).isEmpty)
        }

        let lines = splitter.push(Data("\nnext-event\n".utf8))
        try #require(lines.count == 2)
        #expect(lines[0].count == chunk.count * chunkCount)
        #expect(lines[0].first == UInt8(ascii: "W"))
        #expect(lines[0].last == UInt8(ascii: "W"))
        #expect(String(decoding: lines[1], as: UTF8.self) == "next-event")
        #expect(splitter.drain() == nil)
    }

    /// A stdout line far larger than the OS pipe buffer must arrive intact, and the
    /// line after it must still be delivered. This is the regression behind a big
    /// tool-result truncating mid-line — which dropped everything the agent streamed
    /// afterward (its final answer + the result event), so the turn "never returned".
    @Test(.timeLimit(.minutes(1))) func largeLineIsNotTruncatedAndStreamContinues() async throws {
        let bigCount = 300_000  // well past a 64KB pipe buffer
        let script = "head -c \(bigCount) /dev/zero | tr '\\000' 'A'; printf '\\n'; printf 'done\\n'"
        let host = ProcessHost()
        let handle = try await host.launch(
            ProcessSpec(
                executable: "/bin/sh", args: ["-c", script],
                cwd: FileManager.default.temporaryDirectory.path, stdinMode: .closed))

        var lines: [Data] = []
        for try await line in handle.stdoutLines { lines.append(line) }

        #expect(lines.count == 2)
        #expect(lines.first?.count == bigCount)
        #expect(lines.first?.allSatisfy { $0 == UInt8(ascii: "A") } == true)
        #expect(lines.last.map { String(decoding: $0, as: UTF8.self) } == "done")
    }

    /// Stop must always settle, even when the process's `wait()` never resolves — the case behind the
    /// "inconsistent Stop" on containerized sessions, where the guest wait/stdio RPC can wedge so the
    /// line streams never EOF and the decode loop hangs. `terminate` escalates SIGTERM → SIGKILL and,
    /// when the kill still doesn't resolve `wait()`, force-closes the streams so a consumer blocked on
    /// `stdoutLines` is released.
    @Test(.timeLimit(.minutes(1))) func terminateForceClosesStreamsWhenWaitNeverResolves() async {
        let handle = StallingHandle()
        let grace: UInt64 = 50_000_000  // 50ms
        await handle.terminate(graceNanos: grace)

        #expect(handle.didForceClose)
        #expect(handle.deliveredSignals.contains(SIGTERM))
        #expect(handle.deliveredSignals.contains(SIGKILL))
        // The streams must be finished — a consumer iterating them now completes instead of hanging.
        var produced = 0
        do { for try await _ in handle.stdoutLines { produced += 1 } } catch {}
        #expect(produced == 0)
    }

    /// A responsive process that exits on the first signal must NOT be force-killed or have its
    /// streams force-closed — the escalation is only for a wedged wait.
    @Test(.timeLimit(.minutes(1))) func terminateLeavesAResponsiveProcessAlone() async {
        let handle = StallingHandle(exitsAfterFirstSignal: true)
        await handle.terminate(graceNanos: 1_000_000_000)
        #expect(handle.deliveredSignals == [SIGTERM])  // exited on SIGTERM; no SIGKILL escalation
        #expect(!handle.didForceClose)
    }

    /// `terminateTree` must reap the WHOLE descendant tree, not just the direct child — the
    /// regression behind a hung `host_exec` build/test (a login shell forking make/swift/xctest)
    /// leaving orphans on the host that hold the shared trunk's SwiftPM `.build` lock. Model the
    /// shape with a shell that forks two long-lived grandchildren then waits, and prove every one
    /// is gone after `terminateTree`.
    @Test(.timeLimit(.minutes(1))) func terminateTreeReapsDescendants() async throws {
        let host = ProcessHost()
        let handle = try await host.launch(
            ProcessSpec(
                executable: "/bin/sh",
                // Two backgrounded grandchildren, then block in `wait` like a running build's shell.
                args: ["-c", "sleep 300 & sleep 300 & echo ready; wait"],
                cwd: FileManager.default.temporaryDirectory.path, stdinMode: .closed))

        // Wait until the grandchildren exist (the shell prints "ready" after forking them).
        var ready = false
        for try await line in handle.stdoutLines where String(decoding: line, as: UTF8.self) == "ready" {
            ready = true
            break
        }
        #expect(ready)

        let descendants = hostDescendantPIDs(of: handle.processID)
        #expect(descendants.count >= 2, "expected the two sleep grandchildren, got \(descendants)")

        await handle.terminateTree(graceNanos: 2 * 1_000_000_000)

        // Poll briefly for the OS to finish reaping, then assert every captured descendant is gone
        // (`kill(pid, 0)` returns non-zero / ESRCH for a pid that no longer exists).
        var allGone = false
        for _ in 0..<30 {
            if descendants.allSatisfy({ kill($0, 0) != 0 }) { allGone = true; break }
            try? await Task.sleep(nanoseconds: 100_000_000)
        }
        #expect(allGone, "descendants survived terminateTree: \(descendants.filter { kill($0, 0) == 0 })")
    }

    /// The MCP *connection* timeout is bounded (so an unreachable approval server can't wedge startup
    /// for ~24.8 days) while the *tool* timeout stays effectively unbounded (human-answered approvals
    /// may take many minutes). Locks in the asymmetry against an accidental re-pin of both to max.
    @Test func mcpConnectionTimeoutIsBoundedButToolTimeoutIsNot() {
        #expect(ClaudeCodeBackend.mcpTimeoutEnv["MCP_TIMEOUT"] == "60000")
        #expect(ClaudeCodeBackend.mcpTimeoutEnv["MCP_TOOL_TIMEOUT"] == "2147483647")
    }

    /// `claude -p` abandons still-running background subagents after a 10-minute default ceiling,
    /// losing their work. We pin the ceiling to 0 (wait indefinitely) on every spawn, and the pinned
    /// env must carry the MCP timeouts too so neither pin is dropped when the dictionaries are merged.
    @Test func backgroundWaitCeilingIsPinnedToWaitIndefinitely() {
        #expect(ClaudeCodeBackend.printBackgroundWaitEnv["CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS"] == "0")
        let pinned = ClaudeCodeBackend.pinnedChildEnv
        #expect(pinned["CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS"] == "0")
        #expect(pinned["MCP_TIMEOUT"] == "60000")
        #expect(pinned["MCP_TOOL_TIMEOUT"] == "2147483647")
    }

    /// A gated MCP tool call (e.g. `approve`, `AskUserQuestion`) suspends on a human and emits no
    /// progress, so the CLI's MCP tool *idle* timeout (5 min for our HTTP approval server) would abort
    /// it even though the wall-clock `MCP_TOOL_TIMEOUT` is pinned high. We pin the idle timeout to 0
    /// (disable the check) on every spawn, and the pinned env must still carry the other timeouts.
    @Test func mcpToolIdleTimeoutIsDisabled() {
        #expect(ClaudeCodeBackend.mcpToolIdleTimeoutEnv["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] == "0")
        let pinned = ClaudeCodeBackend.pinnedChildEnv
        #expect(pinned["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] == "0")
        #expect(pinned["CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS"] == "0")
        #expect(pinned["MCP_TIMEOUT"] == "60000")
        #expect(pinned["MCP_TOOL_TIMEOUT"] == "2147483647")
    }

    /// A Bash tool call runs unattended, so its timeout must be enforced (not left effectively
    /// infinite like the human-in-the-loop MCP calls). We pin both the per-call default and the hard
    /// ceiling to 270000 ms (4m30s) on every spawn, and the pinned env must still carry the other
    /// timeouts so no pin is dropped when the dictionaries are merged.
    @Test func bashTimeoutIsPinnedTo4m30s() {
        #expect(ClaudeCodeBackend.bashTimeoutEnv["BASH_DEFAULT_TIMEOUT_MS"] == "270000")
        #expect(ClaudeCodeBackend.bashTimeoutEnv["BASH_MAX_TIMEOUT_MS"] == "270000")
        let pinned = ClaudeCodeBackend.pinnedChildEnv
        #expect(pinned["BASH_DEFAULT_TIMEOUT_MS"] == "270000")
        #expect(pinned["BASH_MAX_TIMEOUT_MS"] == "270000")
        #expect(pinned["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] == "0")
        #expect(pinned["MCP_TOOL_TIMEOUT"] == "2147483647")
    }

    /// The background-wait cutoff detector matches the CLI's actual warning (so the loss is surfaced)
    /// but not unrelated stderr chatter that merely mentions one of the words.
    @Test func detectsBackgroundWaitCutoffWarning() {
        #expect(
            ClaudeCodeBackend.isBackgroundWaitCutoff(
                "Background tasks still running after 600s; terminating. "
                    + "Set CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0 to wait indefinitely."))
        #expect(!ClaudeCodeBackend.isBackgroundWaitCutoff("Background tasks still running"))
        #expect(!ClaudeCodeBackend.isBackgroundWaitCutoff("terminating the process"))
        #expect(!ClaudeCodeBackend.isBackgroundWaitCutoff("npm warn deprecated foo@1.0.0"))
    }

    /// A provider-native background completion is injected back into Claude as a synthetic message.
    /// If it arrives after the apparent final answer, Claude runs again and the chat looks like it
    /// restarted itself. Block every native scheduling entry point while leaving ordinary foreground
    /// Bash and non-creating cron operations alone.
    @Test func rejectsNativeBackgroundCallbacksThatCanRestartAnAnsweredSession() {
        let backgroundBash: JSONValue = [
            "command": "sleep 60; echo done",
            "run_in_background": true,
        ]
        #expect(
            ClaudeCodeBackend.nativeBackgroundToolDenial(
                toolName: "Bash", input: backgroundBash) != nil)
        #expect(
            ClaudeCodeBackend.nativeBackgroundToolDenial(
                toolName: "ScheduleWakeup", input: [:]) != nil)
        #expect(
            ClaudeCodeBackend.nativeBackgroundToolDenial(
                toolName: "CronCreate", input: [:]) != nil)

        #expect(
            ClaudeCodeBackend.nativeBackgroundToolDenial(
                toolName: "Bash", input: ["command": "swift test"]) == nil)
        #expect(
            ClaudeCodeBackend.nativeBackgroundToolDenial(
                toolName: "Bash",
                input: ["command": "swift test", "run_in_background": false]) == nil)
        #expect(
            ClaudeCodeBackend.nativeBackgroundToolDenial(
                toolName: "CronList", input: [:]) == nil)
        #expect(
            ClaudeCodeBackend.nativeBackgroundToolDenial(
                toolName: "CronDelete", input: [:]) == nil)
    }

    /// A pipeline ending in a non-follow `tail` withholds output until EOF, so the stall monitor must
    /// discount output-idle for it. A following `tail -f`, a non-terminal `tail`, `head`, and plain
    /// commands do not qualify.
    @Test func detectsTerminalBufferingTail() {
        // Non-follow tail as the terminal stage — the exact shape that read as a hang.
        #expect(ClaudeCodeBackend.terminalStageBuffersOutput("make vminit-image-push 2>&1 | tail -6"))
        #expect(ClaudeCodeBackend.terminalStageBuffersOutput("some-cmd | tail -n 20"))
        #expect(ClaudeCodeBackend.terminalStageBuffersOutput("cd foo && ./build.sh | tail -6"))
        // Following tail streams — not buffering.
        #expect(!ClaudeCodeBackend.terminalStageBuffersOutput("some-cmd | tail -f log.txt"))
        #expect(!ClaudeCodeBackend.terminalStageBuffersOutput("some-cmd | tail -F log.txt"))
        #expect(!ClaudeCodeBackend.terminalStageBuffersOutput("some-cmd | tail --follow=name x"))
        // tail is not the terminal stage (something reads after it) — streams through.
        #expect(!ClaudeCodeBackend.terminalStageBuffersOutput("some-cmd | tail -100 | grep err"))
        // head streams its first N lines as they arrive; a plain command has no tail at all.
        #expect(!ClaudeCodeBackend.terminalStageBuffersOutput("some-cmd | head -6"))
        #expect(!ClaudeCodeBackend.terminalStageBuffersOutput("swift build"))
    }

    /// A monitor's closing note re-invokes the agent as a full turn, so it may only buy one when it is
    /// the sole remaining thing that can wake it. The case that motivated this: several superseded
    /// watches tear down while a replacement watch is running, and each teardown notice woke the agent
    /// for a turn whose whole content was "nothing to act on".
    @Test func parksMonitorNoticesThatWouldBuyAPointlessTurn() {
        // Superseded watch ends quietly while a replacement is armed — the replacement will wake the
        // agent, so the notice rides along with it instead of costing a turn.
        #expect(
            ClaudeCodeBackend.monitorNoticeCanBeParked(
                hasTrailingOutput: false, otherMonitorsArmed: true))
        // Last watch ends with nothing left armed: the note is the only remaining wake-up, so it must
        // go now — otherwise the agent waits on a watch that is already dead.
        #expect(
            !ClaudeCodeBackend.monitorNoticeCanBeParked(
                hasTrailingOutput: false, otherMonitorsArmed: false))
        // Trailing output means the wake is already happening; the note rides out in that same turn
        // rather than being parked for a later one.
        #expect(
            !ClaudeCodeBackend.monitorNoticeCanBeParked(
                hasTrailingOutput: true, otherMonitorsArmed: true))
        #expect(
            !ClaudeCodeBackend.monitorNoticeCanBeParked(
                hasTrailingOutput: true, otherMonitorsArmed: false))
    }
}

/// A `ProcessHandle` whose `wait()` never resolves (unless told to exit on the first signal), used to
/// drive the Stop-escalation paths deterministically. Records the signals it received and whether its
/// streams were force-closed.
private final class StallingHandle: ProcessHandle, @unchecked Sendable {
    let stdoutLines: AsyncThrowingStream<Data, Error>
    let stderrLines: AsyncThrowingStream<Data, Error>
    private let outCont: AsyncThrowingStream<Data, Error>.Continuation
    private let errCont: AsyncThrowingStream<Data, Error>.Continuation
    private let lock = NSLock()
    private var signals: [Int32] = []
    private var forceClosed = false
    private let exitsAfterFirstSignal: Bool

    init(exitsAfterFirstSignal: Bool = false) {
        self.exitsAfterFirstSignal = exitsAfterFirstSignal
        (stdoutLines, outCont) = AsyncThrowingStream.makeStream()
        (stderrLines, errCont) = AsyncThrowingStream.makeStream()
    }

    var deliveredSignals: [Int32] { lock.withLock { signals } }
    var didForceClose: Bool { lock.withLock { forceClosed } }

    var processID: Int32 { 4242 }
    func writeLine(_ data: Data) throws {}
    func closeStdin() {}
    func sendSignal(_ sig: Int32) { lock.withLock { signals.append(sig) } }

    func wait() async -> Int32 {
        if exitsAfterFirstSignal {
            while lock.withLock({ signals.isEmpty }) {
                try? await Task.sleep(nanoseconds: 5_000_000)
            }
            return 0
        }
        while true { try? await Task.sleep(nanoseconds: 50_000_000) }  // never exits
    }

    func forceCloseStreams() {
        lock.withLock { forceClosed = true }
        outCont.finish()
        errCont.finish()
    }
}
