import Foundation
import SweetCookieKit
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

enum ClaudeWebHTTPTransport {
    #if DEBUG
    @TaskLocal static var overrideForTesting: (any ProviderHTTPTransport)?
    #endif

    static var current: any ProviderHTTPTransport {
        #if DEBUG
        if let override = self.overrideForTesting {
            return override
        }
        #endif
        return ProviderHTTPClient.shared
    }
}

enum ClaudeWebPrepaidCreditsRequest {
    private static let requestTimeout: Duration = .seconds(2)
    #if DEBUG
    @TaskLocal static var timeoutOverrideForTesting: Duration?
    #endif

    static var timeout: Duration {
        #if DEBUG
        self.timeoutOverrideForTesting ?? self.requestTimeout
        #else
        self.requestTimeout
        #endif
    }
}

enum ClaudeWebSessionKeyImport {
    #if DEBUG
    @TaskLocal static var overrideForTesting: ClaudeWebAPIFetcher.SessionKeyInfo?
    #endif

    static var currentOverride: ClaudeWebAPIFetcher.SessionKeyInfo? {
        #if DEBUG
        self.overrideForTesting
        #else
        nil
        #endif
    }
}

private actor ClaudeWebBrowserFetchGate {
    private struct Waiter {
        let id: UUID
        let continuation: CheckedContinuation<Bool, Never>
    }

    private var ownerID: UUID?
    private var waiters: [Waiter] = []

    func acquire(id: UUID) async -> Bool {
        if Task.isCancelled {
            return false
        }
        guard self.ownerID != nil else {
            self.ownerID = id
            return true
        }
        return await withCheckedContinuation { continuation in
            self.waiters.append(Waiter(id: id, continuation: continuation))
        }
    }

    func cancel(id: UUID) {
        if self.ownerID == id {
            return
        }
        guard let index = self.waiters.firstIndex(where: { $0.id == id }) else { return }
        let waiter = self.waiters.remove(at: index)
        waiter.continuation.resume(returning: false)
    }

    func release(id: UUID) {
        guard self.ownerID == id else { return }
        guard !self.waiters.isEmpty else {
            self.ownerID = nil
            return
        }
        let waiter = self.waiters.removeFirst()
        self.ownerID = waiter.id
        waiter.continuation.resume(returning: true)
    }
}

private enum ClaudeWebBrowserFetchSerialization {
    private static let gate = ClaudeWebBrowserFetchGate()

    static func run<T>(_ operation: () async throws -> T) async throws -> T {
        let id = UUID()
        let acquired = await withTaskCancellationHandler {
            await self.gate.acquire(id: id)
        } onCancel: {
            Task { await self.gate.cancel(id: id) }
        }
        guard acquired else { throw CancellationError() }
        do {
            try Task.checkCancellation()
            let value = try await operation()
            await self.gate.release(id: id)
            return value
        } catch {
            await self.gate.release(id: id)
            throw error
        }
    }
}

/// Fetches Claude usage data directly from the claude.ai API using browser session cookies.
///
/// This approach mirrors what Claude Usage Tracker does, but automatically extracts the session key
/// from browser cookies instead of requiring manual setup.
///
/// API endpoints used:
/// - `GET https://claude.ai/api/organizations` → get org UUID
/// - `GET https://claude.ai/api/organizations/{org_id}/usage` → usage percentages + reset times
/// - `GET https://claude.ai/api/organizations/{org_id}/prepaid/credits` → remaining Extra usage balance
public enum ClaudeWebAPIFetcher {
    private static let baseURL = "https://claude.ai/api"
    private static let maxProbeBytes = 200_000
    #if os(macOS)
    private static let cookieClient = BrowserCookieClient()
    private static let cookieImportOrder: BrowserCookieImportOrder =
        ProviderDefaults.metadata[.claude]?.browserCookieOrder ?? Browser.defaultImportOrder
    #else
    private static let cookieImportOrder: BrowserCookieImportOrder = []
    #endif

    public struct OrganizationInfo: Sendable {
        public let id: String
        public let name: String?

        public init(id: String, name: String?) {
            self.id = id
            self.name = name
        }
    }

    public struct SessionKeyInfo: Sendable {
        public let key: String
        public let sourceLabel: String
        public let cookieCount: Int

        public init(key: String, sourceLabel: String, cookieCount: Int) {
            self.key = key
            self.sourceLabel = sourceLabel
            self.cookieCount = cookieCount
        }
    }

    public enum FetchError: LocalizedError, Sendable {
        case noSessionKeyFound
        case invalidSessionKey
        case notSupportedOnThisPlatform
        case networkError(Error)
        case invalidResponse
        case unauthorized
        case serverError(statusCode: Int)
        case noOrganization
        case organizationNotFound(String)

        public var errorDescription: String? {
            switch self {
            case .noSessionKeyFound:
                "No Claude session key found in browser cookies."
            case .invalidSessionKey:
                "Invalid Claude session key format."
            case .notSupportedOnThisPlatform:
                "Claude web fetching is only supported on macOS."
            case let .networkError(error):
                "Network error: \(error.localizedDescription)"
            case .invalidResponse:
                "Invalid response from Claude API."
            case .unauthorized:
                "Sign in to claude.ai (or refresh Claude cookies) to load usage data."
            case let .serverError(code):
                "Claude API error: HTTP \(code)"
            case .noOrganization:
                "No Claude organization found for this account."
            case let .organizationNotFound(id):
                "Claude organization '\(id)' was not found for this session."
            }
        }
    }

    /// Claude usage data from the API
    public struct WebUsageData: Sendable {
        public let sessionPercentUsed: Double
        public let sessionResetsAt: Date?
        public let weeklyPercentUsed: Double?
        public let weeklyResetsAt: Date?
        public let opusPercentUsed: Double?
        public let extraRateWindows: [NamedRateWindow]
        public fileprivate(set) var extraUsageCost: ProviderCostSnapshot?
        public fileprivate(set) var accountOrganization: String?
        public fileprivate(set) var accountOrganizationID: String?
        public fileprivate(set) var accountEmail: String?
        public fileprivate(set) var loginMethod: String?
        /// Whether the API reported a `five_hour` session object. When `false` (the API sent
        /// `five_hour: null`, as enterprise/credit accounts with no live session do), `sessionPercentUsed`
        /// is the synthesized `0` placeholder rather than a real reading. Distinguishing this from a real
        /// session that happens to be at `0%` (with or without a reset) lets lane classifiers drop the
        /// placeholder without hiding a genuine empty session.
        public let hasLiveSessionWindow: Bool

