import Foundation
import Testing
@testable import CodexBarCore

#if canImport(Darwin)
import Darwin
#else
import Glibc
#endif

struct SubprocessRunnerTests {
    @Test
    func `reads large stdout without deadlock`() async throws {
        let result = try await SubprocessRunner.run(
            binary: "/usr/bin/python3",
            arguments: ["-c", "print('x' * 1_000_000)"],
            environment: ProcessInfo.processInfo.environment,
            timeout: 15,
            label: "python large stdout")

        #expect(result.stdout.count >= 1_000_000)
        #expect(result.stderr.isEmpty)
    }

    @Test
    func `bounds oversized stdout while continuing to drain`() async throws {
        let result = try await SubprocessRunner.run(
            binary: "/usr/bin/python3",
            arguments: ["-c", "print('x' * 2_000_000)"],
            environment: ProcessInfo.processInfo.environment,
            timeout: 5,
            label: "python oversized stdout")

        #expect(result.stdout.utf8.count == ProcessPipeCapture.defaultMaxBytes)
        #expect(result.stderr.isEmpty)
    }

    @Test
    func `rejects oversized output when strict limit is configured`() async throws {
        do {
            _ = try await SubprocessRunner.run(
                binary: "/usr/bin/python3",
                arguments: ["-c", "print('x' * 10_000)"],
                environment: ProcessInfo.processInfo.environment,
                timeout: 5,
                maxOutputBytes: 1024,
                label: "python strict output limit")
            Issue.record("Expected strict output limit failure")
        } catch let error as SubprocessRunnerError {
            guard case let .outputTooLarge(label) = error else {
                Issue.record("Expected outputTooLarge, got \(error)")
                return
            }
            #expect(label == "python strict output limit")
        } catch {
            Issue.record("Expected SubprocessRunnerError, got \(error)")
        }
    }

    @Test
    func `preserves captured prefix when limit splits three byte scalar`() async throws {
        let asciiCount = ProcessPipeCapture.defaultMaxBytes - 1
        let script = "import sys; sys.stdout.buffer.write(b'x' * \(asciiCount) + bytes([0xe2, 0x82, 0xac]) + b'tail')"
        let result = try await SubprocessRunner.run(
            binary: "/usr/bin/python3",
            arguments: ["-c", script],
            environment: ProcessInfo.processInfo.environment,
            timeout: 5,
            label: "python split utf8 stdout")

        #expect(result.stdout.count == ProcessPipeCapture.defaultMaxBytes)
        #expect(result.stdout.first == "x")
        #expect(result.stdout.last == "\u{FFFD}")
        #expect(result.stdout.utf8.count == ProcessPipeCapture.defaultMaxBytes + 2)
    }

    @Test
    func `bounds simultaneous oversized stdout and stderr while draining`() async throws {
        let script = """
        import sys
        chunk = 2048
        for _ in range(2000):
            sys.stdout.write('o' * chunk)
            sys.stdout.flush()
            sys.stderr.write('e' * chunk)
            sys.stderr.flush()
        """
        let result = try await SubprocessRunner.run(
            binary: "/usr/bin/python3",
            arguments: ["-c", script],
            environment: ProcessInfo.processInfo.environment,
            timeout: 10,
            label: "python simultaneous oversized output")

        #expect(result.stdout.utf8.count == ProcessPipeCapture.defaultMaxBytes)
        #expect(result.stderr.utf8.count == ProcessPipeCapture.defaultMaxBytes)
    }

    @Test
    func `bounds oversized stderr on failure`() async throws {
        do {
            _ = try await SubprocessRunner.run(
                binary: "/usr/bin/python3",
                arguments: ["-c", "import sys; sys.stderr.write('e' * 2_000_000); sys.exit(7)"],
                environment: ProcessInfo.processInfo.environment,
                timeout: 5,
                label: "python oversized stderr")
            Issue.record("Expected non-zero exit")
        } catch let error as SubprocessRunnerError {
            guard case let .nonZeroExit(code, stderr) = error else {
                Issue.record("Expected non-zero exit, got \(error)")
                return
            }
            #expect(code == 7)
            #expect(stderr.utf8.count == ProcessPipeCapture.defaultMaxBytes)
        } catch {
            Issue.record("Expected SubprocessRunnerError, got \(error)")
        }
    }

