import Foundation

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
// os.Logger on Darwin; the stderr shim in LinuxSupport.swift elsewhere.
#if canImport(os)
import os
#endif

/// Claude subscription auth tracing (category "claudeauth"), shared by ``ClaudeOAuth`` and
/// ``ClaudeCredentialBroker``. The refresh path is otherwise silent, so a stale/revoked refresh
/// token or a transient endpoint failure is invisible in production — exactly the "refresh isn't
/// working" case. Everything logged here is token-free (statuses and OAuth error codes only).
let claudeAuthLog = Logger(subsystem: "com.nucleic", category: "claudeauth")

/// The OAuth 2.0 + PKCE mechanics Nucleic uses to sign a user into their Claude subscription
/// **itself**, without shelling out to the `claude` CLI or requiring an external terminal.
///
/// Anthropic's Claude Code login is a public-client Authorization-Code-with-PKCE flow against
/// `claude.ai/oauth/authorize` (consent) and `console.anthropic.com/v1/oauth/token` (exchange +
/// refresh). Nucleic mints the PKCE pair, opens the consent page in the user's browser, catches the
/// redirect on a localhost loopback (or a pasted code), exchanges it for the access/refresh pair,
/// and hands the result back as the exact `{ "claudeAiOauth": { … } }` credential shape
/// ``ClaudeLoginKeychain`` persists — which is what the credential mesh then seals to runners and
/// paired devices over Covalence. Refresh uses the same token endpoint, so once a user signs in
/// through Nucleic every rotation is Nucleic-owned too; the CLI is never in the loop.
///
/// The parsing/URL-building surface is pure and unit-tested; the two networked calls run through an
/// injectable ``Transport`` so tests exercise the flow without touching Anthropic.
public struct ClaudeOAuth: Sendable {
    // MARK: - Public client constants

    /// Claude Code's public OAuth client id (a public client — no secret; PKCE is the proof).
    public static let clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
    /// The consent page the user's browser is sent to.
    public static let authorizeEndpoint = "https://claude.ai/oauth/authorize"
    /// The token endpoint for both code exchange and refresh.
    public static let tokenEndpoint = "https://console.anthropic.com/v1/oauth/token"
    /// The hosted redirect used for the manual copy-paste fallback: the consent page renders the
    /// authorization code (as `code#state`) for the user to paste back into Nucleic.
    public static let consoleRedirect = "https://console.anthropic.com/oauth/code/callback"
    /// The scopes Claude Code requests; `expiresAt`/`refreshToken`/`scopes` land in the credential.
    public static let scopes = ["org:create_api_key", "user:profile", "user:inference"]

    // MARK: - Dependencies