        public init(
            sessionPercentUsed: Double,
            sessionResetsAt: Date?,
            weeklyPercentUsed: Double?,
            weeklyResetsAt: Date?,
            opusPercentUsed: Double?,
            extraRateWindows: [NamedRateWindow],
            extraUsageCost: ProviderCostSnapshot?,
            accountOrganization: String?,
            accountOrganizationID: String? = nil,
            accountEmail: String?,
            loginMethod: String?,
            hasLiveSessionWindow: Bool = true)
        {
            self.sessionPercentUsed = sessionPercentUsed
            self.sessionResetsAt = sessionResetsAt
            self.weeklyPercentUsed = weeklyPercentUsed
            self.weeklyResetsAt = weeklyResetsAt
            self.opusPercentUsed = opusPercentUsed
            self.extraRateWindows = extraRateWindows
            self.extraUsageCost = extraUsageCost
            self.accountOrganization = accountOrganization
            self.accountOrganizationID = accountOrganizationID
            self.accountEmail = accountEmail
            self.loginMethod = loginMethod
            self.hasLiveSessionWindow = hasLiveSessionWindow
        }
    }

    public struct ProbeResult: Sendable {
        public let url: String
        public let statusCode: Int?
        public let contentType: String?
        public let topLevelKeys: [String]
        public let emails: [String]
        public let planHints: [String]
        public let notableFields: [String]
        public let bodyPreview: String?
    }

    private struct CachePersistence {
        let sourceLabel: String
        let expectedObservation: CookieHeaderCache.ConditionalMutationObservation
        let persistInitialSessionKey: Bool
    }

    private struct FetchOptions {
        let targetOrganizationID: String?
        let includeUsageDetails: Bool
        let includePrepaidBalance: Bool
    }
}

extension ClaudeWebAPIFetcher {
    // MARK: - Public API

    #if os(macOS)

    /// Attempts to fetch Claude usage data using cookies extracted from browsers.
    /// Tries browser cookies using the standard import order.
    public static func fetchUsage(
        browserDetection: BrowserDetection,
        targetOrganizationID: String? = nil,
        includeUsageDetails: Bool = true,
        includePrepaidBalance: Bool = true,
        logger: ((String) -> Void)? = nil) async throws -> WebUsageData
    {
        try await ClaudeWebBrowserFetchSerialization.run {
            try await self.fetchUsageSerialized(
                browserDetection: browserDetection,
                options: FetchOptions(
                    targetOrganizationID: targetOrganizationID,
                    includeUsageDetails: includeUsageDetails,
                    includePrepaidBalance: includePrepaidBalance),
                logger: logger)
        }
    }

    public static func fetchUsage(
        cookieHeader: String,
        targetOrganizationID: String? = nil,
        includeUsageDetails: Bool = true,
        includePrepaidBalance: Bool = true,
        logger: ((String) -> Void)? = nil) async throws -> WebUsageData
    {
        let log: (String) -> Void = { msg in logger?("[claude-web] \(msg)") }
        let sessionInfo = try self.sessionKeyInfo(cookieHeader: cookieHeader)
        log("Using manual session key (\(sessionInfo.cookieCount) cookies)")
        return try await self.fetchUsage(
            using: sessionInfo,
            options: FetchOptions(
                targetOrganizationID: targetOrganizationID,
                includeUsageDetails: includeUsageDetails,
                includePrepaidBalance: includePrepaidBalance),
            logger: log,
            cachePersistence: nil)
    }

    public static func fetchUsage(
        using sessionKeyInfo: SessionKeyInfo,
        targetOrganizationID: String? = nil,
        includeUsageDetails: Bool = true,
        includePrepaidBalance: Bool = true,
        logger: ((String) -> Void)? = nil) async throws -> WebUsageData
    {
        try await self.fetchUsage(
            using: sessionKeyInfo,
            options: FetchOptions(
                targetOrganizationID: targetOrganizationID,
                includeUsageDetails: includeUsageDetails,
                includePrepaidBalance: includePrepaidBalance),
            logger: logger,
            cachePersistence: nil)
    }

    private static func fetchUsageAndRenewCache(
        cachedEntry: CookieHeaderCache.Entry,
        options: FetchOptions,
        logger: ((String) -> Void)? = nil) async throws -> WebUsageData
    {
        let sessionInfo = try self.sessionKeyInfo(cookieHeader: cachedEntry.cookieHeader)
        return try await self.fetchUsage(
            using: sessionInfo,
            options: options,
            logger: logger,
            cachePersistence: CachePersistence(
                sourceLabel: cachedEntry.sourceLabel,
                expectedObservation: .authoritative(cachedEntry),
                persistInitialSessionKey: false))
    }

