import Foundation

public struct CodexStatusSnapshot: Sendable {
    public let credits: Double?
    public let fiveHourPercentLeft: Int?
    public let weeklyPercentLeft: Int?
    public let fiveHourResetDescription: String?
    public let weeklyResetDescription: String?
    public let fiveHourResetsAt: Date?
    public let weeklyResetsAt: Date?
    public let codexCreditLimit: CodexCreditLimitSnapshot?
    public let rawText: String

    public init(
        credits: Double?,
        fiveHourPercentLeft: Int?,
        weeklyPercentLeft: Int?,
        fiveHourResetDescription: String?,
        weeklyResetDescription: String?,
        fiveHourResetsAt: Date?,
        weeklyResetsAt: Date?,
        codexCreditLimit: CodexCreditLimitSnapshot? = nil,
        rawText: String)
    {
        self.credits = credits
        self.fiveHourPercentLeft = fiveHourPercentLeft
        self.weeklyPercentLeft = weeklyPercentLeft
        self.fiveHourResetDescription = fiveHourResetDescription
        self.weeklyResetDescription = weeklyResetDescription
        self.fiveHourResetsAt = fiveHourResetsAt
        self.weeklyResetsAt = weeklyResetsAt
        self.codexCreditLimit = codexCreditLimit
        self.rawText = rawText
    }
}

public enum CodexStatusProbeError: LocalizedError, Sendable {
    case codexNotInstalled
    case launchBlocked(String)
    case parseFailed(String)
    case timedOut
    case updateRequired(String)

    public var errorDescription: String? {
        switch self {
        case .codexNotInstalled:
            "Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart."
        case let .launchBlocked(message):
            message
        case .parseFailed:
            "Could not parse Codex status; will retry shortly."
        case .timedOut:
            "Codex status probe timed out."
        case let .updateRequired(msg):
            "Codex CLI update needed: \(msg)"
        }
    }
}

/// Runs `codex` inside a PTY, sends `/status`, captures text, and parses credits/limits.
public struct CodexStatusProbe {
    private static let defaultTimeoutSeconds: TimeInterval = 8.0
    private static let parseRetryTimeoutSeconds: TimeInterval = 4.0

    public var codexBinary: String = "codex"
    public var timeout: TimeInterval = Self.defaultTimeoutSeconds
    public var keepCLISessionsAlive: Bool = false
    public var environment: [String: String] = ProcessInfo.processInfo.environment

    public init() {}

    public init(
        codexBinary: String = "codex",
        timeout: TimeInterval = 8.0,
        keepCLISessionsAlive: Bool = false,
        environment: [String: String] = ProcessInfo.processInfo.environment)
    {
        self.codexBinary = codexBinary
        self.timeout = timeout
        self.keepCLISessionsAlive = keepCLISessionsAlive
        self.environment = environment
    }

    public func fetch() async throws -> CodexStatusSnapshot {
        let env = self.environment
        let resolved = BinaryLocator.resolveCodexBinary(env: env, loginPATH: LoginShellPathCache.shared.current)
            ?? self.codexBinary
        guard FileManager.default.isExecutableFile(atPath: resolved) || TTYCommandRunner.which(resolved) != nil else {
            throw CodexStatusProbeError.codexNotInstalled
        }
        if let message = CodexCLILaunchGate.shared.backgroundSkipMessage(binary: resolved) {
            throw CodexStatusProbeError.launchBlocked(message)
        }
        do {
            return try await self.runAndParse(binary: resolved, rows: 60, cols: 200, timeout: self.timeout)
        } catch let error as CodexStatusProbeError {
            // Retry only parser-level flakes with a short second attempt.
            switch error {
            case .parseFailed:
                return try await self.runAndParse(
                    binary: resolved,
                    rows: 70,
                    cols: 220,
                    timeout: Self.parseRetryTimeoutSeconds)
            default:
                throw error
            }
        } catch {
            throw error
        }
    }

    // MARK: - Parsing

