import Foundation

/// Result of one `git` invocation.
public struct GitCommandResult: Sendable {
    public let status: Int32
    public let stdout: String
    public let stderr: String
    public var ok: Bool { status == 0 }
}

public enum GitError: Error, Sendable, Equatable {
    case spawnFailed(String)
    /// A `git` command exited non-zero. `command` is redaction-safe (argv only, no env/content).
    case commandFailed(command: String, status: Int32, stderr: String)
    /// The command outlived its wall-clock bound (or its awaiting task was cancelled) and was
    /// killed — SIGTERM then SIGKILL across its whole captured process tree. Without this bound a
    /// git that hangs (a blocking hook, a wedged credential helper, a stuck filter/fsmonitor
    /// process) parked its caller forever and the child survived until app quit.
    case timedOut(command: String, seconds: Double)
    case notAGitRepo(String)
    case baseResolutionFailed(ref: String, stderr: String)
    case dirtyTree(path: String)
    case worktreeExists(path: String)
}

/// Runs short-lived `git` (and `gh`) commands and collects their full output.
///
/// Git invocations here are quick and bounded, so — unlike `ProcessHost`, which
/// line-streams a long-lived agent — this collects stdout/stderr to EOF and returns
/// once. stdout and stderr are drained concurrently so a command that fills both
/// pipe buffers can't deadlock.
public struct GitRunner: Sendable {
    public var executable: String

    /// The dedicated serial queue every fork/exec runs on. Two jobs in one:
    /// (1) `process.run()` blocks in `posix_spawn` for real milliseconds. Executed inline it ran on
    ///     the *calling* Swift cooperative-pool thread, and a reconcile/diff storm of concurrent git
    ///     invocations parked enough of the fixed-width pool to stall every async subsystem in the
    ///     app (the 2026-07-28 hang reports). Callers now suspend on a continuation while the queue
    ///     does the blocking work; no cooperative thread ever waits on a spawn.
    /// (2) Being serial, it *is* the process-wide serialization of the pipe-create → fork/exec
    ///     window — the fd-inheritance race documented at `run(_:in:env:)` — which an `NSLock`
    ///     previously provided (by blocking whoever contended on it).
    private static let spawnQueue = DispatchQueue(label: "nucleic.gitrunner.spawn")

    public init(executable: String = "git") {
        self.executable = executable
    }

    /// Point `process` at `executable`, resolving a bare name (`"git"`) the way the platform
    /// expects — through the one shared ``resolvedSpawnTarget(executable:args:path:)`` that every
    /// host spawn site uses.
    ///
    /// Sharing it is the point. This method and `ChildProcess` briefly each had their own copy of
    /// the rule, the Windows leg landed in only one of them, and every `git` call on Windows failed
    /// with corelibs' "The file doesn't exist" (13 Carbon tests). One implementation, three callers.
    ///
    /// The PATH handed to the search is widened (registry + well-known dirs on Windows); the
    /// child's own environment is composed by the caller and deliberately left alone, so a git
    /// subprocess sees exactly what it always has.
    private func pointAtExecutable(
        _ process: Process, args: [String], env: [String: String]
    ) throws {
        let searchPath = LoginShellPATH.augment(
            ProcessInfo.processInfo.environment.merging(env) { _, new in new })["PATH"]
        guard let target = resolvedSpawnTarget(
            executable: executable, args: args, path: searchPath)
        else {
            throw GitError.spawnFailed("\(executable): not found on PATH")
        }
        process.executableURL = target.executableURL
        process.arguments = target.arguments
    }

    /// Wall-clock bound for one invocation. Generous — every local git op finishes orders of
    /// magnitude faster — because it exists to catch the *pathological* hang (a blocking hook, a
    /// wedged credential helper), not to police slow-but-honest work. Network ops (clone/fetch/
    /// push) pass a larger explicit `timeout` at their call sites.
    public static let defaultTimeout: TimeInterval = 120

