import Foundation
import Testing
@testable import CodexBarCore

@Suite(.serialized)
struct CodebuffUsageFetcherTests {
    @Test
    func `usage URL composes the correct endpoint`() throws {
        let base = try #require(URL(string: "https://www.codebuff.com"))
        let url = CodebuffUsageFetcher.usageURL(baseURL: base)
        #expect(url.absoluteString == "https://www.codebuff.com/api/v1/usage")
    }

    @Test
    func `subscription URL composes the correct endpoint`() throws {
        let base = try #require(URL(string: "https://www.codebuff.com"))
        let url = CodebuffUsageFetcher.subscriptionURL(baseURL: base)
        #expect(url.absoluteString == "https://www.codebuff.com/api/user/subscription")
    }

    @Test
    func `usage request sends required fingerprint id`() async throws {
        defer {
            CodebuffStubURLProtocol.handler = nil
            CodebuffStubURLProtocol.requests = []
            CodebuffStubURLProtocol.requestBodies = []
        }
        CodebuffStubURLProtocol.requests = []
        CodebuffStubURLProtocol.requestBodies = []
        CodebuffStubURLProtocol.handler = { request in
            guard let url = request.url else { throw URLError(.badURL) }
            switch url.path {
            case "/api/v1/usage":
                return try Self.makeResponse(url: url, body: #"{"usage":25,"quota":100,"remainingBalance":75}"#)
            case "/api/user/subscription":
                return try Self.makeResponse(url: url, body: "{}")
            default:
                return try Self.makeResponse(url: url, body: "{}", statusCode: 404)
            }
        }

        let snapshot = try await CodebuffUsageFetcher.fetchUsage(apiKey: "cb-test", session: Self.makeSession())
        let usageIndex = try #require(CodebuffStubURLProtocol.requests.firstIndex {
            $0.url?.path == "/api/v1/usage"
        })
        let body = try #require(CodebuffStubURLProtocol.requestBodies[usageIndex])
        let payload = try #require(JSONSerialization.jsonObject(with: body) as? [String: String])

