#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#elseif canImport(Musl)
import Musl
#endif
import Foundation

public enum ProviderVersionDetector {
    private struct ClaudeExecutableFingerprint: Equatable, Hashable {
        let realPath: String
        let modificationDate: Date
        let fileSize: UInt64
        let inode: UInt64
    }

    private struct ClaudeVersionCacheEntry {
        let fingerprint: ClaudeExecutableFingerprint
        let version: String
        let cachedAt: Date
    }

    private final class PendingDetection {
        let group = DispatchGroup()
        var result: String?
    }

    static let claudeVersionCacheTTL: TimeInterval = 30 * 60
    private static let lock = NSLock()
    private nonisolated(unsafe) static var claudeVersionCache: ClaudeVersionCacheEntry?
    private nonisolated(unsafe) static var claudePendingDetections: [ClaudeExecutableFingerprint: PendingDetection] =
        [:]

    #if DEBUG
    public nonisolated(unsafe) static var whichHook: ((String) -> String?)?
    public nonisolated(unsafe) static var attributesHook: ((String) -> [FileAttributeKey: Any]?)?
    public nonisolated(unsafe) static var runClaudeVersionHook: ((String) throws -> TTYCommandRunner.Result?)?
    public nonisolated(unsafe) static var nowHook: (() -> Date)?

    public static func resetHooksAndCache() {
        self.lock.lock()
        self.claudeVersionCache = nil
        self.claudePendingDetections.removeAll()
        self.whichHook = nil
        self.attributesHook = nil
        self.runClaudeVersionHook = nil
        self.nowHook = nil
        self.lock.unlock()
    }
    #endif

    private static func currentDate() -> Date {
        #if DEBUG
        return self.nowHook?() ?? Date()
        #else
        return Date()
        #endif
    }

    private static func resolveRealPath(_ path: String) -> String {
        var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
        if realpath(path, &buffer) != nil {
            return buffer.withUnsafeBufferPointer { rawBuffer in
                guard let baseAddress = rawBuffer.baseAddress else { return path }
                return String(cString: baseAddress)
            }
        }
        return URL(fileURLWithPath: path).resolvingSymlinksInPath().path
    }

    private static func getClaudeFingerprint(forPath path: String) -> ClaudeExecutableFingerprint? {
        let resolvedPath = self.resolveRealPath(path)
        #if DEBUG
        let attributesOpt = self.attributesHook != nil ? self.attributesHook?(resolvedPath) : try? FileManager.default
            .attributesOfItem(atPath: resolvedPath)
        #else
        let attributesOpt = try? FileManager.default.attributesOfItem(atPath: resolvedPath)
        #endif
        guard let attributes = attributesOpt else {
            return nil
        }
        guard let modificationDate = attributes[.modificationDate] as? Date,
              let fileSize = (attributes[.size] as? NSNumber)?.uint64Value,
              let inode = (attributes[.systemFileNumber] as? NSNumber)?.uint64Value
        else {
            return nil
        }
        return ClaudeExecutableFingerprint(
            realPath: resolvedPath,
            modificationDate: modificationDate,
            fileSize: fileSize,
            inode: inode)
    }

    private static func runClaudeVersionCommand(path: String) -> String? {
        let commandResult: TTYCommandRunner.Result?
        #if DEBUG
        if let hook = runClaudeVersionHook {
            do {
                commandResult = try hook(path)
            } catch {
                commandResult = nil
            }
        } else {
            do {
                commandResult = try TTYCommandRunner().run(
                    binary: path,
                    send: "",
                    options: TTYCommandRunner.Options(
                        timeout: 5.0,
                        extraArgs: ["--version"],
                        initialDelay: 0.0,
                        useProviderProbeWorkingDirectory: true))
            } catch {
                commandResult = nil
            }
        }
        #else
        do {
            commandResult = try TTYCommandRunner().run(
                binary: path,
                send: "",
                options: TTYCommandRunner.Options(
                    timeout: 5.0,
                    extraArgs: ["--version"],
                    initialDelay: 0.0,
                    useProviderProbeWorkingDirectory: true))
        } catch {
            commandResult = nil
        }
        #endif

        guard let commandResult,
              commandResult.completion == .processExited(status: 0)
        else { return nil }
        let trimmed = TextParsing.stripANSICodes(commandResult.text)
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : trimmed
    }