    private static func fetchUsage(
        using sessionKeyInfo: SessionKeyInfo,
        options: FetchOptions,
        logger: ((String) -> Void)?,
        cachePersistence: CachePersistence?) async throws -> WebUsageData
    {
        let log: (String) -> Void = { msg in logger?(msg) }
        let sessionKey = sessionKeyInfo.key
        let renewalTracker = ClaudeWebSessionKeyRenewalTracker(initialSessionKey: sessionKey)

        // Fetch organization info
        let organization = try await fetchOrganizationInfo(
            sessionKey: sessionKey,
            targetOrganizationID: options.targetOrganizationID,
            logger: log,
            renewalTracker: renewalTracker)
        log("Organization resolved")

        var usage = try await fetchUsageData(
            orgId: organization.id,
            sessionKey: renewalTracker.sessionKey,
            logger: log,
            renewalTracker: renewalTracker)
        usage.accountOrganizationID = organization.id
        if options.includeUsageDetails,
           usage.extraUsageCost == nil,
           let extra = try await ClaudeWebExtraUsageCost.fetch(
               baseURL: Self.baseURL,
               orgId: organization.id,
               sessionKey: renewalTracker.sessionKey,
               logger: log,
               renewalTracker: renewalTracker)
        {
            usage.extraUsageCost = extra
        }
        if options.includePrepaidBalance,
           let balance = try await ClaudeWebExtraUsageCost.fetchPrepaidBalance(
               baseURL: Self.baseURL,
               orgId: organization.id,
               sessionKey: renewalTracker.sessionKey,
               logger: log,
               renewalTracker: renewalTracker)
        {
            usage.extraUsageCost = ClaudeWebExtraUsageCost.applyingPrepaidBalance(
                balance,
                to: usage.extraUsageCost)
        }
        if let account = try await fetchAccountInfo(
            sessionKey: renewalTracker.sessionKey,
            orgId: organization.id,
            logger: log,
            renewalTracker: renewalTracker)
        {
            usage.accountEmail = account.email
            usage.loginMethod = account.loginMethod
        }
        if usage.accountOrganization == nil, let name = organization.name {
            usage.accountOrganization = name
        }
        if let cachePersistence {
            self.persistSessionKeyIfNeeded(
                source: (sessionKey, cachePersistence.sourceLabel),
                renewedCookieHeader: renewalTracker.renewedCookieHeader,
                expectedCacheObservation: cachePersistence.expectedObservation,
                persistInitialSessionKey: cachePersistence.persistInitialSessionKey,
                logger: log)
        }
        try Task.checkCancellation()
        return usage
    }

    /// Probes a list of endpoints using the current claude.ai session cookies.
    /// - Parameters:
    ///   - endpoints: Absolute URLs or "/api/..." paths. Supports "{orgId}" placeholder.
    ///   - includePreview: When true, includes a truncated response preview in results.
    public static func probeEndpoints(
        _ endpoints: [String],
        browserDetection: BrowserDetection,
        includePreview: Bool = false,
        logger: ((String) -> Void)? = nil) async throws -> [ProbeResult]
    {
        let log: (String) -> Void = { msg in logger?("[claude-probe] \(msg)") }
        let sessionInfo = try extractSessionKeyInfo(browserDetection: browserDetection, logger: log)
        let sessionKey = sessionInfo.key
        let organization = try? await fetchOrganizationInfo(sessionKey: sessionKey, logger: log)
        let expanded = endpoints.map { endpoint -> String in
            var url = endpoint
            if let orgId = organization?.id {
                url = url.replacingOccurrences(of: "{orgId}", with: orgId)
            }
            if url.hasPrefix("/") {
                url = "https://claude.ai\(url)"
            }
            return url
        }

        var results: [ProbeResult] = []
        results.reserveCapacity(expanded.count)

        for endpoint in expanded {
            guard let url = URL(string: endpoint) else { continue }
            var request = URLRequest(url: url)
            request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")
            request.setValue("application/json, text/html;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept")
            request.httpMethod = "GET"
            request.timeoutInterval = 20

            do {
                let (data, response) = try await ClaudeWebHTTPTransport.current.data(for: request)
                let http = response as? HTTPURLResponse
                let contentType = http?.allHeaderFields["Content-Type"] as? String
                let truncated = data.prefix(Self.maxProbeBytes)
                let body = String(data: truncated, encoding: .utf8) ?? ""

                let parsed = Self.parseProbeBody(data: data, fallbackText: body, contentType: contentType)
                let preview = includePreview ? parsed.preview : nil

                results.append(ProbeResult(
                    url: endpoint,
                    statusCode: http?.statusCode,
                    contentType: contentType,
                    topLevelKeys: parsed.keys,
                    emails: parsed.emails,
                    planHints: parsed.planHints,
                    notableFields: parsed.notableFields,
                    bodyPreview: preview))
            } catch {
                results.append(ProbeResult(
                    url: endpoint,
                    statusCode: nil,
                    contentType: nil,
                    topLevelKeys: [],
                    emails: [],
                    planHints: [],
                    notableFields: [],
                    bodyPreview: "Error: \(error.localizedDescription)"))
            }
        }

        return results
    }

    /// Checks if we can find a Claude session key in browser cookies without making API calls.
    public static func hasSessionKey(browserDetection: BrowserDetection, logger: ((String) -> Void)? = nil) -> Bool {
        if let cached = CookieHeaderCache.load(provider: .claude),
           self.hasSessionKey(cookieHeader: cached.cookieHeader)
        {
            return true
        }
        do {
            _ = try self.sessionKeyInfo(browserDetection: browserDetection, logger: logger)
            return true
        } catch {
            return false
        }
    }

    public static func hasSessionKey(cookieHeader: String?) -> Bool {
        guard let cookieHeader else { return false }
        return (try? self.sessionKeyInfo(cookieHeader: cookieHeader)) != nil
    }

    public static func sessionKeyInfo(
        browserDetection: BrowserDetection,
        logger: ((String) -> Void)? = nil) throws -> SessionKeyInfo
    {
        try self.extractSessionKeyInfo(browserDetection: browserDetection, logger: logger)
    }

    public static func sessionKeyInfo(cookieHeader: String) throws -> SessionKeyInfo {
        let pairs = CookieHeaderNormalizer.pairs(from: cookieHeader)
        if let sessionKey = self.findSessionKey(in: pairs) {
            return SessionKeyInfo(
                key: sessionKey,
                sourceLabel: "Manual",
                cookieCount: pairs.count)
        }
        throw FetchError.noSessionKeyFound
    }

    // MARK: - Session Key Extraction

