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)
    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

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

    @discardableResult
    public func run(
        _ args: [String], in cwd: String, env: [String: String] = [:]
    ) async throws -> GitCommandResult {
        let process = Process()
        if executable.contains("/") {
            process.executableURL = URL(fileURLWithPath: executable)
            process.arguments = args
        } else {
            process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
            process.arguments = [executable] + args
        }
        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

        let outPipe = Pipe()
        let errPipe = Pipe()
        process.standardOutput = outPipe
        process.standardError = errPipe
        process.standardInput = FileHandle.nullDevice

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

        // Await termination via the handler rather than `waitUntilExit()`, which
        // would block a Swift-concurrency cooperative thread for the whole command —
        // under parallel callers (e.g. swift-testing's parallel @Tests) that starves
        // the cooperative pool and deadlocks. The handler is installed before run()
        // so there's no exit-before-handler race.
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
            process.terminationHandler = { _ in continuation.resume() }
            do {
                try process.run()
            } catch {
                process.terminationHandler = nil
                continuation.resume(throwing: GitError.spawnFailed("\(executable): \(error.localizedDescription)"))
            }
        }

        let out = await outData
        let err = await errData

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

    /// 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]
    ) async throws -> String {
        let result = try await run(args, in: cwd, env: env)
        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)
    }

    private static func readToEnd(_ handle: FileHandle) async -> Data {
        await withCheckedContinuation { continuation in
            DispatchQueue.global().async {
                let data = (try? handle.readToEnd()) ?? Data()
                continuation.resume(returning: data)
            }
        }
    }
}