    public static func claudeVersion(
        environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
    {
        #if DEBUG
        let pathOpt = self.whichHook != nil
            ? self.whichHook!("claude")
            : ClaudeCLIResolver.resolvedBinaryPath(environment: environment)
        #else
        let pathOpt = ClaudeCLIResolver.resolvedBinaryPath(environment: environment)
        #endif
        guard let path = pathOpt else { return nil }

        guard let fingerprint = getClaudeFingerprint(forPath: path) else {
            guard ClaudeCLIBackgroundAvailability.allowsOpaqueChildExecution(
                binary: path,
                environment: environment)
            else { return nil }
            return self.runClaudeVersionCommand(path: path)
        }
        self.lock.lock()
        let now = self.currentDate()
        let cacheAge = self.claudeVersionCache.map { now.timeIntervalSince($0.cachedAt) }
        if let cached = claudeVersionCache,
           cached.fingerprint == fingerprint,
           let cacheAge,
           cacheAge >= 0,
           cacheAge < self.claudeVersionCacheTTL
        {
            self.lock.unlock()
            return cached.version
        }
        self.claudeVersionCache = nil

        if let pending = claudePendingDetections[fingerprint] {
            self.lock.unlock()
            pending.group.wait()
            self.lock.lock()
            let result = pending.result
            self.lock.unlock()
            return result
        }

        guard ClaudeCLIBackgroundAvailability.allowsOpaqueChildExecution(
            binary: path,
            environment: environment)
        else {
            self.lock.unlock()
            return nil
        }

        let pending = PendingDetection()
        pending.group.enter()
        self.claudePendingDetections[fingerprint] = pending
        self.lock.unlock()

        let result = self.runClaudeVersionCommand(path: path)
        let completedAt = self.currentDate()

        self.lock.lock()
        pending.result = result
        if let version = result {
            self.claudeVersionCache = ClaudeVersionCacheEntry(
                fingerprint: fingerprint,
                version: version,
                cachedAt: completedAt)
        }
        self.claudePendingDetections.removeValue(forKey: fingerprint)
        pending.group.leave()
        self.lock.unlock()

        return result
    }

    public static func codexVersion() -> String? {
        guard let path = TTYCommandRunner.which(CodexProviderDescriptor.descriptor.cli.name) else { return nil }
        let candidates = [
            ["--version"],
            ["version"],
            ["-v"],
        ]
        for args in candidates {
            if let version = Self.run(path: path, args: args) {
                return version
            }
        }
        return nil
    }

    public static func geminiVersion() -> String? {
        let env = ProcessInfo.processInfo.environment
        guard let path = BinaryLocator.resolveGeminiBinary(env: env, loginPATH: nil)
            ?? TTYCommandRunner.which(GeminiProviderDescriptor.descriptor.cli.name) else { return nil }
        let candidates = [
            ["--version"],
            ["-v"],
        ]
        for args in candidates {
            if let version = Self.run(path: path, args: args) {
                return version
            }
        }
        return nil
    }

    static func run(
        path: String,
        args: [String],
        timeout: TimeInterval = 2.0,
        environment: [String: String]? = nil,
        mergeStandardError: Bool = false) -> String?
    {
        let proc = Process()
        proc.executableURL = URL(fileURLWithPath: path)
        proc.arguments = args
        proc.environment = environment
        let out = Pipe()
        proc.standardOutput = out
        proc.standardError = mergeStandardError ? out : FileHandle.nullDevice
        proc.standardInput = nil
        let outputCapture = ProcessPipeCapture(pipe: out)
        outputCapture.start()

        let exitSemaphore = DispatchSemaphore(value: 0)
        proc.terminationHandler = { _ in
            exitSemaphore.signal()
        }

        do {
            try proc.run()
        } catch {
            outputCapture.stop()
            return nil
        }

        let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success
        if !didExit, !Self.forceExit(proc, exitSemaphore: exitSemaphore) {
            outputCapture.stop()
            return nil
        }

        let data = outputCapture.finishSynchronously(timeout: 0.25)
        guard proc.terminationStatus == 0,
              let text = ProcessPipeCapture.decodeUTF8(data)
                  .split(whereSeparator: \.isNewline).first
        else { return nil }
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : trimmed
    }

    private static func forceExit(_ proc: Process, exitSemaphore: DispatchSemaphore) -> Bool {
        guard proc.isRunning else { return true }

        proc.terminate()
        if exitSemaphore.wait(timeout: .now() + 0.5) == .success {
            return true
        }

        guard proc.isRunning else { return true }
        kill(proc.processIdentifier, SIGKILL)
        return exitSemaphore.wait(timeout: .now() + 1.0) == .success
    }
}