    @discardableResult
    public func run(
        _ args: [String], in cwd: String, env: [String: String] = [:],
        timeout: TimeInterval = GitRunner.defaultTimeout
    ) async throws -> GitCommandResult {
        let process = Process()
        try pointAtExecutable(process, args: args, env: env)
        process.currentDirectoryURL = URL(fileURLWithPath: cwd)

        var environment = ProcessInfo.processInfo.environment
        // Non-interactive, deterministic git: never block on a credential/auth prompt,
        // never contend on the index lock for read-only queries.
        environment["GIT_TERMINAL_PROMPT"] = "0"
        environment["GIT_OPTIONAL_LOCKS"] = "0"
        for (key, value) in env { environment[key] = value }
        process.environment = environment

        // Termination is delivered via the handler rather than `waitUntilExit()`, which would block
        // a Swift-concurrency cooperative thread for the whole command — under parallel callers
        // (swift-testing's parallel @Tests) that starves the cooperative pool. Installed before
        // `run()` so there's no exit-before-handler race; awaited (outside the spawn lock) below.
        let termination = TerminationSignal()
        process.terminationHandler = { _ in termination.fire() }

        // Foundation leaves this invocation's pipe write-ends briefly open in the parent while it
        // forks/execs. If a *concurrent* Process spawn forks during that window, its child inherits —
        // and then holds open past its own exit — these write ends, so `readToEnd` below never sees
        // EOF and blocks on `read()` forever. Under the parallel test suite that leaks enough stuck
        // reads to exhaust libdispatch's 64-thread pool and freeze the whole process at ~0% CPU.
        // Serializing just the pipe-create → fork/exec window across every git spawn closes that
        // race without changing how git itself runs (the child still runs fully concurrently).
        let outPipe: Pipe
        let errPipe: Pipe
        do {
            (outPipe, errPipe) = try await Self.spawn(process)
        } catch {
            process.terminationHandler = nil
            // Report what we actually tried to launch, not just the requested name: on Windows
            // `executable` is a bare `git` while `executableURL` is the PATH × PATHEXT match, and
            // corelibs' "The file doesn't exist" is otherwise unattributable to either the
            // resolved binary or the working directory.
            throw GitError.spawnFailed(
                "\(process.executableURL?.path ?? executable) (cwd \(cwd)): "
                    + error.localizedDescription)
        }

        // Drain both pipes concurrently *before* awaiting exit: a command that fills
        // a 64KB pipe buffer would otherwise block on write and never terminate.
        let outTask = Task { await Self.readToEnd(outPipe.fileHandleForReading) }
        let errTask = Task { await Self.readToEnd(errPipe.fileHandleForReading) }

        // Bounded, cancellation-aware wait. On deadline or caller cancellation the whole
        // captured process tree is signalled (SIGTERM, grace, SIGKILL) — the tree, not just the
        // child, because the hang is usually a *descendant* (a hook, a helper) that would also
        // hold the pipe write-ends open and wedge the drains above. The snapshot is taken
        // before any signal, while the ancestry links still exist.
        let command = "\(executable) \(args.joined(separator: " "))"
        let killed = KilledFlag()
        let pid = process.processIdentifier
        let deadline: Task<Void, Never>? = timeout > 0
            ? Task {
                try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
                guard !Task.isCancelled else { return }
                killed.set()
                let tree = Self.captureTree(pid)
                Self.signalTree(tree, SIGTERM)
                try? await Task.sleep(nanoseconds: Self.killGraceNanos)
                Self.signalTree(tree, SIGKILL)
            }
            : nil
        await withTaskCancellationHandler {
            await termination.wait()
        } onCancel: {
            killed.set()
            let tree = Self.captureTree(pid)
            Self.signalTree(tree, SIGTERM)
            Task.detached {
                try? await Task.sleep(nanoseconds: Self.killGraceNanos)
                Self.signalTree(tree, SIGKILL)
            }
        }
        deadline?.cancel()

        if killed.isSet {
            // A killed tree normally closes the pipes, but a survivor (an unkillable or
            // reparented straggler) could hold a write-end open forever — bound the drains and
            // abandon them rather than trading one hang for another.
            _ = await withTimeout(2.0) { await outTask.value as Data? }
            _ = await withTimeout(2.0) { await errTask.value as Data? }
            if Task.isCancelled { throw CancellationError() }
            throw GitError.timedOut(command: command, seconds: timeout)
        }

        let out = await outTask.value
        let err = await errTask.value

        return GitCommandResult(
            status: process.terminationStatus,
            stdout: String(decoding: out, as: UTF8.self),
            stderr: String(decoding: err, as: UTF8.self))
    }