    private static func extractSessionKeyInfo(
        browserDetection: BrowserDetection,
        logger: ((String) -> Void)? = nil) throws -> SessionKeyInfo
    {
        if let override = ClaudeWebSessionKeyImport.currentOverride {
            return override
        }
        let log: (String) -> Void = { msg in logger?(msg) }

        let cookieDomains = ["claude.ai"]

        // Filter to cookie-eligible browsers to avoid unnecessary keychain prompts
        let installedBrowsers = Self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
        for browserSource in installedBrowsers {
            do {
                let query = BrowserCookieQuery(domains: cookieDomains)
                let sources = try Self.cookieClient.codexBarRecords(
                    matching: query,
                    in: browserSource,
                    logger: log)
                for source in sources {
                    if let sessionKey = findSessionKey(in: source.records.map { record in
                        (name: record.name, value: record.value)
                    }) {
                        log("Found sessionKey in \(source.label)")
                        return SessionKeyInfo(
                            key: sessionKey,
                            sourceLabel: source.label,
                            cookieCount: source.records.count)
                    }
                }
            } catch {
                BrowserCookieAccessGate.recordIfNeeded(error)
                log("\(browserSource.displayName) cookie load failed: \(error.localizedDescription)")
            }
        }

        throw FetchError.noSessionKeyFound
    }

    private static func findSessionKey(in cookies: [(name: String, value: String)]) -> String? {
        for cookie in cookies where cookie.name == "sessionKey" {
            let value = cookie.value.trimmingCharacters(in: .whitespacesAndNewlines)
            // Validate it looks like a Claude session key
            if value.hasPrefix("sk-ant-") {
                return value
            }
        }
        return nil
    }

    // MARK: - API Calls

    private static func fetchOrganizationInfo(
        sessionKey: String,
        targetOrganizationID: String? = nil,
        logger: ((String) -> Void)? = nil,
        renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async throws -> OrganizationInfo
    {
        let url = URL(string: "\(baseURL)/organizations")!
        var request = URLRequest(url: url)
        request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.httpMethod = "GET"
        request.timeoutInterval = 15

        let (data, response) = try await ClaudeWebHTTPTransport.current.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw FetchError.invalidResponse
        }
        renewalTracker?.observe(response: httpResponse)

        logger?("Organizations API status: \(httpResponse.statusCode)")

        switch httpResponse.statusCode {
        case 200:
            return try self.parseOrganizationResponse(data, targetOrganizationID: targetOrganizationID)
        case 401, 403:
            throw FetchError.unauthorized
        default:
            throw FetchError.serverError(statusCode: httpResponse.statusCode)
        }
    }

    private static func fetchUsageData(
        orgId: String,
        sessionKey: String,
        logger: ((String) -> Void)? = nil,
        renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async throws -> WebUsageData
    {
        let url = URL(string: "\(baseURL)/organizations/\(orgId)/usage")!
        var request = URLRequest(url: url)
        request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.httpMethod = "GET"
        request.timeoutInterval = 15

        let (data, response) = try await ClaudeWebHTTPTransport.current.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw FetchError.invalidResponse
        }
        renewalTracker?.observe(response: httpResponse)

        logger?("Usage API status: \(httpResponse.statusCode)")

        switch httpResponse.statusCode {
        case 200:
            return try self.parseUsageResponse(data, logger: logger)
        case 401, 403:
            throw FetchError.unauthorized
        default:
            throw FetchError.serverError(statusCode: httpResponse.statusCode)
        }
    }

    private static func parseUsageResponse(_ data: Data, logger: ((String) -> Void)? = nil) throws -> WebUsageData {
        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            throw FetchError.invalidResponse
        }

        // Parse five_hour (session) usage
        var sessionPercent: Double?
        var sessionResets: Date?
        let fiveHour = json["five_hour"] as? [String: Any]
        if let fiveHour {
            sessionPercent = Self.percentValue(from: fiveHour["utilization"])
            if let resetsAt = fiveHour["resets_at"] as? String {
                sessionResets = self.parseISO8601Date(resetsAt)
            }
        }
        // Enterprise/credit-based accounts return null for five_hour; treat as 0% rather than an error.
        // Track the object's presence so a real 0% session (with or without a reset) is not mistaken for
        // the synthesized null-session placeholder downstream.
        let resolvedSessionPercent = sessionPercent ?? 0.0
        let hasLiveSessionWindow = fiveHour != nil

        // Parse seven_day (weekly) usage
        var weeklyPercent: Double?
        var weeklyResets: Date?
        if let sevenDay = json["seven_day"] as? [String: Any] {
            weeklyPercent = Self.percentValue(from: sevenDay["utilization"])
            if let resetsAt = sevenDay["resets_at"] as? String {
                weeklyResets = self.parseISO8601Date(resetsAt)
            }
        }

        // Parse seven_day_sonnet (preferred) / seven_day_opus usage
        var opusPercent: Double?
        if let sevenDaySonnet = json["seven_day_sonnet"] as? [String: Any] {
            opusPercent = Self.percentValue(from: sevenDaySonnet["utilization"])
        } else if let sevenDayOpus = json["seven_day_opus"] as? [String: Any] {
            opusPercent = Self.percentValue(from: sevenDayOpus["utilization"])
        }
        let extraRateParse = ClaudeWebExtraRateWindowParser.parse(from: json)
        if let sourceKey = extraRateParse.sourceKeys["claude-routines"] {
            logger?("Usage API extra window key matched: routines=\(sourceKey)")
        }
        let extraUsageCost = ClaudeWebExtraUsageCost.parse(from: json["extra_usage"])