        #expect(payload["fingerprintId"] == "codexbar-usage")
        #expect(snapshot.creditsUsed == 25)
    }

    @Test
    func `usage fetch can skip subscription endpoint for API key tokens`() async throws {
        defer {
            CodebuffStubURLProtocol.handler = nil
            CodebuffStubURLProtocol.requests = []
            CodebuffStubURLProtocol.requestBodies = []
        }
        CodebuffStubURLProtocol.requests = []
        CodebuffStubURLProtocol.requestBodies = []
        CodebuffStubURLProtocol.handler = { request in
            guard let url = request.url else { throw URLError(.badURL) }
            switch url.path {
            case "/api/v1/usage":
                return try Self.makeResponse(url: url, body: #"{"usage":25,"quota":100,"remainingBalance":75}"#)
            case "/api/user/subscription":
                Issue.record("Subscription endpoint should not be called for API key tokens")
                return try Self.makeResponse(url: url, body: "{}", statusCode: 500)
            default:
                return try Self.makeResponse(url: url, body: "{}", statusCode: 404)
            }
        }

        let snapshot = try await CodebuffUsageFetcher.fetchUsage(
            apiKey: "cb-test",
            includeSubscription: false,
            session: Self.makeSession())

        #expect(snapshot.creditsUsed == 25)
        #expect(CodebuffStubURLProtocol.requests.map(\.url?.path) == ["/api/v1/usage"])
    }

    @Test
    func `subscription grace does not wait for transport that ignores cancellation`() async throws {
        let transport = ProviderHTTPTransportStub { request in
            let url = try #require(request.url)
            if url.path == "/api/v1/usage" {
                let response = try Self.makeResponse(
                    url: url,
                    body: #"{"usage":25,"quota":100,"remainingBalance":75}"#)
                return (response.1, response.0)
            }
            let response = try Self.makeResponse(
                url: url,
                body: #"{"subscription":{"displayName":"Pro","status":"active"}}"#)
            return await withCheckedContinuation { continuation in
                DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
                    continuation.resume(returning: (response.1, response.0))
                }
            }
        }

        let startedAt = ContinuousClock.now
        let snapshot = try await CodebuffUsageFetcher._fetchUsageForTesting(
            apiKey: "cb-test",
            transport: transport,
            subscriptionGrace: .milliseconds(20))
        let elapsed = startedAt.duration(to: .now)

        #expect(snapshot.creditsUsed == 25)
        #expect(snapshot.tier == nil)
        #expect(elapsed < .milliseconds(300), "Subscription enrichment delayed usage: \(elapsed)")

        // Let the deliberately cancellation-ignoring test task drain before the test exits.
        try await Task.sleep(for: .milliseconds(550))
    }

    @Test
    func `cancellation stops subscription while usage transport ignores cancellation`() async throws {
        let usageStarted = CodebuffRequestGate()
        let subscriptionStarted = CodebuffRequestGate()
        let subscriptionCancelled = CodebuffRequestGate()
        let transport = ProviderHTTPTransportStub { request in
            let url = try #require(request.url)
            if url.path == "/api/v1/usage" {
                await usageStarted.open()
                let response = try Self.makeResponse(
                    url: url,
                    body: #"{"usage":25,"quota":100,"remainingBalance":75}"#)
                return await withCheckedContinuation { continuation in
                    DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
                        continuation.resume(returning: (response.1, response.0))
                    }
                }
            }

            await subscriptionStarted.open()
            do {
                try await Task.sleep(for: .seconds(10))
            } catch {
                await subscriptionCancelled.open()
                throw error
            }
            let response = try Self.makeResponse(
                url: url,
                body: #"{"subscription":{"displayName":"Pro","status":"active"}}"#)
            return (response.1, response.0)
        }
        let task = Task {
            try await CodebuffUsageFetcher.fetchUsage(
                apiKey: "cb-test",
                session: transport)
        }

        await usageStarted.wait()
        await subscriptionStarted.wait()
        let cancellationStartedAt = ContinuousClock.now
        task.cancel()

        await subscriptionCancelled.wait()
        #expect(cancellationStartedAt.duration(to: .now) < .milliseconds(300))
        await #expect(throws: CancellationError.self) {
            try await task.value
        }
    }

    @Test
    func `api strategy only fetches subscription for credentials file tokens`() {
        let envResolution = ProviderTokenResolution(token: "env-token", source: .environment)
        let fileResolution = ProviderTokenResolution(token: "file-token", source: .authFile)

        #expect(CodebuffAPIFetchStrategy.shouldFetchSubscription(for: envResolution) == false)
        #expect(CodebuffAPIFetchStrategy.shouldFetchSubscription(for: fileResolution) == true)
    }

    @Test
    func `status 401 maps to unauthorized`() {
        #expect(CodebuffUsageFetcher._statusErrorForTesting(401) == .unauthorized)
        #expect(CodebuffUsageFetcher._statusErrorForTesting(403) == .unauthorized)
    }

    @Test
    func `status 404 maps to endpoint not found`() {
        #expect(CodebuffUsageFetcher._statusErrorForTesting(404) == .endpointNotFound)
    }

    @Test
    func `status 500 maps to service unavailable`() {
        guard case .serviceUnavailable(503) = CodebuffUsageFetcher._statusErrorForTesting(503)
        else {
            Issue.record("Expected .serviceUnavailable(503)")
            return
        }
    }

    @Test
    func `status 200 returns nil`() {
        #expect(CodebuffUsageFetcher._statusErrorForTesting(200) == nil)
    }

    @Test
    func `usage payload parses numeric credit fields`() throws {
        let json = """
        {
          "usage": 1250,
          "quota": 5000,
          "remainingBalance": 3750,
          "autoTopupEnabled": true,
          "next_quota_reset": "2026-05-01T00:00:00Z"
        }
        """

        let payload = try CodebuffUsageFetcher._parseUsagePayloadForTesting(Data(json.utf8))
        #expect(payload.used == 1250)
        #expect(payload.total == 5000)
        #expect(payload.remaining == 3750)
        #expect(payload.autoTopupEnabled == true)
        #expect(payload.nextQuotaReset != nil)
    }

    @Test
    func `usage payload accepts string-encoded numbers`() throws {
        let json = """
        { "usage": "12", "quota": "100", "remainingBalance": "88" }
        """
        let payload = try CodebuffUsageFetcher._parseUsagePayloadForTesting(Data(json.utf8))
        #expect(payload.used == 12)
        #expect(payload.total == 100)
        #expect(payload.remaining == 88)
    }

    @Test
    func `usage payload returns nil fields when absent`() throws {
        let payload = try CodebuffUsageFetcher._parseUsagePayloadForTesting(Data("{}".utf8))
        #expect(payload.used == nil)
        #expect(payload.total == nil)
        #expect(payload.remaining == nil)
        #expect(payload.autoTopupEnabled == nil)
    }

    @Test
    func `usage payload throws on malformed JSON`() {
        #expect {
            _ = try CodebuffUsageFetcher._parseUsagePayloadForTesting(Data("not-json".utf8))
        } throws: { error in
            guard case CodebuffUsageError.parseFailed = error else { return false }
            return true
        }
    }

    @Test
    func `subscription payload parses tier and weekly window`() throws {
        let json = """
        {
          "hasSubscription": true,
          "subscription": {
            "status": "active",
            "tier": "pro",
            "billingPeriodEnd": "2026-05-15T00:00:00Z"
          },
          "rateLimit": {
            "weeklyUsed": 2100,
            "weeklyLimit": 7000,
            "weeklyResetsAt": "2026-05-08T00:00:00Z"
          },
          "email": "user@example.com"
        }
        """

        let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8))
        #expect(payload.tier == "pro")
        #expect(payload.status == "active")
        #expect(payload.weeklyUsed == 2100)
        #expect(payload.weeklyLimit == 7000)
        #expect(payload.weeklyResetsAt != nil)
        #expect(payload.email == "user@example.com")
        #expect(payload.billingPeriodEnd != nil)
    }

    @Test
    func `subscription payload prefers display name over numeric tier`() throws {
        let json = """
        { "subscription": { "tier": 2, "displayName": "Pro" } }
        """
        let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8))
        #expect(payload.tier == "Pro")
    }

    @Test
    func `subscription payload falls back to numeric scheduled tier`() throws {
        let json = """
        { "subscription": { "scheduledTier": 3 } }
        """
        let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8))
        #expect(payload.tier == "3")
    }

    @Test
    func `subscription payload formats oversized numeric tier without trapping`() throws {
        let json = """
        { "subscription": { "scheduledTier": 9223372036854775808 } }
        """
        let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8))
        #expect(payload.tier == "9223372036854775808")
    }

    @Test
    func `subscription payload tolerates missing rate limit`() throws {
        let json = """
        { "subscription": { "status": "trialing", "tier": "free" } }
        """
        let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8))
        #expect(payload.weeklyUsed == nil)
        #expect(payload.weeklyLimit == nil)
        #expect(payload.status == "trialing")
    }

    @Test
    func `snapshot maps to rate window with credits window`() {
        let snapshot = CodebuffUsageSnapshot(
            creditsUsed: 250,
            creditsTotal: 1000,
            creditsRemaining: 750,
            weeklyUsed: 100,
            weeklyLimit: 500,
            weeklyResetsAt: Date(timeIntervalSince1970: 1_777_680_000),
            tier: "pro",
            autoTopUpEnabled: true,
            updatedAt: Date())

        let unified = snapshot.toUsageSnapshot()
        #expect(unified.primary?.usedPercent == 25)
        // The credit balance is intentionally NOT stored in `resetDescription` —
        // generic renderers prepend "Resets " when `resetsAt` is absent, which would
        // surface misleading text like "Resets 250/1,000 credits".
        #expect(unified.primary?.resetDescription == nil)
        #expect(unified.secondary?.usedPercent == 20)
        #expect(unified.secondary?.windowMinutes == 7 * 24 * 60)
        #expect(unified.secondary?.resetsAt == Date(timeIntervalSince1970: 1_777_680_000))
        #expect(unified.secondary?.resetDescription == nil)
        #expect(unified.identity?.providerID == .codebuff)
        #expect(unified.identity?.loginMethod?.contains("Pro") == true)
        #expect(unified.identity?.loginMethod?.contains("auto top-up") == true)
    }

    @Test
    func `snapshot infers total from used plus remaining`() {
        let snapshot = CodebuffUsageSnapshot(
            creditsUsed: 40,
            creditsTotal: nil,
            creditsRemaining: 60)

        let unified = snapshot.toUsageSnapshot()
        #expect(unified.primary?.usedPercent == 40)
    }

    @Test
    func `snapshot surfaces exhausted state when quota is missing from payload`() {
        // Only `creditsUsed` is populated (no total, no remaining) — the API response is
        // degenerate but we still want the row to be visible so the user notices the
        // missing configuration instead of seeing an empty/healthy-looking bar.
        let usedOnly = CodebuffUsageSnapshot(
            creditsUsed: 42,
            creditsTotal: nil,
            creditsRemaining: nil)
        #expect(usedOnly.toUsageSnapshot().primary?.usedPercent == 100)

        // Only `creditsRemaining` is populated — same fallback should apply.
        let remainingOnly = CodebuffUsageSnapshot(
            creditsUsed: nil,
            creditsTotal: nil,
            creditsRemaining: 17)
        #expect(remainingOnly.toUsageSnapshot().primary?.usedPercent == 100)
    }

    @Test
    func `snapshot hides credit window when no credit fields are present`() {
        let empty = CodebuffUsageSnapshot()
        #expect(empty.toUsageSnapshot().primary == nil)
    }

    @Test
    func `missing credentials fetch call throws missing credentials`() async {
        do {
            _ = try await CodebuffUsageFetcher.fetchUsage(apiKey: "   ")
            Issue.record("Expected missingCredentials error")
        } catch let error as CodebuffUsageError {
            #expect(error == .missingCredentials)
        } catch {
            Issue.record("Unexpected error: \(error)")
        }
    }

    private static func makeSession() -> URLSession {
        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [CodebuffStubURLProtocol.self]
        return URLSession(configuration: config)
    }

    private static func makeResponse(
        url: URL,
        body: String,
        statusCode: Int = 200) throws -> (HTTPURLResponse, Data)
    {
        guard let response = HTTPURLResponse(
            url: url,
            statusCode: statusCode,
            httpVersion: nil,
            headerFields: ["Content-Type": "application/json"])
        else {
            throw URLError(.badServerResponse)
        }
        return (response, Data(body.utf8))
    }
}