    @Test
    func `returns partial output when detached child keeps pipes open`() async throws {
        let root = FileManager.default.temporaryDirectory
            .appendingPathComponent("codexbar-subprocess-drain-\(UUID().uuidString)", isDirectory: true)
        let childPIDFile = root.appendingPathComponent("child.pid")
        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(at: root) }
        defer {
            if let text = try? String(contentsOf: childPIDFile, encoding: .utf8),
               let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
            {
                _ = kill(childPID, SIGKILL)
            }
        }

        var environment = ProcessInfo.processInfo.environment
        environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path
        let script = """
        import os
        import subprocess
        import sys

        child = subprocess.Popen(
            [sys.executable, "-c", "import time; time.sleep(5)"],
            start_new_session=True,
        )
        with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle:
            handle.write(str(child.pid))
        print("parent-output", flush=True)
        """

        let start = Date()
        let result = try await SubprocessRunner.run(
            binary: "/usr/bin/python3",
            arguments: ["-c", script],
            environment: environment,
            timeout: 5,
            label: "detached-output-holder")
        let elapsed = Date().timeIntervalSince(start)

        #expect(result.stdout.contains("parent-output"))
        #expect(elapsed < 3, "Output drain should not wait for the detached child, took \(elapsed)s")
    }

    /// Regression test for #474: a hung subprocess must be killed and throw `.timedOut`
    /// instead of blocking indefinitely.
    ///
    /// This test was previously deleted (commit 3961770) because `waitUntilExit()` blocked
    /// the cooperative thread pool, starving the timeout task. The fix moves blocking calls
    /// to `DispatchQueue.global()`, making this test reliable.
    @Test
    func `throws timed out when process hangs`() async throws {
        let start = Date()
        do {
            _ = try await SubprocessRunner.run(
                binary: "/bin/sleep",
                arguments: ["5"],
                environment: ProcessInfo.processInfo.environment,
                timeout: 1,
                label: "hung-process-test")
            Issue.record("Expected SubprocessRunnerError.timedOut but no error was thrown")
        } catch let error as SubprocessRunnerError {
            guard case let .timedOut(label) = error else {
                Issue.record("Expected .timedOut, got \(error)")
                return
            }
            #expect(label == "hung-process-test")
        } catch {
            Issue.record("Expected SubprocessRunnerError.timedOut, got unexpected error: \(error)")
        }

        let elapsed = Date().timeIntervalSince(start)
        // Must complete in well under 5s (the sleep duration). Allow generous bound for CI.
        #expect(elapsed < 3, "Timeout should fire in ~1s, not wait for process to exit naturally")
    }

    @Test
    func `timeout kills descendants that escape the process group`() async throws {
        let root = FileManager.default.temporaryDirectory
            .appendingPathComponent("codexbar-subprocess-tree-\(UUID().uuidString)", isDirectory: true)
        let childPIDFile = root.appendingPathComponent("child.pid")
        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(at: root) }

        var environment = ProcessInfo.processInfo.environment
        environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path
        let script = """
        import os
        import subprocess
        import sys
        import time

        child = subprocess.Popen(
            [sys.executable, "-c", "import time; time.sleep(30)"],
            start_new_session=True,
        )
        with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle:
            handle.write(str(child.pid))
        time.sleep(30)
        """

        await #expect(throws: SubprocessRunnerError.self) {
            try await SubprocessRunner.run(
                binary: "/usr/bin/python3",
                arguments: ["-c", script],
                environment: environment,
                timeout: 0.5,
                label: "escaped-descendant")
        }

        let text = try String(contentsOf: childPIDFile, encoding: .utf8)
        let childPID = try #require(pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)))
        defer { _ = kill(childPID, SIGKILL) }

        let deadline = Date().addingTimeInterval(1)
        while kill(childPID, 0) == 0, Date() < deadline {
            try await Task.sleep(for: .milliseconds(20))
        }
        #expect(kill(childPID, 0) == -1)
    }

    /// Multiple concurrent hung subprocesses must all time out independently, proving that
    /// one blocked subprocess does not starve the timeout mechanism of others.
    /// This is the core scenario that caused the original permanent-refresh-stall bug.
    @Test
    func `concurrent hung processes all time out`() async {
        let start = Date()
        let count = 8

        await withTaskGroup(of: Void.self) { group in
            for i in 0..<count {
                group.addTask {
                    do {
                        _ = try await SubprocessRunner.run(
                            binary: "/bin/sleep",
                            arguments: ["30"],
                            environment: ProcessInfo.processInfo.environment,
                            timeout: 2,
                            label: "concurrent-hung-\(i)")
                        Issue.record("Expected .timedOut for concurrent-hung-\(i)")
                    } catch let error as SubprocessRunnerError {
                        guard case .timedOut = error else {
                            Issue.record("Expected .timedOut for concurrent-hung-\(i), got \(error)")
                            return
                        }
                    } catch {
                        Issue.record("Unexpected error for concurrent-hung-\(i): \(error)")
                    }
                }
            }
        }

        let elapsed = Date().timeIntervalSince(start)
        // Under release-load parallel tests the runner can be CPU-starved, but it should still finish far before
        // the natural sleep exit and below a serial 8 * 2s timeout chain.
        #expect(
            elapsed < 15,
            "All \(count) concurrent timeouts should fire in ~2s, took \(elapsed)s")
    }

    /// Stress-test the timeout race guard: with very short timeouts, the exit-code task
    /// and the timeout task race tightly, exercising the KillFlag synchronization path.
    @Test
    func `timeout race stress`() async {
        for i in 0..<20 {
            do {
                _ = try await SubprocessRunner.run(
                    binary: "/bin/sleep",
                    arguments: ["1"],
                    environment: ProcessInfo.processInfo.environment,
                    timeout: 0.1,
                    label: "race-stress-\(i)")
                Issue.record("Expected .timedOut for iteration \(i)")
            } catch let error as SubprocessRunnerError {
                guard case .timedOut = error else {
                    Issue.record("Expected .timedOut, got \(error) at iteration \(i)")
                    continue
                }
            } catch {
                Issue.record("Unexpected error at iteration \(i): \(error)")
            }
        }
    }

    @Test
    func `cancellation terminates hung process promptly`() async throws {
        let start = Date()
        let task = Task {
            try await SubprocessRunner.run(
                binary: "/bin/sleep",
                arguments: ["10"],
                environment: ProcessInfo.processInfo.environment,
                timeout: 30,
                label: "cancelled-hung-process")
        }

        try await Task.sleep(for: .milliseconds(100))
        task.cancel()

        do {
            _ = try await task.value
            Issue.record("Expected CancellationError but subprocess completed")
        } catch is CancellationError {
            // Expected: cancellation should tear down the child process immediately.
        } catch {
            Issue.record("Expected CancellationError, got \(error)")
        }

        let elapsed = Date().timeIntervalSince(start)
        #expect(elapsed < 5, "Cancelled subprocess should not wait for timeout or natural exit")
    }

    /// Verify that many concurrent SubprocessRunner calls complete without starving each other.
    @Test
    func `concurrent calls do not starve`() async throws {
        try await withThrowingTaskGroup(of: SubprocessResult.self) { group in
            for i in 0..<20 {
                group.addTask {
                    try await SubprocessRunner.run(
                        binary: "/bin/sleep",
                        arguments: ["0.2"],
                        environment: ProcessInfo.processInfo.environment,
                        timeout: 10,
                        label: "concurrent-\(i)")
                }
            }

            var count = 0
            for try await _ in group {
                count += 1
            }
            #expect(count == 20, "All 20 concurrent calls should complete")
        }
    }
}