        return WebUsageData(
            sessionPercentUsed: resolvedSessionPercent,
            sessionResetsAt: sessionResets,
            weeklyPercentUsed: weeklyPercent,
            weeklyResetsAt: weeklyResets,
            opusPercentUsed: opusPercent,
            extraRateWindows: extraRateParse.windows,
            extraUsageCost: extraUsageCost,
            accountOrganization: nil,
            accountEmail: nil,
            loginMethod: nil,
            hasLiveSessionWindow: hasLiveSessionWindow)
    }

    private static func percentValue(from value: Any?) -> Double? {
        if let intValue = value as? Int {
            return Double(intValue)
        }
        if let doubleValue = value as? Double {
            return doubleValue
        }
        return nil
    }

    #if DEBUG

    // MARK: - Test hooks (DEBUG-only)

    public static func _parseUsageResponseForTesting(_ data: Data) throws -> WebUsageData {
        try self.parseUsageResponse(data)
    }

    public static func _parseOrganizationsResponseForTesting(
        _ data: Data,
        targetOrganizationID: String? = nil) throws -> OrganizationInfo
    {
        try self.parseOrganizationResponse(data, targetOrganizationID: targetOrganizationID)
    }

    public static func _parseOverageSpendLimitForTesting(_ data: Data) -> ProviderCostSnapshot? {
        ClaudeWebExtraUsageCost.parseOverageSpendLimit(data)
    }

    public static func _parsePrepaidCreditsForTesting(_ data: Data) -> ProviderCostSnapshot? {
        guard let balance = ClaudeWebExtraUsageCost.parsePrepaidBalance(data) else { return nil }
        return ClaudeWebExtraUsageCost.applyingPrepaidBalance(balance, to: nil)
    }

    public static func _parseAccountInfoForTesting(_ data: Data, orgId: String?) -> WebAccountInfo? {
        self.parseAccountInfo(data, orgId: orgId)
    }
    #endif

    private static func parseISO8601Date(_ string: String) -> Date? {
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        if let date = formatter.date(from: string) {
            return date
        }
        // Try without fractional seconds
        formatter.formatOptions = [.withInternetDateTime]
        return formatter.date(from: string)
    }

    private static func parseOrganizationResponse(
        _ data: Data,
        targetOrganizationID: String? = nil) throws -> OrganizationInfo
    {
        guard let organizations = try? JSONDecoder().decode([ClaudeWebOrganizationResponse].self, from: data) else {
            throw FetchError.invalidResponse
        }
        if let targetOrganizationID = targetOrganizationID?.trimmingCharacters(in: .whitespacesAndNewlines),
           !targetOrganizationID.isEmpty
        {
            guard let selected = organizations.first(where: { $0.uuid == targetOrganizationID }) else {
                throw FetchError.organizationNotFound(targetOrganizationID)
            }
            return self.organizationInfo(from: selected)
        }
        guard let selected = organizations.first(where: { $0.hasChatCapability })
            ?? organizations.first(where: { !$0.isApiOnly })
            ?? organizations.first
        else {
            throw FetchError.noOrganization
        }
        return self.organizationInfo(from: selected)
    }

    private static func organizationInfo(from selected: ClaudeWebOrganizationResponse) -> OrganizationInfo {
        let name = selected.name?.trimmingCharacters(in: .whitespacesAndNewlines)
        let sanitized = (name?.isEmpty ?? true) ? nil : name
        return OrganizationInfo(id: selected.uuid, name: sanitized)
    }

    public struct WebAccountInfo: Sendable {
        public let email: String?
        public let loginMethod: String?

        public init(email: String?, loginMethod: String?) {
            self.email = email
            self.loginMethod = loginMethod
        }
    }

    private struct AccountResponse: Decodable {
        let emailAddress: String?
        let memberships: [Membership]?

        enum CodingKeys: String, CodingKey {
            case emailAddress = "email_address"
            case memberships
        }

        struct Membership: Decodable {
            let organization: Organization
            let seatTier: String?

            enum CodingKeys: String, CodingKey {
                case organization
                case seatTier = "seat_tier"
            }

            struct Organization: Decodable {
                let uuid: String?
                let name: String?
                let rateLimitTier: String?
                let billingType: String?

                enum CodingKeys: String, CodingKey {
                    case uuid
                    case name
                    case rateLimitTier = "rate_limit_tier"
                    case billingType = "billing_type"
                }
            }
        }
    }

    private static func fetchAccountInfo(
        sessionKey: String,
        orgId: String?,
        logger: ((String) -> Void)? = nil,
        renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async throws -> WebAccountInfo?
    {
        let url = URL(string: "\(baseURL)/account")!
        var request = URLRequest(url: url)
        request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.httpMethod = "GET"
        request.timeoutInterval = 15

        do {
            let response = try await ClaudeWebHTTPTransport.current.response(for: request)
            renewalTracker?.observe(response: response.response)
            logger?("Account API status: \(response.statusCode)")
            guard response.statusCode == 200 else { return nil }
            return Self.parseAccountInfo(response.data, orgId: orgId)
        } catch {
            if error is CancellationError ||
                (error as? URLError)?.code == .cancelled ||
                Task.isCancelled
            {
                throw CancellationError()
            }
            return nil
        }
    }

    private static func parseAccountInfo(_ data: Data, orgId: String?) -> WebAccountInfo? {
        guard let response = try? JSONDecoder().decode(AccountResponse.self, from: data) else { return nil }
        let email = response.emailAddress?.trimmingCharacters(in: .whitespacesAndNewlines)
        let membership = Self.selectMembership(response.memberships, orgId: orgId)
        let plan = ClaudePlan.webLoginMethod(
            rateLimitTier: membership?.organization.rateLimitTier,
            billingType: membership?.organization.billingType,
            seatTier: membership?.seatTier)
        return WebAccountInfo(email: email, loginMethod: plan)
    }

    private static func selectMembership(
        _ memberships: [AccountResponse.Membership]?,
        orgId: String?) -> AccountResponse.Membership?
    {
        guard let memberships, !memberships.isEmpty else { return nil }
        if let orgId {
            if let match = memberships.first(where: { $0.organization.uuid == orgId }) {
                return match
            }
        }
        return memberships.first
    }

    private struct ProbeParseResult {
        let keys: [String]
        let emails: [String]
        let planHints: [String]
        let notableFields: [String]
        let preview: String?
    }

    private static func parseProbeBody(
        data: Data,
        fallbackText: String,
        contentType: String?) -> ProbeParseResult
    {
        let trimmed = fallbackText.trimmingCharacters(in: .whitespacesAndNewlines)
        let looksJSON = (contentType?.lowercased().contains("application/json") ?? false) ||
            trimmed.hasPrefix("{") || trimmed.hasPrefix("[")

        var keys: [String] = []
        var notableFields: [String] = []
        if looksJSON, let json = try? JSONSerialization.jsonObject(with: data) {
            if let dict = json as? [String: Any] {
                keys = dict.keys.sorted()
            } else if let array = json as? [[String: Any]], let first = array.first {
                keys = first.keys.sorted()
            }
            notableFields = Self.extractNotableFields(from: json)
        }

        let emails = Self.extractEmails(from: trimmed)
        let planHints = Self.extractPlanHints(from: trimmed)
        let preview = trimmed.isEmpty ? nil : String(trimmed.prefix(500))
        return ProbeParseResult(
            keys: keys,
            emails: emails,
            planHints: planHints,
            notableFields: notableFields,
            preview: preview)
    }

    private static func extractEmails(from text: String) -> [String] {
        let pattern = #"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#
        guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
        let range = NSRange(text.startIndex..<text.endIndex, in: text)
        var results: [String] = []
        regex.enumerateMatches(in: text, options: [], range: range) { match, _, _ in
            guard let match, let r = Range(match.range(at: 0), in: text) else { return }
            let value = String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines)
            if !value.isEmpty {
                results.append(value)
            }
        }
        return Array(Set(results)).sorted()
    }

    private static func extractPlanHints(from text: String) -> [String] {
        let pattern = #"(?i)\b(max|pro|team|ultra|enterprise)\b"#
        guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
        let range = NSRange(text.startIndex..<text.endIndex, in: text)
        var results: [String] = []
        regex.enumerateMatches(in: text, options: [], range: range) { match, _, _ in
            guard let match, let r = Range(match.range(at: 1), in: text) else { return }
            let value = String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines)
            if !value.isEmpty {
                results.append(value)
            }
        }
        return Array(Set(results)).sorted()
    }

    private static func extractNotableFields(from json: Any) -> [String] {
        let pattern = #"(?i)(plan|tier|subscription|seat|billing|product)"#
        guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
        var results: [String] = []

        func keyMatches(_ key: String) -> Bool {
            let range = NSRange(key.startIndex..<key.endIndex, in: key)
            return regex.firstMatch(in: key, options: [], range: range) != nil
        }

        func appendValue(_ keyPath: String, value: Any) {
            if results.count >= 40 {
                return
            }
            let rendered: String
            switch value {
            case let str as String:
                rendered = str
            case let num as NSNumber:
                rendered = num.stringValue
            case let bool as Bool:
                rendered = bool ? "true" : "false"
            default:
                return
            }
            let trimmed = rendered.trimmingCharacters(in: .whitespacesAndNewlines)
            guard !trimmed.isEmpty else { return }
            results.append("\(keyPath)=\(trimmed)")
        }

        func walk(_ value: Any, path: String) {
            if let dict = value as? [String: Any] {
                for (key, nested) in dict {
                    let nextPath = path.isEmpty ? key : "\(path).\(key)"
                    if keyMatches(key) {
                        appendValue(nextPath, value: nested)
                    }
                    walk(nested, path: nextPath)
                }
            } else if let array = value as? [Any] {
                for (idx, nested) in array.enumerated() {
                    let nextPath = "\(path)[\(idx)]"
                    walk(nested, path: nextPath)
                }
            }
        }

        walk(json, path: "")
        return results
    }

    #else

    public static func fetchUsage(logger: ((String) -> Void)? = nil) async throws -> WebUsageData {
        throw FetchError.notSupportedOnThisPlatform
    }

    public static func fetchUsage(
        browserDetection: BrowserDetection,
        targetOrganizationID: String? = nil,
        includeUsageDetails: Bool = true,
        includePrepaidBalance: Bool = true,
        logger: ((String) -> Void)? = nil) async throws -> WebUsageData
    {
        _ = browserDetection
        _ = targetOrganizationID
        _ = includeUsageDetails
        _ = includePrepaidBalance
        _ = logger
        throw FetchError.notSupportedOnThisPlatform
    }

    public static func fetchUsage(
        cookieHeader: String,
        targetOrganizationID: String? = nil,
        includeUsageDetails: Bool = true,
        includePrepaidBalance: Bool = true,
        logger: ((String) -> Void)? = nil) async throws -> WebUsageData
    {
        _ = cookieHeader
        _ = targetOrganizationID
        _ = includeUsageDetails
        _ = includePrepaidBalance
        _ = logger
        throw FetchError.notSupportedOnThisPlatform
    }

    public static func fetchUsage(
        using sessionKeyInfo: SessionKeyInfo,
        targetOrganizationID: String? = nil,
        includeUsageDetails: Bool = true,
        includePrepaidBalance: Bool = true,
        logger: ((String) -> Void)? = nil) async throws -> WebUsageData
    {
        _ = targetOrganizationID
        _ = includeUsageDetails
        _ = includePrepaidBalance
        throw FetchError.notSupportedOnThisPlatform
    }

    public static func probeEndpoints(
        _ endpoints: [String],
        includePreview: Bool = false,
        logger: ((String) -> Void)? = nil) async throws -> [ProbeResult]
    {
        throw FetchError.notSupportedOnThisPlatform
    }

    public static func hasSessionKey(browserDetection: BrowserDetection, logger: ((String) -> Void)? = nil) -> Bool {
        _ = browserDetection
        _ = logger
        return false
    }

    public static func hasSessionKey(cookieHeader: String?) -> Bool {
        guard let cookieHeader else { return false }
        for pair in CookieHeaderNormalizer.pairs(from: cookieHeader) where pair.name == "sessionKey" {
            let value = pair.value.trimmingCharacters(in: .whitespacesAndNewlines)
            if value.hasPrefix("sk-ant-") {
                return true
            }
        }
        return false
    }

    public static func sessionKeyInfo(logger: ((String) -> Void)? = nil) throws -> SessionKeyInfo {
        throw FetchError.notSupportedOnThisPlatform
    }

    #endif
}

