import Foundation

/// Upper bound on payload + stderr bytes read from the CLI. Real payloads top out near 500 KB
/// (365 days of history with dozens of models); anything larger is pathological and truncating
/// prevents unbounded memory growth. Hard timeout guards against a hung CLI keeping Process and
/// Pipe file descriptors pinned forever.
private let maxPayloadBytes = 20 * 1024 * 1024
private let maxStderrBytes = 256 * 1024
private let spawnTimeoutSeconds: UInt64 = 45
private let maxConcurrentSpawns = 6

enum DataClientError: Error {
    case spawn(String)
    case nonZeroExit(code: Int32, stderr: String)
    case decode(Error)
    case timeout
    case outputTooLarge
}

/// Wraps a `MenubarPayload` decode failure with a bounded snippet of what the CLI
/// actually wrote to stdout (plus stderr), so a malformed-output failure — for
/// example a stray Node banner landing on stdout ahead of the JSON (see #515) —
/// is self-diagnosing in logs and the UI instead of an opaque "not valid JSON".
struct CLIDecodeFailure: Error, CustomStringConvertible {
    let underlying: Error
    let stdoutByteCount: Int
    let stdoutSnippet: String
    let stderr: String

    var description: String {
        var parts = [
            "decode failed: \(underlying)",
            "stdout (\(stdoutByteCount) bytes): \(stdoutSnippet.isEmpty ? "<empty>" : stdoutSnippet)",
        ]
        if !stderr.isEmpty { parts.append("stderr: \(stderr)") }
        return parts.joined(separator: " | ")
    }
}

