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

public enum OpenAIAPIUsageError: LocalizedError, Sendable, Equatable {
    case missingCredentials
    case networkError(String)
    case apiError(endpoint: String, statusCode: Int)
    case parseFailed(endpoint: String, message: String)

    public var errorDescription: String? {
        switch self {
        case .missingCredentials:
            "Missing OpenAI Admin API key."
        case let .networkError(message):
            "OpenAI API usage network error: \(message)"
        case let .apiError(endpoint, statusCode):
            "OpenAI API usage \(endpoint) error: HTTP \(statusCode)"
        case let .parseFailed(endpoint, message):
            "Failed to parse OpenAI API usage \(endpoint): \(message)"
        }
    }

    var isCredentialRejected: Bool {
        switch self {
        case let .apiError(_, statusCode):
            statusCode == 401 || statusCode == 403
        default:
            false
        }
    }
}

public enum OpenAIAPIUsageFetcher {
    public static let organizationCostsURL = URL(string: "https://api.openai.com/v1/organization/costs")!
    public static let organizationCompletionsUsageURL =
        URL(string: "https://api.openai.com/v1/organization/usage/completions")!

    private static let maxDailyBucketLimit = 31
    private static let maxPaginationPages = 100
    private static let timeoutSeconds: TimeInterval = 20

    private struct EndpointRequestContext {
        let apiKey: String
        let projectID: String?
        let transport: any ProviderHTTPTransport
        let retryPolicy: ProviderHTTPRetryPolicy
    }

    private struct UsageEndpoint<Bucket> {
        let name: String
        let baseURL: URL
        let queryItems: [URLQueryItem]
        let decodePage: (Data) throws -> UsagePage<Bucket>
    }

    private struct UsagePage<Bucket> {
        let data: [Bucket]
        let hasMore: Bool
        let nextPage: String?
    }

    private typealias SnapshotMetadata = (now: Date, calendar: Calendar, historyDays: Int, projectID: String?)

    public static func fetchUsage(
        apiKey: String,
        projectID: String? = nil,
        costsURL: URL = Self.organizationCostsURL,
        completionsURL: URL = Self.organizationCompletionsUsageURL,
        session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
        now: Date = Date(),
        historyDays: Int = 30,
        retryPolicy: ProviderHTTPRetryPolicy = .transientIdempotent) async throws -> OpenAIAPIUsageSnapshot
    {
        let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else {
            throw OpenAIAPIUsageError.missingCredentials
        }
        let normalizedProjectID = OpenAIAPISettingsReader.cleaned(projectID)

        let calendar = Self.utcCalendar
        let clampedHistoryDays = max(1, min(365, historyDays))
        let ranges = Self.dailyRanges(now: now, calendar: calendar, historyDays: clampedHistoryDays)
        let requestContext = EndpointRequestContext(
            apiKey: trimmed,
            projectID: normalizedProjectID,
            transport: transport,
            retryPolicy: retryPolicy)
        let costs = try await Self.fetchBuckets(
            endpoint: Self.costsEndpoint(baseURL: costsURL),
            context: requestContext,
            ranges: ranges)
        let completions = try await Self.fetchBuckets(
            endpoint: Self.completionsEndpoint(baseURL: completionsURL),
            context: requestContext,
            ranges: ranges)

        return Self.makeSnapshot(
            costs: costs,
            completions: completions,
            metadata: SnapshotMetadata(
                now: now,
                calendar: calendar,
                historyDays: clampedHistoryDays,
                projectID: normalizedProjectID))
    }

    static func _parseSnapshotForTesting(
        costs: Data,
        completions: Data,
        now: Date,
        calendar: Calendar = Self.utcCalendar,
        historyDays: Int = 30,
        projectID: String? = nil) throws -> OpenAIAPIUsageSnapshot
    {
        try self.makeSnapshot(
            costs: self.decodeCosts(costs).data,
            completions: self.decodeCompletions(completions).data,
            metadata: SnapshotMetadata(
                now: now,
                calendar: calendar,
                historyDays: historyDays,
                projectID: OpenAIAPISettingsReader.cleaned(projectID)))
    }

