import Foundation
import Testing

@testable import NucleicCore

@Suite struct ProcessHostTests {
    /// 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)
    }

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

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