    /// How long a SIGTERMed tree gets to exit cleanly before the SIGKILL sweep.
    private static let killGraceNanos: UInt64 = 3_000_000_000

    /// The child plus every transitive descendant, snapshotted in one pass — pids stay valid
    /// across reparenting, so the set is still killable after the root dies (ProcessHost's
    /// `terminateTree` rationale; `Process` children share our group, so `killpg` is not an option).
    private static func captureTree(_ pid: pid_t) -> [pid_t] {
        guard pid > 0 else { return [] }
        return [pid] + hostDescendantPIDs(of: pid)
    }

    private static func signalTree(_ tree: [pid_t], _ sig: Int32) {
        for p in tree where p > 0 { kill(p, sig) }
    }

    /// Runs a command with stdin and/or stdout redirected to **files** — for git's binary
    /// plumbing (`pack-objects --stdout`, `index-pack --fix-thin --stdin`), whose byte streams
    /// must not round-trip through the String-decoding pipe path above (Carbon, spec §6.1/§9.1).
    /// File redirection also sidesteps pipe-buffer deadlocks for multi-hundred-MB packs.
    /// stderr still rides the drained pipe for diagnostics; `result.stdout` is empty when
    /// `stdoutFile` is set.
    @discardableResult
    public func runRedirected(
        _ args: [String], in cwd: String, env: [String: String] = [:],
        stdinFile: URL? = nil, stdoutFile: URL? = nil,
        timeout: TimeInterval = GitRunner.defaultTimeout
    ) async throws -> GitCommandResult {
        let process = Process()
        try pointAtExecutable(process, args: args, env: env)
        process.currentDirectoryURL = URL(fileURLWithPath: cwd)

        var environment = ProcessInfo.processInfo.environment
        environment["GIT_TERMINAL_PROMPT"] = "0"
        environment["GIT_OPTIONAL_LOCKS"] = "0"
        for (key, value) in env { environment[key] = value }
        process.environment = environment

        let termination = TerminationSignal()
        process.terminationHandler = { _ in termination.fire() }

        var inHandle: FileHandle?
        var outHandle: FileHandle?
        let errPipe: Pipe
        do {
            if let stdinFile {
                let h = try FileHandle(forReadingFrom: stdinFile)
                inHandle = h
                process.standardInput = h
            } else {
                process.standardInput = FileHandle.nullDevice
            }
            if let stdoutFile {
                FileManager.default.createFile(atPath: stdoutFile.path, contents: nil)
                let h = try FileHandle(forWritingTo: stdoutFile)
                outHandle = h
                process.standardOutput = h
            }
            errPipe = try await Self.spawnRedirected(process)
        } catch {
            process.terminationHandler = nil
            try? inHandle?.close()
            try? outHandle?.close()
            // Report what we actually tried to launch, not just the requested name: on Windows
            // `executable` is a bare `git` while `executableURL` is the PATH × PATHEXT match, and
            // corelibs' "The file doesn't exist" is otherwise unattributable to either the
            // resolved binary or the working directory.
            throw GitError.spawnFailed(
                "\(process.executableURL?.path ?? executable) (cwd \(cwd)): "
                    + error.localizedDescription)
        }
        defer {
            try? inHandle?.close()
            try? outHandle?.close()
        }

        let errTask = Task { await Self.readToEnd(errPipe.fileHandleForReading) }

        let command = "\(executable) \(args.joined(separator: " "))"
        let killed = KilledFlag()
        let pid = process.processIdentifier
        let deadline: Task<Void, Never>? = timeout > 0
            ? Task {
                try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
                guard !Task.isCancelled else { return }
                killed.set()
                let tree = Self.captureTree(pid)
                Self.signalTree(tree, SIGTERM)
                try? await Task.sleep(nanoseconds: Self.killGraceNanos)
                Self.signalTree(tree, SIGKILL)
            }
            : nil
        await withTaskCancellationHandler {
            await termination.wait()
        } onCancel: {
            killed.set()
            let tree = Self.captureTree(pid)
            Self.signalTree(tree, SIGTERM)
            Task.detached {
                try? await Task.sleep(nanoseconds: Self.killGraceNanos)
                Self.signalTree(tree, SIGKILL)
            }
        }
        deadline?.cancel()

        if killed.isSet {
            _ = await withTimeout(2.0) { await errTask.value as Data? }
            if Task.isCancelled { throw CancellationError() }
            throw GitError.timedOut(command: command, seconds: timeout)
        }

        let err = await errTask.value
        return GitCommandResult(
            status: process.terminationStatus,
            stdout: "",
            stderr: String(decoding: err, as: UTF8.self))
    }