    private static func costsEndpoint(baseURL: URL) -> UsageEndpoint<OpenAICostBucket> {
        UsageEndpoint(
            name: "costs",
            baseURL: baseURL,
            queryItems: [URLQueryItem(name: "group_by", value: "line_item")],
            decodePage: {
                let response = try self.decodeCosts($0)
                return UsagePage(
                    data: response.data,
                    hasMore: response.hasMore,
                    nextPage: response.nextPage)
            })
    }

    private static func completionsEndpoint(
        baseURL: URL) -> UsageEndpoint<OpenAICompletionsUsageBucket>
    {
        UsageEndpoint(
            name: "completions",
            baseURL: baseURL,
            queryItems: [URLQueryItem(name: "group_by", value: "model")],
            decodePage: {
                let response = try self.decodeCompletions($0)
                return UsagePage(
                    data: response.data,
                    hasMore: response.hasMore,
                    nextPage: response.nextPage)
            })
    }

    private static func fetchBuckets<Bucket>(
        endpoint: UsageEndpoint<Bucket>,
        context: EndpointRequestContext,
        ranges: [DateRange]) async throws -> [Bucket]
    {
        var buckets: [Bucket] = []
        for range in ranges {
            var nextPage: String?
            var seenPages: Set<String> = []
            var pageCount = 0

            repeat {
                pageCount += 1
                guard pageCount <= Self.maxPaginationPages else {
                    throw OpenAIAPIUsageError.parseFailed(
                        endpoint: endpoint.name,
                        message: "Pagination exceeded \(Self.maxPaginationPages) pages.")
                }

                let url = Self.url(
                    baseURL: endpoint.baseURL,
                    range: range,
                    queryItems: endpoint.queryItems + Self.projectQueryItems(projectID: context.projectID),
                    page: nextPage)
                let data = try await Self.fetchData(
                    url: url,
                    apiKey: context.apiKey,
                    endpoint: endpoint.name,
                    transport: context.transport,
                    retryPolicy: context.retryPolicy)
                let page = try endpoint.decodePage(data)
                buckets.append(contentsOf: page.data)

                guard page.hasMore else {
                    nextPage = nil
                    continue
                }
                guard let cursor = Self.cleanedPageCursor(page.nextPage) else {
                    throw OpenAIAPIUsageError.parseFailed(
                        endpoint: endpoint.name,
                        message: "Pagination cursor missing.")
                }
                guard seenPages.insert(cursor).inserted else {
                    throw OpenAIAPIUsageError.parseFailed(
                        endpoint: endpoint.name,
                        message: "Pagination cursor repeated.")
                }
                nextPage = cursor
            } while nextPage != nil
        }
        return buckets
    }

    private static func projectQueryItems(projectID: String?) -> [URLQueryItem] {
        guard let projectID else { return [] }
        return [URLQueryItem(name: "project_ids", value: projectID)]
    }

    private static func fetchData(
        url: URL,
        apiKey: String,
        endpoint: String,
        transport: any ProviderHTTPTransport,
        retryPolicy: ProviderHTTPRetryPolicy) async throws -> Data
    {
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.timeoutInterval = Self.timeoutSeconds
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        let response: ProviderHTTPResponse
        do {
            response = try await transport.response(for: request, retryPolicy: retryPolicy)
        } catch {
            throw OpenAIAPIUsageError.networkError(error.localizedDescription)
        }

        guard response.statusCode == 200 else {
            throw OpenAIAPIUsageError.apiError(endpoint: endpoint, statusCode: response.statusCode)
        }
        return response.data
    }