    public static func parse(text: String, now: Date = .init()) throws -> CodexStatusSnapshot {
        let clean = TextParsing.stripANSICodes(text)
        guard !clean.isEmpty else { throw CodexStatusProbeError.timedOut }
        if clean.localizedCaseInsensitiveContains("data not available yet") {
            throw CodexStatusProbeError.parseFailed("data not available yet")
        }
        if self.containsUpdatePrompt(clean) {
            throw CodexStatusProbeError.updateRequired(
                "Run `bun install -g @openai/codex` to continue (update prompt blocking /status).")
        }
        let credits = TextParsing.firstNumber(pattern: #"Credits:\s*([0-9][0-9.,]*)"#, text: clean)
        // Pull reset info from the same lines that contain the percentages.
        let fiveLine = TextParsing.firstLine(matching: #"5h limit[^\n]*"#, text: clean)
        let weekLine = TextParsing.firstLine(matching: #"Weekly limit[^\n]*"#, text: clean)
        let fivePct = fiveLine.flatMap(TextParsing.percentLeft(fromLine:))
        let weekPct = weekLine.flatMap(TextParsing.percentLeft(fromLine:))
        let fiveReset = fiveLine.flatMap(TextParsing.resetString(fromLine:))
        let weekReset = weekLine.flatMap(TextParsing.resetString(fromLine:))
        let monthlyLimit = self.parseMonthlyCreditLimit(text: clean, now: now)
        if credits == nil, fivePct == nil, weekPct == nil, monthlyLimit == nil {
            throw CodexStatusProbeError.parseFailed(clean.prefix(400).description)
        }
        return CodexStatusSnapshot(
            credits: credits,
            fiveHourPercentLeft: fivePct,
            weeklyPercentLeft: weekPct,
            fiveHourResetDescription: fiveReset,
            weeklyResetDescription: weekReset,
            fiveHourResetsAt: self.parseResetDate(from: fiveReset, now: now),
            weeklyResetsAt: self.parseResetDate(from: weekReset, now: now),
            codexCreditLimit: monthlyLimit,
            rawText: clean)
    }

    private static func parseMonthlyCreditLimit(text: String, now: Date) -> CodexCreditLimitSnapshot? {
        guard let monthlyLine = TextParsing.firstLine(matching: #"Monthly credit limit[^\n]*"#, text: text) else {
            return nil
        }
        guard let limit = TextParsing.firstNumber(
            pattern: #"of\s+([0-9][0-9., ]*)\s+credits used"#,
            text: text),
            limit > 0
        else {
            return nil
        }
        let used = TextParsing.firstNumber(
            pattern: #"([0-9][0-9., ]*)\s+of\s+[0-9][0-9., ]*\s+credits used"#,
            text: text) ?? 0
        let remainingPercent = TextParsing.percentLeft(fromLine: monthlyLine)
            .map(Double.init)
            ?? max(0, min(100, 100 - (used / limit * 100)))
        let resetText = TextParsing.resetString(fromLine: monthlyLine)
        return CodexCreditLimitSnapshot(
            used: used,
            limit: limit,
            remainingPercent: remainingPercent,
            resetsAt: self.parseResetDate(from: resetText, now: now),
            updatedAt: now)
    }

    private static func parseResetDate(from text: String?, now: Date) -> Date? {
        guard var raw = text?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
        raw = raw.trimmingCharacters(in: CharacterSet(charactersIn: "()"))
        raw = raw.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
            .trimmingCharacters(in: .whitespacesAndNewlines)

        let calendar = Calendar(identifier: .gregorian)
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone.current
        formatter.defaultDate = now

        if let match = raw.firstMatch(of: /^([0-9]{1,2}:[0-9]{2}) on ([0-9]{1,2} [A-Za-z]{3})$/) {
            raw = "\(match.output.2) \(match.output.1)"
            formatter.dateFormat = "d MMM HH:mm"
            if let date = formatter.date(from: raw) {
                return self.bumpYearIfNeeded(date, now: now, calendar: calendar)
            }
        }

        if let match = raw.firstMatch(of: /^([0-9]{1,2}:[0-9]{2}) on ([A-Za-z]{3} [0-9]{1,2})$/) {
            raw = "\(match.output.2) \(match.output.1)"
            formatter.dateFormat = "MMM d HH:mm"
            if let date = formatter.date(from: raw) {
                return self.bumpYearIfNeeded(date, now: now, calendar: calendar)
            }
        }

        for format in ["HH:mm", "H:mm"] {
            formatter.dateFormat = format
            if let time = formatter.date(from: raw) {
                let components = calendar.dateComponents([.hour, .minute], from: time)
                guard let anchored = calendar.date(
                    bySettingHour: components.hour ?? 0,
                    minute: components.minute ?? 0,
                    second: 0,
                    of: now)
                else {
                    return nil
                }
                if anchored >= now {
                    return anchored
                }
                return calendar.date(byAdding: .day, value: 1, to: anchored)
            }
        }

        return nil
    }

    private static func bumpYearIfNeeded(_ date: Date, now: Date, calendar: Calendar) -> Date? {
        if date >= now {
            return date
        }
        return calendar.date(byAdding: .year, value: 1, to: date)
    }

    private func runAndParse(
        binary: String,
        rows: UInt16,
        cols: UInt16,
        timeout: TimeInterval) async throws -> CodexStatusSnapshot
    {
        let stateHome = try CodexStatusProbeIsolation.supportDirectory(environment: self.environment)
        let extraArgs = CodexStatusProbeIsolation.codexArguments(stateHome: stateHome)
        let workingDirectory = CodexStatusProbeIsolation.workingDirectory(environment: self.environment)
        let text: String
        if self.keepCLISessionsAlive {
            do {
                text = try await CodexCLISession.shared.captureStatus(
                    binary: binary,
                    options: .init(
                        timeout: timeout,
                        rows: rows,
                        cols: cols,
                        environment: self.environment,
                        extraArgs: extraArgs,
                        workingDirectory: workingDirectory))
            } catch CodexCLISession.SessionError.processExited {
                throw CodexStatusProbeError.timedOut
            } catch CodexCLISession.SessionError.timedOut {
                throw CodexStatusProbeError.timedOut
            } catch let CodexCLISession.SessionError.launchFailed(message) {
                if let throttled = CodexCLILaunchGate.shared.recordLaunchFailure(binary: binary, message: message) {
                    throw CodexStatusProbeError.launchBlocked(throttled)
                }
                throw CodexStatusProbeError.codexNotInstalled
            }
        } else {
            let runner = TTYCommandRunner()
            let script = "/status"
            let result: TTYCommandRunner.Result
            do {
                result = try runner.run(
                    binary: binary,
                    send: script,
                    options: .init(
                        rows: rows,
                        cols: cols,
                        timeout: timeout,
                        workingDirectory: workingDirectory,
                        extraArgs: extraArgs,
                        baseEnvironment: self.environment,
                        forceCodexStatusMode: true))
            } catch let TTYCommandRunner.Error.launchFailed(message) {
                if let throttled = CodexCLILaunchGate.shared.recordLaunchFailure(binary: binary, message: message) {
                    throw CodexStatusProbeError.launchBlocked(throttled)
                }
                throw CodexStatusProbeError.codexNotInstalled
            }
            text = result.text
        }
        return try Self.parse(text: text)
    }

    private static func containsUpdatePrompt(_ text: String) -> Bool {
        let lower = text.lowercased()
        return lower.contains("update available") && lower.contains("codex")
    }
}
