import Foundation
import NucleicProtocol

/// 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? SecretFile.restrictToOwner(path: to, isDirectory: false)
    }

    /// Whether `candidate` should replace `current`. The I/O-free core of the newest-wins rule
    /// (unit-tested without touching a real file), delegating to NucleicProtocol's
    /// `CodexCredentialFormat` so the phone vault reconciles with the exact same rule:
    /// `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 {
        CodexCredentialFormat.shouldReplace(candidate: candidate, current: current)
    }

    // MARK: - Pure parsing (shared with the phone via NucleicProtocol's CodexCredentialFormat)

    /// 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? {
        CodexCredentialFormat.lastRefresh(json)
    }

    /// 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 {
        CodexCredentialFormat.isValidLogin(json)
    }

    // MARK: - ChatGPT subscription plan

    /// The ChatGPT subscription plan carried by a Codex `auth.json`, normalized to lowercase
    /// (`"plus"`, `"pro"`, …), or `nil` when the login does not advertise one.
    ///
    /// Codex obtains this from the JWT's `chatgpt_plan_type` claim. Depending on the auth-server
    /// generation, that claim has appeared either at the payload root or inside OpenAI's
    /// `https://api.openai.com/auth` namespace, and in either the access or ID token. Check all
    /// supported shapes so a harmless token-layout change does not hide an entitled feature.
    ///
    /// The JWT is deliberately decoded without signature verification, just as Codex does when
    /// deriving local account metadata. This value controls presentation only; the Codex service
    /// remains authoritative when a turn is submitted.
    public static func subscriptionPlanType(_ json: String) -> String? {
        guard let root = try? JSONSerialization.jsonObject(with: Data(json.utf8))
                as? [String: Any],
              let tokens = root["tokens"] as? [String: Any]
        else { return nil }

        // Codex's current login flow reads the plan from access-token claims. Keep the ID token as
        // a compatibility fallback because older ChatGPT logins placed the same claim there.
        for key in ["access_token", "id_token"] {
            guard let token = tokens[key] as? String,
                  let claims = jwtClaims(token)
            else { continue }
            if let plan = normalizedPlan(claims["chatgpt_plan_type"]) { return plan }
            if let auth = claims["https://api.openai.com/auth"] as? [String: Any],
               let plan = normalizedPlan(auth["chatgpt_plan_type"]) {
                return plan
            }
        }
        return nil
    }

    /// Read the current host Codex login's ChatGPT plan. A missing, API-key-only, or malformed
    /// credential returns `nil` rather than guessing an entitlement.
    public static func currentSubscriptionPlanType(
        authFileURL: URL = FileManager.default.homeDirectoryForCurrentUser
            .appendingPathComponent(".codex/auth.json")
    ) -> String? {
        guard !disabled else { return nil }
        guard let json = try? String(contentsOf: authFileURL, encoding: .utf8) else { return nil }
        return subscriptionPlanType(json)
    }

    private static func jwtClaims(_ token: String) -> [String: Any]? {
        let segments = token.split(separator: ".", omittingEmptySubsequences: false)
        guard segments.count >= 2,
              let payload = CodexOAuth.base64URLDecode(String(segments[1]))
        else { return nil }
        return try? JSONSerialization.jsonObject(with: payload) as? [String: Any]
    }

    private static func normalizedPlan(_ value: Any?) -> String? {
        guard let raw = value as? String else { return nil }
        let plan = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
        return plan.isEmpty ? nil : plan
    }
}