    private static func decodeCosts(_ data: Data) throws -> CostsResponse {
        do {
            return try JSONDecoder().decode(CostsResponse.self, from: data)
        } catch {
            throw OpenAIAPIUsageError.parseFailed(endpoint: "costs", message: error.localizedDescription)
        }
    }

    private static func decodeCompletions(_ data: Data) throws -> CompletionsUsageResponse {
        do {
            return try JSONDecoder().decode(CompletionsUsageResponse.self, from: data)
        } catch {
            throw OpenAIAPIUsageError.parseFailed(endpoint: "completions", message: error.localizedDescription)
        }
    }

    private static func makeSnapshot(
        costs: [OpenAICostBucket],
        completions: [OpenAICompletionsUsageBucket],
        metadata: SnapshotMetadata) -> OpenAIAPIUsageSnapshot
    {
        var accumulators: [Int: DailyAccumulator] = [:]

        for bucket in costs {
            var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator(
                startTime: bucket.startTime,
                endTime: bucket.endTime)
            for result in bucket.results {
                let value = result.amount?.value ?? 0
                accumulator.costUSD += value
                let lineItem = Self.displayName(result.lineItem, fallback: "API")
                accumulator.lineItems[lineItem, default: 0] += value
            }
            accumulators[bucket.startTime] = accumulator
        }

        for bucket in completions {
            var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator(
                startTime: bucket.startTime,
                endTime: bucket.endTime)
            for result in bucket.results {
                let input = result.inputTokens ?? 0
                let cached = result.inputCachedTokens ?? 0
                let output = result.outputTokens ?? 0
                let audioInput = result.inputAudioTokens ?? 0
                let audioOutput = result.outputAudioTokens ?? 0
                let requests = result.numModelRequests ?? 0
                let totalTokens = input + output + audioInput + audioOutput
                accumulator.requests += requests
                accumulator.inputTokens += input + audioInput
                accumulator.cachedInputTokens += cached
                accumulator.outputTokens += output + audioOutput
                accumulator.totalTokens += totalTokens
                let modelName = Self.displayName(result.model, fallback: "Responses and Chat Completions")
                accumulator.models[modelName, default: ModelAccumulator()].add(
                    requests: requests,
                    inputTokens: input + audioInput,
                    cachedInputTokens: cached,
                    outputTokens: output + audioOutput,
                    totalTokens: totalTokens)
            }
            accumulators[bucket.startTime] = accumulator
        }

        let daily = accumulators.values
            .filter { $0.startDate <= metadata.now }
            .sorted { $0.startTime < $1.startTime }
            .map { $0.makeBucket(calendar: metadata.calendar) }
        return OpenAIAPIUsageSnapshot(
            daily: daily,
            updatedAt: metadata.now,
            historyDays: metadata.historyDays,
            projectID: metadata.projectID)
    }

    private static func displayName(_ raw: String?, fallback: String) -> String {
        guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
            return fallback
        }
        return trimmed
    }

    private static func cleanedPageCursor(_ raw: String?) -> String? {
        guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
            return nil
        }
        return trimmed
    }

    private static func url(
        baseURL: URL,
        range: DateRange,
        queryItems extraItems: [URLQueryItem],
        page: String? = nil) -> URL
    {
        var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
        var queryItems = [
            URLQueryItem(name: "start_time", value: String(range.startTime)),
            URLQueryItem(name: "end_time", value: String(range.endTime)),
            URLQueryItem(name: "bucket_width", value: "1d"),
            URLQueryItem(name: "limit", value: String(range.limit)),
        ] + extraItems
        if let page {
            queryItems.append(URLQueryItem(name: "page", value: page))
        }
        components.queryItems = queryItems
        return components.url!
    }

    private static func dailyRanges(now: Date, calendar: Calendar, historyDays: Int) -> [DateRange] {
        let today = calendar.startOfDay(for: now)
        let clampedHistoryDays = max(1, min(365, historyDays))
        var cursor = calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: today) ?? today
        var remainingDays = clampedHistoryDays
        var ranges: [DateRange] = []
        ranges.reserveCapacity(Int(ceil(Double(clampedHistoryDays) / Double(Self.maxDailyBucketLimit))))

        while remainingDays > 0 {
            let chunkDays = min(Self.maxDailyBucketLimit, remainingDays)
            let end = calendar.date(byAdding: .day, value: chunkDays, to: cursor) ?? cursor
            ranges.append(DateRange(
                startTime: Int(cursor.timeIntervalSince1970),
                endTime: Int(end.timeIntervalSince1970),
                limit: chunkDays))
            cursor = end
            remainingDays -= chunkDays
        }
        return ranges
    }

    private static var utcCalendar: Calendar {
        var calendar = Calendar(identifier: .gregorian)
        calendar.locale = Locale(identifier: "en_US_POSIX")
        calendar.timeZone = TimeZone(secondsFromGMT: 0)!
        return calendar
    }
}