    /// `spawn(_:)`'s sibling for file-redirected IO: only stderr gets a pipe, created on the same
    /// serial spawn queue so no concurrent spawn inherits its write end.
    private static func spawnRedirected(_ process: Process) async throws -> Pipe {
        let box = ProcessBox(process)
        return try await withCheckedThrowingContinuation { continuation in
            spawnQueue.async {
                let errPipe = Pipe()
                box.process.standardError = errPipe
                do {
                    try box.process.run()
                    continuation.resume(returning: errPipe)
                } catch {
                    continuation.resume(throwing: error)
                }
            }
        }
    }

    /// Runs a command and returns trimmed stdout, throwing `GitError.commandFailed`
    /// on a non-zero exit. `acceptStatuses` lets callers treat expected non-zero exits
    /// (e.g. `git diff --no-index` returning 1 for "files differ") as success.
    @discardableResult
    public func checked(
        _ args: [String], in cwd: String, env: [String: String] = [:],
        acceptStatuses: Set<Int32> = [0], timeout: TimeInterval = GitRunner.defaultTimeout
    ) async throws -> String {
        let result = try await run(args, in: cwd, env: env, timeout: timeout)
        guard acceptStatuses.contains(result.status) else {
            throw GitError.commandFailed(
                command: "git " + args.joined(separator: " "),
                status: result.status,
                stderr: result.stderr)
        }
        return result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
    }

    /// Create the stdio pipes and fork/exec `process` on `spawnQueue` — serialized process-wide so
    /// no concurrent spawn can inherit this invocation's pipe write-ends during the exec window,
    /// and off the cooperative pool so the caller suspends instead of blocking in `posix_spawn`.
    private static func spawn(_ process: Process) async throws -> (out: Pipe, err: Pipe) {
        let box = ProcessBox(process)
        return try await withCheckedThrowingContinuation { continuation in
            spawnQueue.async {
                let outPipe = Pipe()
                let errPipe = Pipe()
                box.process.standardOutput = outPipe
                box.process.standardError = errPipe
                box.process.standardInput = FileHandle.nullDevice
                do {
                    try box.process.run()
                    continuation.resume(returning: (outPipe, errPipe))
                } catch {
                    continuation.resume(throwing: error)
                }
            }
        }
    }

    /// Handler queue for the `DispatchIO` pipe drains — short chunk-append callbacks only.
    private static let ioQueue = DispatchQueue(label: "nucleic.gitrunner.io")

