import Foundation

/// Mediates Claude subscription authentication for every Nucleic session — Nucleic drives the OAuth
/// flow itself, so no external terminal or `claude` CLI login is ever required.
///
/// **Sign-in.** ``loginInteractively()`` runs the Authorization-Code-with-PKCE flow through
/// ``ClaudeOAuth``: it opens Anthropic's consent page in the browser, catches the redirect on a
/// localhost loopback (falling back to a pasted code), exchanges it for the access/refresh pair, and
/// writes the credential straight into ``ClaudeLoginKeychain`` — the same store the credential mesh
/// seals to runners and paired devices over Covalence. The moment a user signs in through Nucleic,
/// every device sharing that mesh can use the login.
///
/// **Per-turn preparation.** Containerized Claude processes cannot read the Keychain, so before a
/// turn this broker checks the canonical credential's expiry and, near expiry, refreshes it *itself*
/// against the token endpoint (again via ``ClaudeOAuth``). The rotating refresh token never leaves
/// the broker; the session receives only its short-lived access token in `CLAUDE_CODE_OAUTH_TOKEN`.
///
/// Calls are actor-isolated and further serialized by an explicit FIFO gate, so two sessions can
/// never exchange the same rotating refresh token concurrently.
public actor ClaudeCredentialBroker {
    public enum Preparation: Sendable, Equatable {
        case ready(accessToken: String?)
        case refreshed(accessToken: String)
        case loginRequired(String)
        case unavailable(String)
    }

    public enum LoginResult: Sendable, Equatable {
        case connected
        case failed(String)
    }

    struct RefreshMaterial: Sendable, Equatable {
        let refreshToken: String
        let scopes: [String]
        let expiresAt: Double
    }

    /// Opens a URL in the user's browser. Injected by the app (`NSWorkspace`); defaults to `open(1)`
    /// so the broker works headlessly and in tests.
    public typealias URLOpener = @Sendable (URL) -> Void
    /// Presents the copy-paste fallback: given the consent URL, returns the code the user pasted (or
    /// nil if they cancelled). Injected by the app; nil disables the fallback (loopback-only).
    public typealias CodePrompt = @Sendable (URL) async -> String?
    /// Whether THIS device may advance the rotating refresh token right now. The mesh elects a
    /// single refresher per credential (``CredentialRefreshLease``); when another member holds the
    /// lease, the local broker must not rotate too — two concurrent refreshers revoke each other's
    /// token, which is exactly how a still-connected runner can strand this device's login. Returns
    /// true when this device is the elected holder (or no live lease exists). `nil` ⇒ always permit
    /// (single-device setups, tests) — the previous unconditional behavior.
    public typealias RefreshPolicy = @Sendable () async -> Bool

    private let oauth: ClaudeOAuth
    private let processHost: ProcessHost
    private let openURL: URLOpener?
    private let promptForCode: CodePrompt?
    private let now: @Sendable () -> Date
    private let refreshLeeway: TimeInterval
    private let loginTimeoutNanos: UInt64
    private let localRefreshPermitted: RefreshPolicy?
    /// Reserved for future container-side operations; the OAuth flow no longer needs it, but the
    /// app still constructs the broker with it and tests may exercise container paths.
    private let containerManager: ContainerManager?
    /// Swift actors are reentrant across awaits, so actor isolation alone does not serialize a
    /// networked refresh. This explicit FIFO gate keeps one credential lineage in flight at a time.
    private var operationInProgress = false
    private var operationWaiters: [CheckedContinuation<Void, Never>] = []

    public init(
        containerManager: ContainerManager?,
        oauth: ClaudeOAuth = ClaudeOAuth(),
        processHost: ProcessHost = ProcessHost(),
        openURL: URLOpener? = nil,
        promptForCode: CodePrompt? = nil,
        now: @escaping @Sendable () -> Date = { Date() },
        refreshLeeway: TimeInterval = 30 * 60,
        loginTimeoutNanos: UInt64 = 10 * 60 * 1_000_000_000,
        localRefreshPermitted: RefreshPolicy? = nil
    ) {
        self.containerManager = containerManager
        self.oauth = oauth
        self.processHost = processHost
        self.openURL = openURL
        self.promptForCode = promptForCode
        self.now = now
        self.refreshLeeway = refreshLeeway
        self.loginTimeoutNanos = loginTimeoutNanos
        self.localRefreshPermitted = localRefreshPermitted
    }

    // MARK: - Per-turn preparation

    /// Prepare one container home for a Claude turn. Calls are actor-serialized, so two sessions
    /// can never exchange the same rotating refresh token concurrently.
    public func prepare(container spec: ContainerSpec) async -> Preparation {
        await acquireOperation()
        defer { releaseOperation() }

        guard let home = spec.env["CLAUDE_CONFIG_DIR"], !home.isEmpty else {
            return .unavailable("Claude container has no credential directory")
        }
        // A configured API key outranks subscription OAuth and needs no broker.
        // The full credential is staged only for this broker operation. Every return path removes
        // it before the user-controlled Claude process can start; sessions receive only the access
        // token carried by `Preparation`.
        defer { ClaudeLoginKeychain.removeExportedCredential(from: home) }
        if let key = spec.env["ANTHROPIC_API_KEY"],
           !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            return .ready(accessToken: nil)
        }

        ClaudeLoginKeychain.reconcileAndExport(into: home)
        guard let credential = ClaudeLoginKeychain.read(),
              ClaudeLoginKeychain.isValidLogin(credential),
              let currentAccessToken = ClaudeLoginKeychain.accessToken(credential)
        else {
            return .loginRequired("No Claude subscription login is available")
        }
        guard let material = Self.refreshMaterial(from: credential) else {
            return .loginRequired("Claude login is missing refresh metadata")
        }

        guard Self.needsRefresh(
            expiresAt: material.expiresAt, now: now(), leeway: refreshLeeway
        ) else {
            // Another session may have refreshed while this session home was first seeded.
            ClaudeLoginKeychain.reconcileAndExport(into: home)
            return .ready(accessToken: currentAccessToken)
        }

        // Near expiry, but another mesh member holds the refresh lease: don't rotate here (a second
        // refresher would revoke the holder's token). Use the current token while it is still valid
        // and let the holder's mirror-back converge us; only sign-in when nothing usable remains.
        if await localRefreshPermitted?() == false {
            claudeAuthLog.notice("Claude refresh deferred to the mesh lease holder; using current token")
            return recoverAfterFailedRefresh(into: home)
        }

        // Near expiry: rotate the token ourselves against Anthropic's endpoint. No container, no
        // CLI — the same code path a runner or paired device can use to keep the mesh fresh.
        switch await oauth.refresh(refreshToken: material.refreshToken) {
        case .refreshed(let refreshedJSON) where ClaudeLoginKeychain.isValidLogin(refreshedJSON):
            persistRefreshed(refreshedJSON)
            ClaudeLoginKeychain.reconcileAndExport(into: home)
            guard let refreshed = ClaudeLoginKeychain.read(),
                  let refreshedAccessToken = ClaudeLoginKeychain.accessToken(refreshed),
                  let refreshedExpiry = ClaudeLoginKeychain.expiresAt(refreshed),
                  !Self.needsRefresh(expiresAt: refreshedExpiry, now: now(), leeway: refreshLeeway)
            else {
                return .loginRequired("Claude did not return a usable refreshed login")
            }
            return .refreshed(accessToken: refreshedAccessToken)
        case .refreshed:
            // A 2xx that isn't a usable login (should not happen) — treat as transient.
            return recoverAfterFailedRefresh(into: home)
        case .invalidGrant:
            // The refresh token was rejected — almost always because another mesh member already
            // rotated it and the newer credential hasn't been mirrored back yet. Recover from the
            // store rather than blocking a turn that still has a usable access token.
            claudeAuthLog.error(
                "Claude refresh rejected (invalid_grant) preparing a turn — refresh token is stale or revoked")
            return recoverAfterFailedRefresh(into: home)
        case .transientFailure:
            claudeAuthLog.notice("Claude refresh failed transiently preparing a turn — using current token if valid")
            return recoverAfterFailedRefresh(into: home)
        }
    }

    /// Recover after a refresh that did not rotate the token. A mirror-back from another mesh
    /// member lands straight in the store, so re-read for it first; then hand back the current
    /// access token only while it has not actually expired — never a definitively-dead token — and
    /// require sign-in only when nothing usable remains.
    private func recoverAfterFailedRefresh(into home: String) -> Preparation {
        ClaudeLoginKeychain.reconcileAndExport(into: home)
        guard let credential = ClaudeLoginKeychain.read(),
              ClaudeLoginKeychain.isValidLogin(credential),
              let accessToken = ClaudeLoginKeychain.accessToken(credential),
              let expiresAt = ClaudeLoginKeychain.expiresAt(credential),
              expiresAt > now().timeIntervalSince1970 * 1_000
        else {
            return .loginRequired("Claude could not refresh the login — sign in again")
        }
        return .ready(accessToken: accessToken)
    }

    /// The current subscription access token, refreshed through Nucleic's own OAuth when near
    /// expiry — so *out-of-turn* consumers (subscription-usage polling, provider availability) get a
    /// live token without depending on a session turn or the external `claude` CLI to rotate it.
    /// Returns nil when there is no subscription login (API-key mode or signed out). Serialized with
    /// `prepare`/`loginInteractively` via the same gate, so it never forks the refresh lineage.
    public func freshAccessToken() async -> String? {
        await acquireOperation()
        defer { releaseOperation() }

        guard let credential = ClaudeLoginKeychain.read(),
              ClaudeLoginKeychain.isValidLogin(credential),
              let material = Self.refreshMaterial(from: credential)
        else { return nil }

        guard Self.needsRefresh(expiresAt: material.expiresAt, now: now(), leeway: refreshLeeway)
        else { return ClaudeLoginKeychain.accessToken(credential) }

        // Another mesh member holds the refresh lease: defer to it rather than racing a rotation.
        if await localRefreshPermitted?() == false {
            return currentAccessTokenIfUnexpired()
        }

        // Near expiry: rotate ourselves.
        switch await oauth.refresh(refreshToken: material.refreshToken) {
        case .refreshed(let refreshedJSON) where ClaudeLoginKeychain.isValidLogin(refreshedJSON):
            persistRefreshed(refreshedJSON)
            let current = ClaudeLoginKeychain.read() ?? refreshedJSON
            return ClaudeLoginKeychain.accessToken(current)
        case .refreshed, .transientFailure:
            // Transient — the current token may still authenticate; hand it back only if it hasn't
            // actually expired, so a caller never fetches with a definitively-dead token.
            return currentAccessTokenIfUnexpired()
        case .invalidGrant:
            // The lineage is dead. A mirror-back may have landed a newer token; prefer it, else the
            // current one while valid, else nil so the caller surfaces a clean re-login.
            claudeAuthLog.error("Claude refresh rejected (invalid_grant) out-of-turn — refresh token is stale or revoked")
            return currentAccessTokenIfUnexpired()
        }
    }

    /// The current access token, but only while it has not actually expired — the store is re-read
    /// so a token another mesh member just mirrored back is preferred. Returns nil when nothing
    /// usable remains, so a caller cleanly surfaces re-login instead of fetching with a dead token.
    private func currentAccessTokenIfUnexpired() -> String? {
        guard let credential = ClaudeLoginKeychain.read(),
              ClaudeLoginKeychain.isValidLogin(credential),
              let expiresAt = ClaudeLoginKeychain.expiresAt(credential),
              expiresAt > now().timeIntervalSince1970 * 1_000
        else { return nil }
        return ClaudeLoginKeychain.accessToken(credential)
    }

    /// Persist a freshly-minted credential, newest-wins, so a token another session or a paired
    /// device landed in the meantime is never downgraded (mirrors `CredentialProvider.landUpdate`).
    private func persistRefreshed(_ json: String) {
        guard ClaudeLoginKeychain.shouldWriteBack(
            candidate: json, current: ClaudeLoginKeychain.read())
        else { return }
        ClaudeLoginKeychain.write(json)
    }

    // MARK: - Interactive sign-in

    /// Run the Claude subscription sign-in end to end inside Nucleic. Opens Anthropic's browser
    /// consent page, captures the redirect (loopback preferred, pasted code as fallback), exchanges
    /// the authorization code for the credential, and stores it. The browser stays Anthropic-owned;
    /// everything else — PKCE, the redirect capture, the token exchange, persistence — is Nucleic's.
    public func loginInteractively() async -> LoginResult {
        await acquireOperation()
        defer { releaseOperation() }

        let pkce = ClaudeOAuth.PKCE.generate()
        let timeout = Double(loginTimeoutNanos) / 1_000_000_000

        #if canImport(Network)
        if let loopback = OAuthLoopback.start() {
            defer { loopback.stop() }
            let url = ClaudeOAuth.authorizeURL(redirectURI: loopback.redirectURI, pkce: pkce)
            await open(url)
            do {
                let callback = try await loopback.waitForCallback(timeout: timeout)
                guard callback.state == pkce.state else {
                    return .failed("Claude sign-in response did not match this request")
                }
                return await finishLogin(
                    rawCode: callback.code, pkce: pkce, redirectURI: loopback.redirectURI)
            } catch {
                // Loopback timed out or was cancelled — fall through to the paste fallback below so
                // a blocked local port never strands the user.
            }
        }
        #endif

        // Fallback: consent page renders the code for the user to paste back into Nucleic.
        guard let promptForCode else {
            return .failed(
                "Nucleic could not open a local sign-in listener. Try again, or configure an "
                + "ANTHROPIC_API_KEY.")
        }
        let url = ClaudeOAuth.authorizeURL(redirectURI: ClaudeOAuth.consoleRedirect, pkce: pkce)
        await open(url)
        guard let pasted = await promptForCode(url),
              !pasted.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        else {
            return .failed("Claude sign-in was cancelled")
        }
        return await finishLogin(
            rawCode: pasted, pkce: pkce, redirectURI: ClaudeOAuth.consoleRedirect)
    }

    /// Run the Claude sign-in with a REMOTE browser + redirect capture (remote agent sign-in,
    /// docs/REMOTE_AGENT_LOGIN.md): the initiating device opens the consent URL and captures the
    /// redirect — on its own loopback when it bound a port (the browser runs on the device, so
    /// `localhost` in the redirect resolves there), else via Anthropic's console paste-back page.
    /// This host keeps everything secret: PKCE, the exchange, persistence. `capture` suspends for
    /// the whole browser round-trip; the login timeout backstops an abandoned attempt.
    public func loginRemotely(
        boundLoopbackPort: UInt16?, capture: @escaping RemoteLoginCapture
    ) async -> LoginResult {
        await acquireOperation()
        defer { releaseOperation() }

        let pkce = ClaudeOAuth.PKCE.generate()
        let redirectURI: String
        let captureSpec: RemoteLoginChallenge.Capture
        if let port = boundLoopbackPort {
            redirectURI = "http://localhost:\(port)/callback"
            captureSpec = .loopback(port: port, path: "/callback")
        } else {
            redirectURI = ClaudeOAuth.consoleRedirect
            captureSpec = .pasteCode
        }
        let challenge = RemoteLoginChallenge(
            authorizeURL: ClaudeOAuth.authorizeURL(redirectURI: redirectURI, pkce: pkce),
            capture: captureSpec)

        guard let captured = await remoteCaptureWithDeadline(
            challenge, capture: capture, timeoutNanos: loginTimeoutNanos)
        else { return .failed("Claude sign-in was cancelled") }
        // Loopback captures carry the redirect's state; verify it binds to THIS attempt. A
        // pasted `code#state` blob rides whole in `code` (state nil) — `finishLogin` parses it,
        // exactly as the local paste fallback does.
        if let state = captured.state, state != pkce.state {
            return .failed("Claude sign-in response did not match this request")
        }
        return await finishLogin(rawCode: captured.code, pkce: pkce, redirectURI: redirectURI)
    }

    /// Exchange the captured code and persist the resulting credential.
    private func finishLogin(
        rawCode: String, pkce: ClaudeOAuth.PKCE, redirectURI: String
    ) async -> LoginResult {
        let json: String
        do {
            json = try await oauth.exchange(rawCode: rawCode, pkce: pkce, redirectURI: redirectURI)
        } catch {
            return .failed(Self.loginErrorMessage(error))
        }
        guard ClaudeLoginKeychain.isValidLogin(json),
              let expiry = ClaudeLoginKeychain.expiresAt(json),
              expiry > now().timeIntervalSince1970 * 1_000,
              ClaudeLoginKeychain.write(json)
        else {
            return .failed("Claude signed in, but Nucleic could not store the credential")
        }
        return .connected
    }

    /// Open a URL in the browser via the injected opener, or `open(1)` when none was supplied.
    private func open(_ url: URL) async {
        if let openURL {
            openURL(url)
            return
        }
        #if os(macOS)
        let handle = try? await processHost.launch(ProcessSpec(
            executable: "/usr/bin/open", args: [url.absoluteString],
            cwd: FileManager.default.homeDirectoryForCurrentUser.path, stdinMode: .closed))
        handle?.closeStdin()
        #endif
    }

    /// A user-facing sign-in error that never leaks an OAuth URL or token.
    private static func loginErrorMessage(_ error: Error) -> String {
        if let oauthError = error as? ClaudeOAuth.OAuthError {
            return "Claude sign-in did not complete — \(oauthError.description)"
        }
        return "Claude sign-in did not complete"
    }

    // MARK: - Pure parsing (shared with tests)

    static func refreshMaterial(from json: String) -> RefreshMaterial? {
        guard let data = json.data(using: .utf8),
              let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let oauth = root["claudeAiOauth"] as? [String: Any],
              let refreshToken = oauth["refreshToken"] as? String,
              !refreshToken.isEmpty,
              let expiresAt = ClaudeLoginKeychain.expiresAt(json)
        else { return nil }

        let scopes: [String]
        if let values = oauth["scopes"] as? [String] {
            scopes = values.filter { !$0.isEmpty }
        } else if let value = oauth["scopes"] as? String {
            scopes = value.split(whereSeparator: \.isWhitespace).map(String.init)
        } else {
            scopes = []
        }
        guard !scopes.isEmpty else { return nil }
        return RefreshMaterial(
            refreshToken: refreshToken, scopes: scopes, expiresAt: expiresAt)
    }

    static func needsRefresh(
        expiresAt: Double, now: Date, leeway: TimeInterval = 30 * 60
    ) -> Bool {
        expiresAt <= (now.timeIntervalSince1970 + leeway) * 1_000
    }

    // MARK: - Operation gate

    private func acquireOperation() async {
        if !operationInProgress {
            operationInProgress = true
            return
        }
        await withCheckedContinuation { continuation in
            operationWaiters.append(continuation)
        }
    }

    private func releaseOperation() {
        if operationWaiters.isEmpty {
            operationInProgress = false
        } else {
            operationWaiters.removeFirst().resume()
        }
    }
}