extension ClaudeWebAPIFetcher {
    private static func persistSessionKeyIfNeeded(
        source: (initialSessionKey: String, label: String),
        renewedCookieHeader: String?,
        expectedCacheObservation: CookieHeaderCache.ConditionalMutationObservation,
        persistInitialSessionKey: Bool,
        logger: (String) -> Void)
    {
        let importedCookieHeader = persistInitialSessionKey ? "sessionKey=\(source.initialSessionKey)" : nil
        guard let cookieHeader = renewedCookieHeader ?? importedCookieHeader else { return }
        let stored = CookieHeaderCache.storeIfObservationCurrent(
            provider: .claude,
            expected: expectedCacheObservation,
            cookieHeader: cookieHeader,
            sourceLabel: source.label)
        if renewedCookieHeader != nil {
            logger(stored ? "Stored renewed Claude session key" : "Skipped renewal because the cache changed")
        }
    }
}

private final class ClaudeWebSessionKeyRenewalTracker: @unchecked Sendable {
    private let initialSessionKey: String
    private let lock = NSLock()
    private var currentSessionKey: String

    init(initialSessionKey: String) {
        self.initialSessionKey = initialSessionKey
        self.currentSessionKey = initialSessionKey
    }

    var sessionKey: String {
        self.lock.lock()
        defer { self.lock.unlock() }
        return self.currentSessionKey
    }

