import Foundation

#if canImport(Security)
import Security
#endif

/// Keeps the containerized Claude login coherent with the macOS Keychain — the single source of
/// truth every non-sandbox Claude client already shares (LOGIN SYNC).
///
/// **Why this exists.** Claude Code's subscription login is an OAuth blob in the login Keychain
/// (`Claude Code-credentials`), holding a short-lived `accessToken`, a long-lived `refreshToken`,
/// and an `expiresAt`. When the access token expires (~daily) the client refreshes it *and rotates
/// the refresh token*, invalidating the previous one. Every normal consumer (the desktop app, the
/// CLI, other wrappers) read-modify-writes that ONE Keychain item in place, so rotation stays
/// coherent — there is a single credential lineage.
///
/// The sandbox can't reach the Keychain (Linux, no Security framework), so Nucleic exports the blob
/// into each session's `<claude-home>/.credentials.json` (``SessionController/exportLoginCredentials``).
/// That turned the sandbox into an ISOLATED second copy: the containerized `claude` refreshes it,
/// rotating the shared refresh token, but the new token stayed in the container file and never flowed
/// back. So the Keychain — and the host apps sharing it — kept holding a now-invalidated refresh
/// token, and at the next daily expiry a copy got stranded ("Not logged in"), while the host, sharing
/// the one item, never diverged. That is exactly why the logout was *only* inside Nucleic and *~daily*.
///
/// This type closes the loop. After a turn, the sandbox's (possibly refreshed) credential is written
/// BACK into the Keychain when it is strictly newer (``writeBackIfNewer(fromContainerFile:)``), and at
/// export time the same reconciliation runs first — so every copy converges on one lineage through the
/// Keychain hub. Newest-wins is decided by `expiresAt` (``shouldWriteBack(candidate:current:)``); all
/// Keychain mutation is serialized on one lock so concurrent sessions can't clobber each other, and a
/// nil/unreadable current is treated as "don't touch" so the Keychain is never downgraded or fabricated.
///
/// macOS-only (like the rest of `NucleicCore`, which the app builds for); the container runs a
/// prebuilt `claude`, never this code.
public enum ClaudeLoginKeychain {
    /// The generic-password item Claude Code writes its OAuth credentials to (mirrors
    /// ``SubscriptionUsageFetcher/keychainService``).
    static let service = "Claude Code-credentials"

    /// Serializes the read-compare-write across sessions: every `SessionController` lives in one
    /// process, so an in-process lock is enough to keep two turns from racing the same update.
    private static let lock = NSLock()

    /// Kill switch for every real Keychain touch (read and write). A process that can't show the
    /// login-item ACL prompt — CI, an agent-driven `swift test`, any headless run — blocks forever
    /// inside `SecItemCopyMatching` while holding `lock`, starving the cooperative pool and hanging
    /// whatever suite reached it. Unit tests never reach this type while
    /// `ContainerServiceSettings.serviceEnabled` is false; this is the backstop for when one does.
    /// On when `NUCLEIC_DISABLE_LOGIN_SYNC=1` (for CI wrappers), and set directly by the test
    /// bootstrap (`TestProcessGuards`) so `swift test` needs no runner configuration.
    /// `nonisolated(unsafe)`: written once, before any session runs, then only read.
    public nonisolated(unsafe) static var disabled: Bool =
        ProcessInfo.processInfo.environment["NUCLEIC_DISABLE_LOGIN_SYNC"] == "1"

    // MARK: - Reconciliation (write-back)

    /// If the container's credential file is a valid login **strictly newer** than the Keychain,
    /// write it back — the sandbox refreshed (and rotated) the token this turn, so propagate the new
    /// lineage to the shared source of truth. Best-effort and host-side; a no-op when the file is
    /// missing/older/equal, or when the Keychain can't be read (never downgrades or creates an item).
    public static func writeBackIfNewer(fromContainerFile path: String) {
        guard !disabled else { return }
        guard let fileJSON = try? String(contentsOfFile: path, encoding: .utf8) else { return }
        lock.lock()
        defer { lock.unlock() }
        let current = read()
        guard shouldWriteBack(candidate: fileJSON, current: current) else { return }
        write(fileJSON)
    }