    /// A single HTTP round-trip: request in, `(body, response)` out. Injectable so tests never hit
    /// the network; the default drives `URLSession`.
    public typealias Transport = @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)

    private let transport: Transport
    private let now: @Sendable () -> Date

    public init(
        transport: @escaping Transport = ClaudeOAuth.liveTransport,
        now: @escaping @Sendable () -> Date = { Date() }
    ) {
        self.transport = transport
        self.now = now
    }

    public enum OAuthError: Error, CustomStringConvertible, Equatable {
        case network(String)
        case httpStatus(Int, String)
        case malformedResponse
        case stateMismatch

        public var description: String {
            switch self {
            case .network(let m): return "Network error: \(m)"
            case .httpStatus(let code, _): return "Sign-in server returned HTTP \(code)"
            case .malformedResponse: return "Sign-in server returned an unexpected response"
            case .stateMismatch: return "Sign-in response did not match this request"
            }
        }
    }

    // MARK: - PKCE

    /// A one-shot PKCE + anti-forgery bundle for a single login attempt.
    public struct PKCE: Sendable, Equatable {
        public let verifier: String
        public let challenge: String
        public let state: String

        /// Fresh, cryptographically-random material. `verifier`/`state` are 32 bytes of base64url;
        /// `challenge` is the base64url SHA-256 of the verifier (the S256 method).
        public static func generate() -> PKCE {
            let verifier = ClaudeOAuth.base64URL(randomBytes(32))
            let state = ClaudeOAuth.base64URL(randomBytes(32))
            return PKCE(verifier: verifier, challenge: challenge(for: verifier), state: state)
        }

        /// Deterministic constructor for tests.
        public init(verifier: String, state: String) {
            self.verifier = verifier
            self.challenge = ClaudeOAuth.PKCE.challenge(for: verifier)
            self.state = state
        }

        init(verifier: String, challenge: String, state: String) {
            self.verifier = verifier
            self.challenge = challenge
            self.state = state
        }

        static func challenge(for verifier: String) -> String {
            let digest = SHA256.hash(data: Data(verifier.utf8))
            return ClaudeOAuth.base64URL(Data(digest))
        }

        private static func randomBytes(_ count: Int) -> Data {
            var g = SystemRandomNumberGenerator()
            var bytes = Data(count: count)
            for i in 0..<count { bytes[i] = UInt8.random(in: 0...255, using: &g) }
            return bytes
        }
    }

    // MARK: - Authorize URL

    /// The consent URL to open in the browser. `redirectURI` is the loopback callback when Nucleic
    /// caught a local port, or ``consoleRedirect`` for the copy-paste fallback (which also asks the
    /// page to render the code via `code=true`).
    public static func authorizeURL(redirectURI: String, pkce: PKCE) -> URL {
        var components = URLComponents(string: authorizeEndpoint)!
        var items = [
            URLQueryItem(name: "code", value: "true"),
            URLQueryItem(name: "client_id", value: clientID),
            URLQueryItem(name: "response_type", value: "code"),
            URLQueryItem(name: "redirect_uri", value: redirectURI),
            URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
            URLQueryItem(name: "code_challenge", value: pkce.challenge),
            URLQueryItem(name: "code_challenge_method", value: "S256"),
            URLQueryItem(name: "state", value: pkce.state),
        ]
        // The loopback form does not need the page to display the code; only the console-paste
        // fallback does. Keeping `code=true` is harmless for loopback, so we leave it for both.
        _ = items
        components.queryItems = items
        return components.url!
    }

    // MARK: - Code exchange / refresh

    /// Exchange an authorization `code` for a credential. `rawCode` may arrive as `code#state`
    /// (the console-paste form embeds the state after `#`); the trailing state, when present, must
    /// match `pkce.state`. Returns the `{ "claudeAiOauth": { … } }` JSON string on success.
    public func exchange(rawCode: String, pkce: PKCE, redirectURI: String) async throws -> String {
        let parts = rawCode.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
        let code = String(parts[0]).trimmingCharacters(in: .whitespacesAndNewlines)
        if parts.count == 2 {
            let returnedState = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
            guard returnedState == pkce.state else { throw OAuthError.stateMismatch }
        }
        let body: [String: Any] = [
            "grant_type": "authorization_code",
            "code": code,
            "state": pkce.state,
            "client_id": Self.clientID,
            "redirect_uri": redirectURI,
            "code_verifier": pkce.verifier,
        ]
        let data = try await post(body)
        guard let json = Self.credentialJSON(fromTokenResponse: data, requestedScopes: Self.scopes, now: now())
        else { throw OAuthError.malformedResponse }
        return json
    }

    /// The result of a refresh attempt. The distinction matters to the caller: a rejected refresh
    /// token (``invalidGrant``) is unrecoverable without re-login (it was rotated away by another
    /// mesh member, or revoked), whereas a ``transientFailure`` (network, 5xx, garbled body) leaves
    /// the current credential usable and may succeed on a later attempt — so the caller keeps the
    /// current token instead of forcing a needless sign-in.
    public enum RefreshOutcome: Sendable, Equatable {
        /// Rotation succeeded — the new `{ "claudeAiOauth": { … } }` credential JSON.
        case refreshed(String)
        /// The refresh token was rejected (token-endpoint 400/401, typically `invalid_grant`):
        /// the lineage is dead and only a new sign-in restores it.
        case invalidGrant
        /// A recoverable failure (network, 429/5xx, unparseable body): keep the current credential.
        case transientFailure
    }

    /// Rotate a `refreshToken` into a fresh credential. Never throws — every failure is classified
    /// so the caller can tell a dead lineage apart from a transient blip (see ``RefreshOutcome``).
    public func refresh(refreshToken: String) async -> RefreshOutcome {
        let body: [String: Any] = [
            "grant_type": "refresh_token",
            "refresh_token": refreshToken,
            "client_id": Self.clientID,
        ]
        let data: Data
        do {
            data = try await post(body)
        } catch let OAuthError.httpStatus(code, responseBody) {
            // On a `refresh_token` grant a 400/401 means the token itself was rejected (rotated
            // away or revoked) — re-login is the only fix. 429/5xx/other are transient.
            let summary = Self.tokenErrorSummary(responseBody)
            if code == 400 || code == 401 {
                claudeAuthLog.error(
                    "Claude token refresh rejected: HTTP \(code, privacy: .public) \(summary, privacy: .public)")
                return .invalidGrant
            }
            claudeAuthLog.notice(
                "Claude token refresh failed transiently: HTTP \(code, privacy: .public) \(summary, privacy: .public)")
            return .transientFailure
        } catch {
            claudeAuthLog.notice(
                "Claude token refresh failed (network): \(String(describing: error), privacy: .public)")
            return .transientFailure
        }
        guard let json = Self.credentialJSON(
            fromTokenResponse: data, requestedScopes: Self.scopes, now: now(),
            fallbackRefreshToken: refreshToken)
        else {
            claudeAuthLog.error("Claude token refresh returned an unparseable token response")
            return .transientFailure
        }
        return .refreshed(json)
    }

    /// A short, token-free summary of a token-endpoint error body for logs: the OAuth `error` /
    /// `error_description` fields when the body is JSON, else empty. Never logs the raw body — it
    /// can echo the submitted refresh token or other request parameters.
    static func tokenErrorSummary(_ body: String) -> String {
        guard let data = body.data(using: .utf8),
              let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
        else { return "" }
        let code = (obj["error"] as? String) ?? ""
        let desc = (obj["error_description"] as? String) ?? ""
        return [code, desc].filter { !$0.isEmpty }.joined(separator: ": ")
    }

    private func post(_ body: [String: Any]) async throws -> Data {
        var request = URLRequest(url: URL(string: Self.tokenEndpoint)!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.httpBody = try JSONSerialization.data(withJSONObject: body)
        let (data, response): (Data, HTTPURLResponse)
        do {
            (data, response) = try await transport(request)
        } catch {
            throw OAuthError.network(String(describing: error))
        }
        guard (200..<300).contains(response.statusCode) else {
            throw OAuthError.httpStatus(response.statusCode, String(decoding: data, as: UTF8.self))
        }
        return data
    }

    // MARK: - Response → credential shape

    /// Build the persisted credential JSON from a token-endpoint response body. The response is the
    /// standard OAuth JSON (`access_token`, `refresh_token`, `expires_in`, optional `scope`); we
    /// convert `expires_in` (seconds-from-now) into the absolute `expiresAt` milliseconds
    /// ``ClaudeLoginKeychain`` compares on, and wrap it under `claudeAiOauth`. A refresh response may
    /// omit `refresh_token` (the lineage is unchanged) — `fallbackRefreshToken` preserves it so the
    /// result is never a half-credential the reconciler would reject.
    static func credentialJSON(
        fromTokenResponse data: Data,
        requestedScopes: [String],
        now: Date,
        fallbackRefreshToken: String? = nil
    ) -> String? {
        guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let accessToken = obj["access_token"] as? String, !accessToken.isEmpty
        else { return nil }

        let refreshToken = (obj["refresh_token"] as? String).flatMap { $0.isEmpty ? nil : $0 }
            ?? fallbackRefreshToken
        guard let refreshToken, !refreshToken.isEmpty else { return nil }

        let expiresInSeconds: Double = {
            if let n = obj["expires_in"] as? Double { return n }
            if let n = obj["expires_in"] as? Int { return Double(n) }
            if let s = obj["expires_in"] as? String, let n = Double(s) { return n }
            return 0
        }()
        let expiresAtMillis = (now.timeIntervalSince1970 + expiresInSeconds) * 1000

        let scopes: [String]
        if let scope = obj["scope"] as? String, !scope.isEmpty {
            scopes = scope.split(whereSeparator: \.isWhitespace).map(String.init)
        } else {
            scopes = requestedScopes
        }

        var oauth: [String: Any] = [
            "accessToken": accessToken,
            "refreshToken": refreshToken,
            "expiresAt": expiresAtMillis,
            "scopes": scopes,
        ]
        // Preserve the subscription tier when the server reports it (the CLI stores it too); purely
        // informational for Nucleic, but keeps the blob byte-compatible with a CLI-written one.
        if let sub = obj["subscriptionType"] as? String ?? (obj["account"] as? [String: Any])?["subscription_type"] as? String {
            oauth["subscriptionType"] = sub
        }
        let wrapped: [String: Any] = ["claudeAiOauth": oauth]
        guard let out = try? JSONSerialization.data(withJSONObject: wrapped),
              let string = String(data: out, encoding: .utf8)
        else { return nil }
        return string
    }

    // MARK: - Helpers

    /// URL-safe base64 without padding (RFC 7636 §A).
    static func base64URL(_ data: Data) -> String {
        data.base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }

    /// The default `Transport`: a `URLSession` round-trip wrapped in a continuation so it compiles
    /// identically on macOS and on the Linux runner (whose `FoundationNetworking` lacks the async
    /// `data(for:)` overload in some toolchains).
    public static let liveTransport: Transport = { request in
        try await withCheckedThrowingContinuation { continuation in
            let task = URLSession.shared.dataTask(with: request) { data, response, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                guard let http = response as? HTTPURLResponse else {
                    continuation.resume(throwing: OAuthError.malformedResponse)
                    return
                }
                continuation.resume(returning: (data ?? Data(), http))
            }
            task.resume()
        }
    }
}