    var renewedCookieHeader: String? {
        self.lock.lock()
        defer { self.lock.unlock() }
        guard self.currentSessionKey != self.initialSessionKey else { return nil }
        return "sessionKey=\(self.currentSessionKey)"
    }

    func observe(response: HTTPURLResponse) {
        guard response.statusCode == 200,
              let sessionKey = Self.sessionKey(fromSetCookieHeaders: response.allHeaderFields)
        else {
            return
        }
        self.lock.lock()
        self.currentSessionKey = sessionKey
        self.lock.unlock()
    }

    private static func sessionKey(fromSetCookieHeaders fields: [AnyHashable: Any]) -> String? {
        guard let value = fields.first(where: {
            String(describing: $0.key).caseInsensitiveCompare("Set-Cookie") == .orderedSame
        })?.value else {
            return nil
        }
        var latestSessionKey: String?
        for header in self.setCookieHeaderValues(from: value) {
            latestSessionKey = self.sessionKey(fromSetCookieHeader: header) ?? latestSessionKey
        }
        return latestSessionKey
    }

    private static func setCookieHeaderValues(from value: Any) -> [String] {
        if let values = value as? [String] {
            return values
        }
        if let values = value as? [Any] {
            return values.map { String(describing: $0) }
        }
        return [String(describing: value)]
    }

    private static func sessionKey(fromSetCookieHeader header: String) -> String? {
        let pattern = #"(?i)(?:^|[,\r\n])\s*sessionKey=([^;,\r\n]+)"#
        guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
        let range = NSRange(header.startIndex..<header.endIndex, in: header)
        var latestSessionKey: String?
        for match in regex.matches(in: header, range: range) {
            guard match.numberOfRanges >= 2,
                  let valueRange = Range(match.range(at: 1), in: header)
            else {
                continue
            }
            let value = String(header[valueRange]).trimmingCharacters(in: .whitespacesAndNewlines)
            if value.hasPrefix("sk-ant-") {
                latestSessionKey = value
            }
        }
        return latestSessionKey
    }
}

private enum ClaudeWebExtraUsageCost {
    // MARK: - Extra usage cost (Claude "Extra")

    struct PrepaidBalance: Equatable, Sendable {
        let amount: Double
        let currencyCode: String
    }

    private struct PrepaidCreditsResponse: Decodable {
        let amount: Double
        let currency: String
    }

