import Foundation
import Testing

#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif

@testable import NucleicCore

struct ClaudeOAuthTests {
    // MARK: - PKCE

    @Test func challengeIsBase64URLSHA256OfVerifier() {
        let pkce = ClaudeOAuth.PKCE(verifier: "test-verifier-1234567890", state: "state-abc")
        let expected = ClaudeOAuth.base64URL(Data(SHA256.hash(data: Data(pkce.verifier.utf8))))
        #expect(pkce.challenge == expected)
        // URL-safe, unpadded.
        #expect(!pkce.challenge.contains("+"))
        #expect(!pkce.challenge.contains("/"))
        #expect(!pkce.challenge.contains("="))
    }

    @Test func generatedPKCEIsFreshAndWellFormed() {
        let a = ClaudeOAuth.PKCE.generate()
        let b = ClaudeOAuth.PKCE.generate()
        #expect(a.verifier != b.verifier)
        #expect(a.state != b.state)
        #expect(a.challenge == ClaudeOAuth.PKCE.challenge(for: a.verifier))
    }

    // MARK: - Authorize URL

    @Test func authorizeURLCarriesPKCEAndClientParams() throws {
        let pkce = ClaudeOAuth.PKCE(verifier: "v", state: "the-state")
        let url = ClaudeOAuth.authorizeURL(redirectURI: "http://localhost:5599/callback", pkce: pkce)
        let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))
        let items = Dictionary(
            uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value) })
        #expect(url.absoluteString.hasPrefix(ClaudeOAuth.authorizeEndpoint))
        #expect(items["client_id"] == ClaudeOAuth.clientID)
        #expect(items["response_type"] == "code")
        #expect(items["redirect_uri"] == "http://localhost:5599/callback")
        #expect(items["code_challenge"] == pkce.challenge)
        #expect(items["code_challenge_method"] == "S256")
        #expect(items["state"] == "the-state")
        #expect(items["scope"] == ClaudeOAuth.scopes.joined(separator: " "))
    }

    // MARK: - Credential JSON shaping

    private func tokenResponse(
        access: String = "acc", refresh: String? = "ref",
        expiresIn: Int? = 3600, scope: String? = "user:profile user:inference"
    ) -> Data {
        var obj: [String: Any] = ["access_token": access]
        if let refresh { obj["refresh_token"] = refresh }
        if let expiresIn { obj["expires_in"] = expiresIn }
        if let scope { obj["scope"] = scope }
        return try! JSONSerialization.data(withJSONObject: obj)
    }

    @Test func credentialJSONComputesAbsoluteExpiryAndShape() throws {
        let now = Date(timeIntervalSince1970: 1_000_000)
        let json = try #require(ClaudeOAuth.credentialJSON(
            fromTokenResponse: tokenResponse(expiresIn: 3600),
            requestedScopes: ClaudeOAuth.scopes, now: now))
        // Parses through the exact accessor the rest of the system uses.
        #expect(ClaudeLoginKeychain.isValidLogin(json))
        #expect(ClaudeLoginKeychain.accessToken(json) == "acc")
        #expect(ClaudeLoginKeychain.expiresAt(json) == Double((1_000_000 + 3600) * 1000))
        let material = try #require(ClaudeCredentialBroker.refreshMaterial(from: json))
        #expect(material.refreshToken == "ref")
        #expect(material.scopes == ["user:profile", "user:inference"])
    }

    @Test func refreshResponseWithoutRefreshTokenReusesPriorLineage() throws {
        let now = Date(timeIntervalSince1970: 2_000_000)
        let json = try #require(ClaudeOAuth.credentialJSON(
            fromTokenResponse: tokenResponse(refresh: nil, expiresIn: 60),
            requestedScopes: ClaudeOAuth.scopes, now: now,
            fallbackRefreshToken: "kept-refresh"))
        #expect(ClaudeCredentialBroker.refreshMaterial(from: json)?.refreshToken == "kept-refresh")
    }

    @Test func credentialJSONFallsBackToRequestedScopes() throws {
        let json = try #require(ClaudeOAuth.credentialJSON(
            fromTokenResponse: tokenResponse(scope: nil),
            requestedScopes: ["a", "b"], now: Date()))
        #expect(ClaudeCredentialBroker.refreshMaterial(from: json)?.scopes == ["a", "b"])
    }

    @Test func credentialJSONRejectsMissingAccessOrRefresh() {
        // No access token.
        #expect(ClaudeOAuth.credentialJSON(
            fromTokenResponse: Data(#"{"refresh_token":"r"}"#.utf8),
            requestedScopes: [], now: Date()) == nil)
        // No refresh token and no fallback.
        #expect(ClaudeOAuth.credentialJSON(
            fromTokenResponse: Data(#"{"access_token":"a"}"#.utf8),
            requestedScopes: [], now: Date()) == nil)
    }

    // MARK: - Exchange / refresh over an injected transport

    private func oauth(
        status: Int = 200, body: Data, now: Date = Date()
    ) -> ClaudeOAuth {
        ClaudeOAuth(transport: { request in
            #expect(request.httpMethod == "POST")
            #expect(request.url?.absoluteString == ClaudeOAuth.tokenEndpoint)
            let response = HTTPURLResponse(
                url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!
            return (body, response)
        }, now: { now })
    }

    @Test func exchangeProducesStoredCredential() async throws {
        let now = Date(timeIntervalSince1970: 3_000_000)
        let client = oauth(body: tokenResponse(access: "live", expiresIn: 120), now: now)
        let pkce = ClaudeOAuth.PKCE(verifier: "v", state: "s")
        let json = try await client.exchange(
            rawCode: "the-code", pkce: pkce, redirectURI: "http://localhost:1/callback")
        #expect(ClaudeLoginKeychain.accessToken(json) == "live")
        #expect(ClaudeLoginKeychain.expiresAt(json) == Double((3_000_000 + 120) * 1000))
    }

    @Test func exchangeRejectsMismatchedStateInPastedCode() async {
        let client = oauth(body: tokenResponse())
        let pkce = ClaudeOAuth.PKCE(verifier: "v", state: "expected")
        await #expect(throws: ClaudeOAuth.OAuthError.stateMismatch) {
            _ = try await client.exchange(
                rawCode: "code#wrong", pkce: pkce, redirectURI: ClaudeOAuth.consoleRedirect)
        }
    }

    @Test func exchangeSurfacesHTTPFailure() async {
        let client = oauth(status: 400, body: Data("bad request".utf8))
        let pkce = ClaudeOAuth.PKCE(verifier: "v", state: "s")
        await #expect(throws: ClaudeOAuth.OAuthError.self) {
            _ = try await client.exchange(
                rawCode: "c", pkce: pkce, redirectURI: "http://localhost:1/callback")
        }
    }

    @Test func refreshReportsInvalidGrantOnServerRejection() async {
        // A rejected refresh token (400/401) is a dead lineage — re-login, not a retry.
        #expect(await oauth(status: 401, body: Data("revoked".utf8)).refresh(refreshToken: "r")
            == .invalidGrant)
        #expect(await oauth(status: 400, body: Data(#"{"error":"invalid_grant"}"#.utf8))
            .refresh(refreshToken: "r") == .invalidGrant)
    }

    @Test func refreshReportsTransientFailureOnServerError() async {
        // 5xx / rate-limit leave the current credential usable; the caller should keep it.
        #expect(await oauth(status: 503, body: Data("upstream".utf8)).refresh(refreshToken: "r")
            == .transientFailure)
        #expect(await oauth(status: 429, body: Data("slow down".utf8)).refresh(refreshToken: "r")
            == .transientFailure)
    }

    @Test func refreshRotatesCredential() async throws {
        let now = Date(timeIntervalSince1970: 4_000_000)
        let client = oauth(
            body: tokenResponse(access: "rotated", refresh: "new-ref", expiresIn: 3600), now: now)
        guard case .refreshed(let json) = await client.refresh(refreshToken: "old-ref") else {
            Issue.record("expected a refreshed credential")
            return
        }
        #expect(ClaudeLoginKeychain.accessToken(json) == "rotated")
        #expect(ClaudeCredentialBroker.refreshMaterial(from: json)?.refreshToken == "new-ref")
    }

    @Test func tokenErrorSummaryExtractsOAuthErrorWithoutLeakingBody() {
        #expect(ClaudeOAuth.tokenErrorSummary(#"{"error":"invalid_grant","error_description":"expired"}"#)
            == "invalid_grant: expired")
        // A non-JSON body yields nothing (never echoed into logs).
        #expect(ClaudeOAuth.tokenErrorSummary("refresh_token=secret").isEmpty)
    }

    // MARK: - Loopback callback parsing (macOS)

    #if canImport(Network)
    @Test func loopbackParsesCodeAndState() throws {
        let request = "GET /callback?code=abc123&state=xyz HTTP/1.1\r\nHost: localhost\r\n\r\n"
        let callback = try #require(OAuthLoopback.parseCallback(fromRequestLine: request))
        #expect(callback.code == "abc123")
        #expect(callback.state == "xyz")
    }

    @Test func loopbackIgnoresRequestsMissingParams() {
        #expect(OAuthLoopback.parseCallback(
            fromRequestLine: "GET /favicon.ico HTTP/1.1") == nil)
        #expect(OAuthLoopback.parseCallback(
            fromRequestLine: "GET /callback?code=only HTTP/1.1") == nil)
    }
    #endif
}