private actor CodebuffRequestGate {
    private var isOpen = false
    private var continuations: [CheckedContinuation<Void, Never>] = []

    func wait() async {
        guard !self.isOpen else { return }
        await withCheckedContinuation { continuation in
            self.continuations.append(continuation)
        }
    }

    func open() {
        self.isOpen = true
        let continuations = self.continuations
        self.continuations.removeAll()
        for continuation in continuations {
            continuation.resume()
        }
    }
}

final class CodebuffStubURLProtocol: URLProtocol {
    private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil)
    static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? {
        get { Self._handlerBox.value }
        set { Self._handlerBox.setValue(newValue) }
    }

    nonisolated(unsafe) static var requests: [URLRequest] = []
    nonisolated(unsafe) static var requestBodies: [Data?] = []

    override static func canInit(with request: URLRequest) -> Bool {
        request.url?.host == "www.codebuff.com"
    }

    override static func canonicalRequest(for request: URLRequest) -> URLRequest {
        request
    }

    override func startLoading() {
        Self.requests.append(self.request)
        Self.requestBodies.append(Self.bodyData(from: self.request))
        guard let handler = Self.handler else {
            self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
            return
        }
        do {
            let (response, data) = try handler(self.request)
            self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            self.client?.urlProtocol(self, didLoad: data)
            self.client?.urlProtocolDidFinishLoading(self)
        } catch {
            self.client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {}

    private static func bodyData(from request: URLRequest) -> Data? {
        if let httpBody = request.httpBody {
            return httpBody
        }
        guard let stream = request.httpBodyStream else { return nil }

        stream.open()
        defer { stream.close() }

        var data = Data()
        var buffer = [UInt8](repeating: 0, count: 1024)
        while stream.hasBytesAvailable {
            let count = stream.read(&buffer, maxLength: buffer.count)
            if count > 0 {
                data.append(buffer, count: count)
            } else {
                break
            }
        }
        return data.isEmpty ? nil : data
    }
}