    static func parse(from value: Any?) -> ProviderCostSnapshot? {
        guard let extraUsage = value as? [String: Any] else { return nil }
        guard let used = Self.doubleValue(extraUsage["used_credits"]),
              let limit = Self.doubleValue(extraUsage["monthly_limit"] ?? extraUsage["monthly_credit_limit"]),
              limit > 0 else { return nil }
        let currency = (extraUsage["currency"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
        let currencyCode = currency?.isEmpty == false ? currency ?? "USD" : "USD"
        return Self.makeExtraUsageCost(
            usedCredits: used,
            monthlyCreditLimit: limit,
            currencyCode: currencyCode)
    }

    struct OverageSpendLimitResponse: Decodable {
        let monthlyCreditLimit: Double?
        let currency: String?
        let usedCredits: Double?
        let isEnabled: Bool?

        enum CodingKeys: String, CodingKey {
            case monthlyCreditLimit = "monthly_credit_limit"
            case currency
            case usedCredits = "used_credits"
            case isEnabled = "is_enabled"
        }
    }

    /// Best-effort fetch of Claude Extra spend/limit (does not fail the main usage fetch).
    static func fetch(
        baseURL: String,
        orgId: String,
        sessionKey: String,
        logger: ((String) -> Void)? = nil,
        renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async throws -> ProviderCostSnapshot?
    {
        let data = try await Self.fetchBestEffortJSON(
            request: BestEffortRequest(
                url: URL(string: "\(baseURL)/organizations/\(orgId)/overage_spend_limit"),
                requestTimeout: 15,
                joinGrace: .seconds(15),
                logLabel: "Overage"),
            sessionKey: sessionKey,
            logger: logger,
            renewalTracker: renewalTracker)
        return data.flatMap(Self.parseOverageSpendLimit)
    }

    static func parseOverageSpendLimit(_ data: Data) -> ProviderCostSnapshot? {
        guard let decoded = try? JSONDecoder().decode(OverageSpendLimitResponse.self, from: data) else { return nil }
        guard decoded.isEnabled == true else { return nil }
        guard let used = decoded.usedCredits,
              let limit = decoded.monthlyCreditLimit,
              let currency = decoded.currency,
              !currency.isEmpty else { return nil }

        return Self.makeExtraUsageCost(
            usedCredits: used,
            monthlyCreditLimit: limit,
            currencyCode: currency)
    }

    /// Best-effort fetch of Claude's remaining prepaid Extra usage balance.
    static func fetchPrepaidBalance(
        baseURL: String,
        orgId: String,
        sessionKey: String,
        logger: ((String) -> Void)? = nil,
        renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async throws -> PrepaidBalance?
    {
        let data = try await Self.fetchBestEffortJSON(
            request: BestEffortRequest(
                url: URL(string: "\(baseURL)/organizations/\(orgId)/prepaid/credits"),
                requestTimeout: 15,
                joinGrace: ClaudeWebPrepaidCreditsRequest.timeout,
                logLabel: "Prepaid credits"),
            sessionKey: sessionKey,
            logger: logger,
            renewalTracker: renewalTracker)
        return data.flatMap(Self.parsePrepaidBalance)
    }

    private static func fetchBestEffortJSON(
        request spec: BestEffortRequest,
        sessionKey: String,
        logger: ((String) -> Void)?,
        renewalTracker: ClaudeWebSessionKeyRenewalTracker?) async throws -> Data?
    {
        guard let url = spec.url else { return nil }
        var request = URLRequest(url: url)
        request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.httpMethod = "GET"
        request.timeoutInterval = spec.requestTimeout

        let transport = ClaudeWebHTTPTransport.current
        let sourceTask = Task<ProviderHTTPResponse, Error> {
            try await transport.response(for: request)
        }
        let race = BoundedTaskJoin(sourceTask: sourceTask)
        switch await race.value(joinGrace: spec.joinGrace) {
        case let .value(payload):
            try Task.checkCancellation()
            renewalTracker?.observe(response: payload.response)
            logger?("\(spec.logLabel) API status: \(payload.statusCode)")
            guard payload.statusCode == 200 else { return nil }
            return payload.data
        case let .failure(error):
            if error is CancellationError ||
                (error as? URLError)?.code == .cancelled ||
                Task.isCancelled
            {
                throw CancellationError()
            }
            return nil
        case .timedOut:
            try Task.checkCancellation()
            return nil
        }
    }

    static func parsePrepaidBalance(_ data: Data) -> PrepaidBalance? {
        guard let response = try? JSONDecoder().decode(PrepaidCreditsResponse.self, from: data),
              response.amount.isFinite,
              response.amount >= 0
        else { return nil }
        let currency = response.currency.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
        guard !currency.isEmpty else { return nil }
        return PrepaidBalance(amount: response.amount / 100.0, currencyCode: currency)
    }

    static func applyingPrepaidBalance(
        _ balance: PrepaidBalance,
        to cost: ProviderCostSnapshot?) -> ProviderCostSnapshot
    {
        if let cost {
            guard cost.currencyCode.caseInsensitiveCompare(balance.currencyCode) == .orderedSame else {
                return cost
            }
            return cost.replacing(balance: balance.amount)
        }
        return ProviderCostSnapshot(
            used: 0,
            limit: 0,
            currencyCode: balance.currencyCode,
            period: "Extra usage",
            balance: balance.amount,
            updatedAt: Date())
    }

    private struct BestEffortRequest {
        let url: URL?
        let requestTimeout: TimeInterval
        let joinGrace: Duration
        let logLabel: String
    }

    static func makeExtraUsageCost(
        usedCredits: Double,
        monthlyCreditLimit: Double,
        currencyCode: String) -> ProviderCostSnapshot
    {
        let usedAmount = usedCredits / 100.0
        let limitAmount = monthlyCreditLimit / 100.0

        return ProviderCostSnapshot(
            used: usedAmount,
            limit: limitAmount,
            currencyCode: currencyCode,
            period: "Monthly cap",
            resetsAt: nil,
            updatedAt: Date())
    }

    static func doubleValue(_ value: Any?) -> Double? {
        switch value {
        case let int as Int:
            Double(int)
        case let double as Double:
            double
        case let string as String:
            Double(string)
        default:
            nil
        }
    }
}

#if os(macOS)
extension ClaudeWebAPIFetcher {
    private static func fetchUsageSerialized(
        browserDetection: BrowserDetection,
        options: FetchOptions,
        logger: ((String) -> Void)?) async throws -> WebUsageData
    {
        let log: (String) -> Void = { msg in logger?("[claude-web] \(msg)") }
        var cacheObservation = CookieHeaderCache.observeForConditionalMutation(provider: .claude)
        var invalidatedCacheError: FetchError?

        if let cached = cacheObservation.entry,
           !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        {
            log("Using cached cookie header from \(cached.sourceLabel)")
            do {
                return try await self.fetchUsageAndRenewCache(
                    cachedEntry: cached,
                    options: options,
                    logger: log)
            } catch let error as FetchError {
                switch error {
                case .unauthorized, .noSessionKeyFound, .invalidSessionKey:
                    let cleared = CookieHeaderCache.clearIfCurrent(provider: .claude, expected: cached)
                    cacheObservation = .authoritative(cleared ? nil : cached)
                    invalidatedCacheError = error
                default:
                    throw error
                }
            } catch {
                throw error
            }
        }

        // The claude.ai session cookie can rotate independently of the user's signed-in state, so a background
        // refresh can see a cached cookie go stale even when the user never signed out. Still attempt browser
        // recovery here rather than assuming it will fail: BrowserCookieAccessGate already gates the read on its
        // own no-UI preflight (Safari never needs Keychain decryption, and a Chromium browser with a prior
        // "Always Allow" Keychain grant is also read without a prompt), so a background attempt is not
        // unconditionally denied. Only if that attempt itself comes back empty do we surface the original,
        // more informative cached-auth error instead of a misleading "no session key found" — mirroring the
        // equivalent Ollama recovery in `OllamaStatusFetchStrategy.fetchAutomatic`.
        do {
            let sessionInfo = try extractSessionKeyInfo(browserDetection: browserDetection, logger: log)
            log("Found session key (\(sessionInfo.cookieCount) cookies)")

            return try await self.fetchUsage(
                using: sessionInfo,
                options: options,
                logger: log,
                cachePersistence: CachePersistence(
                    sourceLabel: sessionInfo.sourceLabel,
                    expectedObservation: cacheObservation,
                    persistInitialSessionKey: true))
        } catch {
            if let invalidatedCacheError {
                throw invalidatedCacheError
            }
            throw error
        }
    }
}
#endif

private struct ClaudeWebOrganizationResponse: Decodable {
    let uuid: String
    let name: String?
    let capabilities: [String]?

    var normalizedCapabilities: Set<String> {
        Set((self.capabilities ?? []).map { $0.lowercased() })
    }

    var hasChatCapability: Bool {
        self.normalizedCapabilities.contains("chat")
    }

    var isApiOnly: Bool {
        let normalized = self.normalizedCapabilities
        return !normalized.isEmpty && normalized == ["api"]
    }
}
