import Foundation

/// Keeps a containerized Codex login coherent with the host's `~/.codex/auth.json` — the single
/// credential lineage every non-sandbox `codex` client already shares (LOGIN SYNC, the Codex
/// counterpart to ``ClaudeLoginKeychain``).
///
/// **Why this exists.** Codex's ChatGPT-subscription login is a plain JSON file, `~/.codex/auth.json`,
/// holding a short-lived `access_token`, a long-lived `refresh_token`, and a `last_refresh`
/// timestamp. When the access token expires the client refreshes it *and rotates the refresh token*
/// — OpenAI issues one refresh token per (account, OAuth client) and revokes the previous one, so a
/// stale refresh token used afterward fails with "your refresh token was revoked." Every normal
/// consumer read-modify-writes that ONE file in place, so rotation stays coherent.
///
/// The sandbox runs against a per-session *copy* of `~/.codex` (``SessionController/seedAgentHome``),
/// which turned the container into an ISOLATED second copy of the same refresh-token family: the
/// containerized `codex` refreshes and rotates the token, the new one stays trapped in the container
/// file, and the host keeps the now-revoked one (and vice-versa). At the next refresh on the other
/// side, OpenAI rejects the stale token — which is exactly why Codex worked on the host but died in
/// Nucleic Control containers.
///
/// This type closes the loop, hub-and-spoke through the host file: at turn start the freshest of
/// {host, container} is pushed into the container, and after a turn the container's (possibly
/// refreshed) credential is written BACK to the host when it is strictly newer
/// (``copyIfNewer(from:to:)``). Newest-wins is decided by `last_refresh`
/// (``shouldReplace(candidate:current:)``); all file mutation is serialized on one lock so concurrent
/// sessions can't clobber each other, and a nil/unparseable current is treated conservatively so a
/// good login is never downgraded.
///
/// macOS-only host-side glue (like the rest of `NucleicCore`); the container runs a prebuilt `codex`,
/// never this code.
public enum CodexAuthFile {
    /// Serializes the read-compare-write across sessions in this one process.
    private static let lock = NSLock()

    /// Kill switch for the real file touch, shared with ``ClaudeLoginKeychain`` so the same test
    /// bootstrap disables both login-sync paths. `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 (copy newest)

    /// Copy `from` over `to` when `from` is a valid login **strictly newer** than what's at `to`
    /// (or `to` is missing/unreadable) — used both to seed the freshest credential into the container
    /// at turn start (host → container) and to write a refreshed one back afterward (container →
    /// host). Best-effort and host-side; a no-op when the source is missing/invalid or the
    /// destination is already at least as new. Preserves `0600` on the written file.
    public static func copyIfNewer(from: String, to: String) {
        guard !disabled else { return }
        guard let source = try? String(contentsOfFile: from, encoding: .utf8),
            isValidLogin(source)
        else { return }
        lock.lock()
        defer { lock.unlock() }
        let current = try? String(contentsOfFile: to, encoding: .utf8)
        guard shouldReplace(candidate: source, current: current) else { return }
        guard (try? source.write(toFile: to, atomically: true, encoding: .utf8)) != nil else { return }
        try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: to)
    }

    /// Whether `candidate` should replace `current`. The I/O-free core of the newest-wins rule, so
    /// it's unit-tested without touching a real file: `candidate` must be a valid, timestamped login;
    /// a nil `current` (destination missing) means seed it; an unparseable `current` is treated as
    /// stale (a valid candidate wins); otherwise the strictly-later `last_refresh` wins. Identical
    /// bytes are a no-op.
    public static func shouldReplace(candidate: String, current: String?) -> Bool {
        guard isValidLogin(candidate), let cand = lastRefresh(candidate) else { return false }
        guard let current else { return true }
        guard current != candidate else { return false }
        guard let cur = lastRefresh(current) else { return true }
        return cand > cur
    }

    // MARK: - Pure parsing

    /// The `last_refresh` timestamp (seconds since epoch) from an `auth.json` string, or nil if
    /// absent/unparseable. Codex writes it as an ISO-8601 UTC string, with or without fractional
    /// seconds (e.g. `"2026-07-09T10:56:59.068487Z"`); both forms parse.
    public static func lastRefresh(_ json: String) -> Double? {
        guard let obj = object(json), let stamp = obj["last_refresh"] as? String else { return nil }
        return Self.date(from: stamp)?.timeIntervalSince1970
    }

    /// Whether `json` is a usable Codex ChatGPT login: a `tokens` object carrying a non-empty
    /// `refresh_token`. Guards the sync from ever propagating a truncated/half-written file, or an
    /// API-key-only `auth.json` (no refresh lineage to reconcile).
    public static func isValidLogin(_ json: String) -> Bool {
        guard let obj = object(json),
            let tokens = obj["tokens"] as? [String: Any],
            let refresh = tokens["refresh_token"] as? String, !refresh.isEmpty
        else { return false }
        return true
    }

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

    /// Parse an ISO-8601 UTC instant, tolerating the optional fractional-seconds Codex emits.
    private static func date(from stamp: String) -> Date? {
        let withFraction = ISO8601DateFormatter()
        withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        if let d = withFraction.date(from: stamp) { return d }
        let plain = ISO8601DateFormatter()
        plain.formatOptions = [.withInternetDateTime]
        return plain.date(from: stamp)
    }
}
