import Foundation

/// Mediates ChatGPT/Codex subscription sign-in for Nucleic — the Codex counterpart to
/// ``ClaudeCredentialBroker``. Nucleic drives the OAuth flow itself, so no external `codex login`
/// terminal step is required.
///
/// ``loginInteractively()`` runs Authorization-Code-with-PKCE through ``CodexOAuth``: it opens
/// OpenAI's consent page in the browser, catches the redirect on the fixed `localhost:1455`
/// loopback the Codex client requires, exchanges the code, and writes `~/.codex/auth.json` — the
/// exact file the host, every containerized `codex`, and the Covalence credential mesh already
/// reconcile (``CodexAuthFile``). Per-turn refresh stays where it already works: the containerized
/// `codex` rotates `auth.json` in place and ``SessionController`` mirrors it back host-side, so this
/// broker only owns the initial sign-in.
///
/// Calls are actor-isolated and serialized by an explicit FIFO gate so two sign-ins can't race the
/// single fixed loopback port.
public actor CodexCredentialBroker {
    public enum LoginResult: Sendable, Equatable {
        case connected
        case failed(String)
    }

    /// Opens a URL in the user's browser. Injected by the app (`NSWorkspace`); defaults to `open(1)`.
    public typealias URLOpener = @Sendable (URL) -> Void

    private let oauth: CodexOAuth
    private let processHost: ProcessHost
    private let openURL: URLOpener?
    private let now: @Sendable () -> Date
    private let loginTimeoutNanos: UInt64
    /// Where the login lands — `~/.codex/auth.json` in production; overridable for tests.
    private let authFileURL: URL
    private var operationInProgress = false
    private var operationWaiters: [CheckedContinuation<Void, Never>] = []

    public init(
        oauth: CodexOAuth = CodexOAuth(),
        processHost: ProcessHost = ProcessHost(),
        openURL: URLOpener? = nil,
        now: @escaping @Sendable () -> Date = { Date() },
        loginTimeoutNanos: UInt64 = 10 * 60 * 1_000_000_000,
        authFileURL: URL = FileManager.default.homeDirectoryForCurrentUser
            .appendingPathComponent(".codex/auth.json")
    ) {
        self.oauth = oauth
        self.processHost = processHost
        self.openURL = openURL
        self.now = now
        self.loginTimeoutNanos = loginTimeoutNanos
        self.authFileURL = authFileURL
    }

    /// Run the Codex subscription sign-in end to end inside Nucleic and persist `~/.codex/auth.json`.
    public func loginInteractively() async -> LoginResult {
        await acquireOperation()
        defer { releaseOperation() }

        #if canImport(Network)
        // The Codex OAuth client only permits the fixed http://localhost:1455/auth/callback redirect.
        guard let loopback = OAuthLoopback.start(
            port: CodexOAuth.loopbackPort, path: CodexOAuth.loopbackPath)
        else {
            return .failed(
                "Nucleic could not open the Codex sign-in listener on port 1455. Quit any running "
                + "`codex login` and try again.")
        }
        defer { loopback.stop() }

        let pkce = CodexOAuth.PKCE.generate()
        let url = CodexOAuth.authorizeURL(pkce: pkce)
        await open(url)

        let callback: OAuthLoopback.Callback
        do {
            callback = try await loopback.waitForCallback(
                timeout: Double(loginTimeoutNanos) / 1_000_000_000)
        } catch {
            return .failed("Codex sign-in did not complete")
        }
        guard callback.state == pkce.state else {
            return .failed("Codex sign-in response did not match this request")
        }

        let json: String
        do {
            json = try await oauth.exchange(code: callback.code, pkce: pkce)
        } catch {
            return .failed(Self.loginErrorMessage(error))
        }
        guard CodexAuthFile.isValidLogin(json), persist(json) else {
            return .failed("Codex signed in, but Nucleic could not store the credential")
        }
        return .connected
        #else
        return .failed("Codex sign-in is not available on this platform")
        #endif
    }

    /// Run the Codex sign-in with a REMOTE browser + redirect capture (remote agent sign-in,
    /// docs/REMOTE_AGENT_LOGIN.md). The Codex OAuth client only permits the fixed
    /// `localhost:1455` redirect and the browser runs on the initiating device — so the device
    /// MUST have bound 1455 (it reports what it bound in `agentLoginBegin`); there is no paste
    /// fallback. This host keeps PKCE, the exchange, and persistence; `capture` suspends for the
    /// browser round-trip, backstopped by the login timeout. Notably this path needs no local
    /// loopback, so — unlike `loginInteractively` — it works on Linux runners too.
    public func loginRemotely(
        boundLoopbackPort: UInt16?, capture: @escaping RemoteLoginCapture
    ) async -> LoginResult {
        await acquireOperation()
        defer { releaseOperation() }

        guard boundLoopbackPort == CodexOAuth.loopbackPort else {
            return .failed(
                "Codex sign-in needs your device to listen on port 1455 and it couldn't — "
                + "close anything using that port and try again.")
        }
        let pkce = CodexOAuth.PKCE.generate()
        let challenge = RemoteLoginChallenge(
            authorizeURL: CodexOAuth.authorizeURL(pkce: pkce),
            capture: .loopback(port: CodexOAuth.loopbackPort, path: CodexOAuth.loopbackPath))

        guard let captured = await remoteCaptureWithDeadline(
            challenge, capture: capture, timeoutNanos: loginTimeoutNanos)
        else { return .failed("Codex sign-in was cancelled") }
        guard captured.state == pkce.state else {
            return .failed("Codex sign-in response did not match this request")
        }

        let json: String
        do {
            json = try await oauth.exchange(code: captured.code, pkce: pkce)
        } catch {
            return .failed(Self.loginErrorMessage(error))
        }
        guard CodexAuthFile.isValidLogin(json), persist(json) else {
            return .failed("Codex signed in, but Nucleic could not store the credential")
        }
        return .connected
    }

    /// Write the freshly-minted login to `~/.codex/auth.json`, 0600 under a 0700 parent. An explicit
    /// user sign-in is authoritative (it may switch accounts), so it overwrites unconditionally —
    /// exactly as `codex login` itself does; the newest-wins reconciliation governs *background*
    /// syncs, not a deliberate re-login.
    private func persist(_ json: String) -> Bool {
        let dir = authFileURL.deletingLastPathComponent()
        do {
            try SecretFile.createDirectory(at: dir)
            try Data(json.utf8).write(to: authFileURL, options: .atomic)
            try? SecretFile.restrictToOwner(path: authFileURL.path, isDirectory: false)
            return true
        } catch {
            return false
        }
    }

    /// 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
    }

    private static func loginErrorMessage(_ error: Error) -> String {
        if let oauthError = error as? CodexOAuth.OAuthError {
            return "Codex sign-in did not complete — \(oauthError.description)"
        }
        return "Codex sign-in did not complete"
    }

    // 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()
        }
    }
}