private struct DateRange {
    let startTime: Int
    let endTime: Int
    let limit: Int
}

private struct DailyAccumulator {
    let startTime: Int
    let endTime: Int
    var costUSD: Double = 0
    var requests: Int = 0
    var inputTokens: Int = 0
    var cachedInputTokens: Int = 0
    var outputTokens: Int = 0
    var totalTokens: Int = 0
    var lineItems: [String: Double] = [:]
    var models: [String: ModelAccumulator] = [:]

    var startDate: Date {
        Date(timeIntervalSince1970: TimeInterval(self.startTime))
    }

    func makeBucket(calendar: Calendar) -> OpenAIAPIUsageSnapshot.DailyBucket {
        OpenAIAPIUsageSnapshot.DailyBucket(
            day: Self.dayKey(from: self.startDate, calendar: calendar),
            startTime: self.startDate,
            endTime: Date(timeIntervalSince1970: TimeInterval(self.endTime)),
            costUSD: self.costUSD,
            requests: self.requests,
            inputTokens: self.inputTokens,
            cachedInputTokens: self.cachedInputTokens,
            outputTokens: self.outputTokens,
            totalTokens: self.totalTokens,
            lineItems: self.lineItems
                .map { OpenAIAPIUsageSnapshot.LineItemBreakdown(name: $0.key, costUSD: $0.value) }
                .sorted {
                    if $0.costUSD == $1.costUSD { return $0.name < $1.name }
                    return $0.costUSD > $1.costUSD
                },
            models: self.models
                .map { $0.value.makeModel(name: $0.key) }
                .sorted {
                    if $0.totalTokens == $1.totalTokens { return $0.name < $1.name }
                    return $0.totalTokens > $1.totalTokens
                })
    }

    private static func dayKey(from date: Date, calendar: Calendar) -> String {
        let comps = calendar.dateComponents([.year, .month, .day], from: date)
        return String(format: "%04d-%02d-%02d", comps.year ?? 0, comps.month ?? 0, comps.day ?? 0)
    }
}

private struct ModelAccumulator {
    var requests = 0
    var inputTokens = 0
    var cachedInputTokens = 0
    var outputTokens = 0
    var totalTokens = 0

    mutating func add(
        requests: Int,
        inputTokens: Int,
        cachedInputTokens: Int,
        outputTokens: Int,
        totalTokens: Int)
    {
        self.requests += requests
        self.inputTokens += inputTokens
        self.cachedInputTokens += cachedInputTokens
        self.outputTokens += outputTokens
        self.totalTokens += totalTokens
    }

    func makeModel(name: String) -> OpenAIAPIUsageSnapshot.ModelBreakdown {
        OpenAIAPIUsageSnapshot.ModelBreakdown(
            name: name,
            requests: self.requests,
            inputTokens: self.inputTokens,
            cachedInputTokens: self.cachedInputTokens,
            outputTokens: self.outputTokens,
            totalTokens: self.totalTokens)
    }
}