    /// Drain `handle` to EOF without pinning a thread. The previous implementation parked a
    /// libdispatch *global-pool* worker in a blocking `read()` per pipe for the whole life of the
    /// command — two threads per invocation, which under a git storm (dozens of concurrent
    /// invocations) exhausted the 64-thread dispatch pool and stalled unrelated queue work (the
    /// 2026-07-28 hang reports show 30+ workers in this read). `DispatchIO` delivers chunks via
    /// callbacks instead; an idle pipe costs a kqueue registration, not a thread.
    ///
    /// Windows takes the blocking path instead, on a thread of its own — see the leg below.
    private static func readToEnd(_ handle: FileHandle) async -> Data {
        #if os(Windows)
        // There is no descriptor to give `DispatchIO` here: Foundation's `FileHandle` wraps a Win32
        // `HANDLE`, and `fileDescriptor` is `@available(Windows, unavailable)` — "cannot perform
        // non-owning handle to fd conversion" — so the POSIX path can't be spelled at all, never
        // mind cast into place. Blocking read on a **dedicated** thread: the exhaustion this
        // function was rewritten to avoid is specifically about parking libdispatch's global pool
        // (and, from an async caller, the cooperative pool), neither of which a thread of our own
        // touches. Costs two threads per invocation, parked in `ReadFile` and released at EOF.
        return await withCheckedContinuation { continuation in
            let box = HandleBox(handle)
            let thread = Thread {
                // Partial data on error is the honest answer, matching the POSIX leg's `done`.
                let data = (try? box.handle.readToEnd()) ?? nil
                try? box.handle.close()
                continuation.resume(returning: data ?? Data())
            }
            thread.name = "nucleic.gitrunner.drain"
            thread.start()
        }
        #else
        return await withCheckedContinuation { continuation in
            let accumulated = PipeAccumulator()
            let io = DispatchIO(
                type: .stream, fileDescriptor: handle.fileDescriptor, queue: ioQueue
            ) { _ in
                // Cleanup owns the fd's end-of-life: runs once DispatchIO is done with it.
                try? handle.close()
            }
            io.setLimit(lowWater: 1)
            io.read(offset: 0, length: .max, queue: ioQueue) { done, chunk, _ in
                if let chunk { accumulated.append(chunk) }
                guard done else { return }
                // `done` fires exactly once (EOF or error — either way the partial data is the
                // honest answer, matching the old `(try? readToEnd()) ?? Data()` behavior).
                io.close()
                continuation.resume(returning: accumulated.take())
            }
        }
        #endif
    }
}

#if os(Windows)
/// Hands a pipe's `FileHandle` (not `Sendable`) to the drain thread that owns it from there on —
/// the reader thread is the only thing that touches it, and it closes it before resuming.
private struct HandleBox: @unchecked Sendable {
    let handle: FileHandle
    init(_ handle: FileHandle) { self.handle = handle }
}
#endif

/// Hands a configured `Process` (not `Sendable`) across to the spawn queue. Safe: the caller
/// finishes configuring before the hop, doesn't touch the process until the continuation resumes,
/// and every post-spawn read (pid, exit status) happens-after that resume.
private struct ProcessBox: @unchecked Sendable {
    let process: Process
    init(_ process: Process) { self.process = process }
}

/// Collects pipe chunks across `DispatchIO` callbacks. All access happens on the serial
/// `GitRunner.ioQueue`, which is what makes the unchecked `Sendable` sound.
private final class PipeAccumulator: @unchecked Sendable {
    private var data = Data()
    func append(_ chunk: DispatchData) { data.append(contentsOf: chunk) }
    func take() -> Data { data }
}

/// Lock-guarded one-way flag: set once (by the deadline task or the cancellation handler, both
/// off-actor) and read after the wait to tell "exited on its own" from "was killed" — the
/// termination handler fires either way, so the exit alone can't distinguish them.
private final class KilledFlag: @unchecked Sendable {
    private let lock = NSLock()
    private var value = false
    func set() {
        lock.lock()
        value = true
        lock.unlock()
    }
    var isSet: Bool {
        lock.lock()
        defer { lock.unlock() }
        return value
    }
}

/// One-shot await of a `Process` termination handler: `fire()` from the handler, `wait()` from the
/// caller. Lets `run` install the handler before spawning (no exit-before-handler race) yet await
/// exit *outside* the spawn lock. Idempotent; if termination beat the waiter, `wait()` returns at once.
private final class TerminationSignal: @unchecked Sendable {
    private let lock = NSLock()
    private var fired = false
    private var continuation: CheckedContinuation<Void, Never>?

    func fire() {
        lock.lock()
        fired = true
        let c = continuation
        continuation = nil
        lock.unlock()
        c?.resume()
    }

    func wait() async {
        await withCheckedContinuation { (c: CheckedContinuation<Void, Never>) in
            lock.lock()
            if fired {
                lock.unlock()
                c.resume()
            } else {
                continuation = c
                lock.unlock()
            }
        }
    }
}