/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
/// commands through `/bin/zsh -c` anymore.
struct DataClient {
    static func fetch(period: Period,
                      day: String? = nil,
                      days: Set<String> = [],
                      provider: ProviderFilter,
                      includeOptimize: Bool,
                      scope: MenubarScope = .local,
                      claudeConfigSourceId: String? = nil) async throws -> MenubarPayload {
        let subcommand = statusSubcommand(
            period: period,
            day: day,
            days: days,
            provider: provider,
            includeOptimize: includeOptimize,
            scope: scope,
            claudeConfigSourceId: claudeConfigSourceId
        )
        let result = try await runCLI(subcommand: subcommand)
        guard result.exitCode == 0 else {
            throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr)
        }
        do {
            return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout)
        } catch {
            let snippet = String(decoding: result.stdout.prefix(2048), as: UTF8.self)
            throw DataClientError.decode(CLIDecodeFailure(
                underlying: error,
                stdoutByteCount: result.stdout.count,
                stdoutSnippet: snippet,
                stderr: result.stderr
            ))
        }
    }

    static func statusSubcommand(period: Period,
                                 day: String? = nil,
                                 days: Set<String> = [],
                                 provider: ProviderFilter,
                                 includeOptimize: Bool,
                                 scope: MenubarScope = .local,
                                 claudeConfigSourceId: String? = nil) -> [String] {
        let effectiveScope: MenubarScope = days.count > 1 ? .local : scope
        let effectiveProvider: ProviderFilter = effectiveScope == .combined ? .all : provider
        var subcommand = [
            "status",
            "--format", "menubar-json",
            "--provider", effectiveProvider.cliArg,
        ]
        if effectiveScope == .combined {
            subcommand.append(contentsOf: ["--scope", effectiveScope.cliArg])
        }
        if effectiveScope == .local, let claudeConfigSourceId, !claudeConfigSourceId.isEmpty {
            subcommand.append(contentsOf: ["--claude-config-source", claudeConfigSourceId])
        }
        if days.count > 1 {
            subcommand.append(contentsOf: ["--days", days.sorted().joined(separator: ",")])
        } else if let day {
            subcommand.append(contentsOf: ["--day", day])
        } else if let d = days.first {
            subcommand.append(contentsOf: ["--day", d])
        } else {
            subcommand.append(contentsOf: ["--period", period.cliArg])
        }
        if !includeOptimize {
            subcommand.append("--no-optimize")
        }
        return subcommand
    }

    struct ProcessResult {
        let stdout: Data
        let stderr: String
        let exitCode: Int32
    }

    /// Caps concurrent CLI spawns so a wake-burst of refreshes can't fan out into
    /// dozens of node processes at once.
    private static let spawnLimiter = AsyncSemaphore(maxConcurrentSpawns)

    private static func runCLI(subcommand: [String]) async throws -> ProcessResult {
        await spawnLimiter.acquire()
        defer { Task { await spawnLimiter.release() } }
        let process = CodeburnCLI.makeProcess(subcommand: subcommand)
        return try await runProcess(process,
                                    timeoutSeconds: spawnTimeoutSeconds,
                                    label: subcommand.joined(separator: " "))
    }

    /// Runs an already-configured process to completion, draining its output and
    /// enforcing a hard timeout.
    ///
    /// CRITICAL: nothing here may block a worker thread waiting for the process.
    /// `process.waitUntilExit()` is a blocking syscall. An earlier fix moved it
    /// onto a global(qos:.utility) queue with the timeout on that SAME queue — but
    /// under sustained load every utility worker ended up blocked in waitUntilExit,
    /// so the timeout could never be scheduled to kill them and the menubar wedged
    /// on "Loading…" forever (confirmed via sample: threads parked in
    /// waitUntilExit, timeout never firing). Instead we await
    /// `process.terminationHandler`, which fires on a Foundation-managed queue and
    /// blocks nothing, so the timeout always has a free thread to fire on.
    static func runProcess(_ process: Process,
                           timeoutSeconds: UInt64,
                           label: String) async throws -> ProcessResult {
        let outPipe = Pipe()
        let errPipe = Pipe()
        process.standardOutput = outPipe
        process.standardError = errPipe

        // Bridge the process exit to an async signal set up BEFORE run(), so the
        // exit can never be missed and the wait never blocks a worker thread.
        let exitSignal = ProcessExitSignal()
        process.terminationHandler = { _ in exitSignal.fulfill() }

        do {
            try process.run()
        } catch {
            throw DataClientError.spawn(error.localizedDescription)
        }

        let timeoutTimer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
        timeoutTimer.schedule(deadline: .now() + .seconds(Int(timeoutSeconds)))
        timeoutTimer.setEventHandler {
            if process.isRunning {
                NSLog("CodeBurn: CLI subprocess timed out after %llus for %@ — terminating",
                      timeoutSeconds, label)
                terminateWithEscalation(process)
            }
        }
        timeoutTimer.resume()
        defer { timeoutTimer.cancel() }

        let outHandle = outPipe.fileHandleForReading
        let errHandle = errPipe.fileHandleForReading
        let (out, err) = await withTaskCancellationHandler {
            async let stdoutData = drain(outHandle, limit: maxPayloadBytes)
            async let stderrData = drain(errHandle, limit: maxStderrBytes)
            return await (stdoutData, stderrData)
        } onCancel: {
            terminateWithEscalation(process)
        }
        try? outHandle.close()
        try? errHandle.close()
        // Wait for exit via terminationHandler, never by parking a worker thread
        // in waitUntilExit (see the doc comment above for why that wedged).
        await exitSignal.wait()

        if out.count >= maxPayloadBytes {
            throw DataClientError.outputTooLarge
        }

        let stderrString = String(data: err, encoding: .utf8) ?? ""
        return ProcessResult(stdout: out, stderr: stderrString, exitCode: process.terminationStatus)
    }

    private static func terminateWithEscalation(_ process: Process) {
        guard process.isRunning else { return }
        process.terminate()
        let pid = process.processIdentifier
        DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) {
            if process.isRunning { kill(pid, SIGKILL) }
        }
    }

    private static func drain(_ handle: FileHandle, limit: Int) async -> Data {
        let fd = handle.fileDescriptor
        let flags = Darwin.fcntl(fd, F_GETFL)
        if flags >= 0 {
            _ = Darwin.fcntl(fd, F_SETFL, flags | O_NONBLOCK)
        } else {
            NSLog("CodeBurn: fcntl F_GETFL failed on fd %d, drain may block", fd)
        }

        var buffer = Data()
        var chunk = [UInt8](repeating: 0, count: 65_536)

        while buffer.count < limit && !Task.isCancelled {
            let toRead = min(chunk.count, limit - buffer.count)
            let n = chunk.withUnsafeMutableBufferPointer { ptr in
                Darwin.read(fd, ptr.baseAddress!, toRead)
            }
            if n > 0 {
                buffer.append(contentsOf: chunk.prefix(n))
            } else if n == 0 {
                break
            } else if errno == EAGAIN || errno == EWOULDBLOCK {
                try? await Task.sleep(nanoseconds: 5_000_000)
            } else if errno == EINTR {
                continue
            } else {
                NSLog("CodeBurn: drain read() failed on fd %d: errno %d", fd, errno)
                break
            }
        }
        return buffer
    }
}

/// One-shot async signal that bridges `Process.terminationHandler` (invoked on a
/// Foundation-internal queue) to an awaiting task without blocking a worker
/// thread. Safe against fulfill-before-wait.
final class ProcessExitSignal: @unchecked Sendable {
    private let lock = NSLock()
    private var fulfilled = false
    private var continuation: CheckedContinuation<Void, Never>?

    func fulfill() {
        lock.lock()
        if fulfilled { lock.unlock(); return }
        fulfilled = true
        let cont = continuation
        continuation = nil
        lock.unlock()
        cont?.resume()
    }

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

/// Minimal actor-based async semaphore. Caps concurrency without blocking a
/// thread (unlike DispatchSemaphore.wait()).
actor AsyncSemaphore {
    private var available: Int
    private var waiters: [CheckedContinuation<Void, Never>] = []

    init(_ count: Int) { available = count }

    func acquire() async {
        if available > 0 {
            available -= 1
            return
        }
        await withCheckedContinuation { waiters.append($0) }
    }

    func release() {
        if waiters.isEmpty {
            available += 1
        } else {
            waiters.removeFirst().resume()
        }
    }
}