    /// Whether `candidate` should replace `current` in the Keychain. The I/O-free core of the
    /// newest-wins rule, so it's unit-tested without touching a real Keychain: `candidate` must be a
    /// valid login, must differ from `current`, and must have a strictly later `expiresAt` than a
    /// *readable, parseable* `current`. A nil/unparseable `current` returns false — we only ever
    /// advance the Keychain when we can positively confirm the candidate is newer, so a denied read
    /// or a logged-out slot can never cause a downgrade or an accidental login.
    public static func shouldWriteBack(candidate: String, current: String?) -> Bool {
        guard isValidLogin(candidate), let candExp = expiresAt(candidate) else { return false }
        guard let current, current != candidate, let curExp = expiresAt(current) else { return false }
        return candExp > curExp
    }

    // MARK: - Pure parsing

    /// The `claudeAiOauth.expiresAt` (milliseconds since epoch) from a credentials JSON string, or
    /// nil if absent/unparseable. Tolerates the value arriving as a JSON number or a numeric string.
    public static func expiresAt(_ json: String) -> Double? {
        guard let oauth = oauthObject(json) else { return nil }
        if let n = oauth["expiresAt"] as? Double { return n }
        if let n = oauth["expiresAt"] as? Int { return Double(n) }
        if let s = oauth["expiresAt"] as? String, let n = Double(s) { return n }
        return nil
    }

    /// Whether `json` is a usable OAuth login: a `claudeAiOauth` object carrying a non-empty
    /// `accessToken` and `refreshToken`. Guards write-back from ever pushing a truncated or
    /// half-written credential file back into the Keychain.
    public static func isValidLogin(_ json: String) -> Bool {
        guard let oauth = oauthObject(json),
            let access = oauth["accessToken"] as? String, !access.isEmpty,
            let refresh = oauth["refreshToken"] as? String, !refresh.isEmpty
        else { return false }
        return true
    }

    private static func oauthObject(_ json: String) -> [String: Any]? {
        guard let data = json.data(using: .utf8),
            let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
            let oauth = obj["claudeAiOauth"] as? [String: Any]
        else { return nil }
        return oauth
    }

    // MARK: - Keychain I/O

    #if canImport(Security)
    /// The raw `Claude Code-credentials` value (the OAuth JSON), or nil when there's no such item
    /// (logged out / API-key auth) or the read is denied.
    static func read() -> String? {
        guard !disabled else { return nil }
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
        ]
        var item: CFTypeRef?
        guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
            let data = item as? Data
        else { return nil }
        return String(data: data, encoding: .utf8)
    }

    /// Update the existing item's value in place with `SecItemUpdate` (never delete-and-add), so the
    /// item's ACL/trust set is preserved and Claude keeps reading it. Returns false — a no-op — when
    /// the item doesn't exist (`errSecItemNotFound`): we update the user's login, never fabricate one.
    @discardableResult
    static func write(_ json: String) -> Bool {
        guard !disabled, let data = json.data(using: .utf8) else { return false }
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
        ]
        let attributes: [String: Any] = [kSecValueData as String: data]
        return SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecSuccess
    }
    #else
    // Linux (runner): the shared source of truth is Claude's OWN credentials file —
    // `~/.claude/.credentials.json`, exactly where the Linux `claude` CLI keeps its login. The
    // reconciliation semantics above (newest-wins by `expiresAt`, never fabricate, never
    // downgrade) apply unchanged; only the hub moves from the Keychain to that file. Credentials
    // arrive here via the sealed credential mesh (ANTIMATTER_RUNNER §6).

    /// Claude's credentials file on Linux (`$CLAUDE_CONFIG_DIR` honored like the CLI does).
    static var linuxCredentialsFile: URL {
        let env = ProcessInfo.processInfo.environment
        let configDir = env["CLAUDE_CONFIG_DIR"].flatMap { $0.isEmpty ? nil : $0 }
            .map { URL(fileURLWithPath: $0, isDirectory: true) }
            ?? FileManager.default.homeDirectoryForCurrentUser
                .appendingPathComponent(".claude", isDirectory: true)
        return configDir.appendingPathComponent(".credentials.json")
    }

    static func read() -> String? {
        guard !disabled else { return nil }
        return try? String(contentsOf: linuxCredentialsFile, encoding: .utf8)
    }

    /// Update the credentials file in place. Like the Keychain path, this only ever *updates* an
    /// existing login — a missing file means logged out, and we never fabricate one.
    @discardableResult
    static func write(_ json: String) -> Bool {
        guard !disabled, FileManager.default.fileExists(atPath: linuxCredentialsFile.path) else {
            return false
        }
        do {
            try Data(json.utf8).write(to: linuxCredentialsFile, options: .atomic)
            try? FileManager.default.setAttributes(
                [.posixPermissions: 0o600], ofItemAtPath: linuxCredentialsFile.path)
            return true
        } catch {
            return false
        }
    }
    #endif
}
