use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};

use super::model::{API_KEY_SCOPE, AuthMode, AuthStore, KimiAuth, lookup_auth};

// ── System-keyring storage for the Kimi Code OAuth session ─────────────
//
// PRD F1: the OAuth token set lives in the system keyring (service `kigi`,
// entry `oauth/kimi-code`); when the keyring is unavailable we fall back to
// the file mechanism below (`auth.json`, owner-only, atomic writes). The
// official Kimi client's keyring entries (service `kimi-code`) and `~/.kimi`
// files are never touched.

/// Keyring service name — deliberately distinct from the official client's
/// `kimi-code` service.
#[cfg(any(target_os = "macos", windows))]
pub(crate) const KEYRING_SERVICE: &str = "kigi";

/// Outcome of a keyring read for the session scope.
#[derive(Debug)]
pub(crate) enum KeyringRead {
    /// Backend reachable and the entry exists.
    Found(Box<KimiAuth>),
    /// Backend reachable, no entry stored.
    Missing,
    /// Keyring disabled, unsupported on this platform, or the backend
    /// errored — callers fall back to the file store.
    Unavailable,
}

/// Whether keyring storage participates for the session credential.
///
/// Disabled when:
/// - the platform has no supported backend (non-macOS/Windows builds),
/// - `KIGI_DISABLE_KEYRING` is set to a truthy value,
/// - a non-default credential location is in use (`KIGI_SHARE_DIR` /
///   `KIGI_AUTH_PATH`): the keyring entry belongs to the default user
///   install; alternate profiles (and tests) stay file-scoped, and
/// - in unit-test builds, unless a test explicitly opted into the mock
///   keyring via [`enable_mock_keyring_for_test`].
pub(crate) fn keyring_enabled() -> bool {
    #[cfg(test)]
    {
        // Thread-local so keyring-specific tests (which opt in via
        // `enable_mock_keyring_for_test`) can't leak the toggle into
        // concurrently running persistence tests on other threads.
        TEST_KEYRING_ENABLED.with(|flag| flag.get())
    }
    #[cfg(not(test))]
    {
        #[cfg(not(any(target_os = "macos", windows)))]
        {
            false
        }
        #[cfg(any(target_os = "macos", windows))]
        {
            let disabled = std::env::var("KIGI_DISABLE_KEYRING")
                .is_ok_and(|v| !matches!(v.trim(), "" | "0" | "false" | "off" | "no"));
            !disabled
                && std::env::var_os("KIGI_SHARE_DIR").is_none()
                && std::env::var_os("KIGI_AUTH_PATH").is_none()
        }
    }
}

#[cfg(test)]
thread_local! {
    static TEST_KEYRING_ENABLED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}

/// Route keyring calls through an in-memory mock store for this process,
/// enable [`keyring_enabled`] on this thread, and clear any entry left by an
/// earlier test. Tests using this must serialize on the `kigi_keyring` key:
/// the mock entry is shared process-wide.
#[cfg(all(test, any(target_os = "macos", windows)))]
pub(crate) fn enable_mock_keyring_for_test() {
    keyring::set_default_credential_builder(keyring::mock::default_credential_builder());
    TEST_KEYRING_ENABLED.with(|flag| flag.set(true));
    if let Err(e) = keyring_delete_session() {
        panic!("mock keyring cleanup failed: {e}");
    }
}

/// Disable the test keyring again (paired with
/// [`enable_mock_keyring_for_test`] in an RAII guard or test teardown).
#[cfg(test)]
pub(crate) fn disable_mock_keyring_for_test() {
    TEST_KEYRING_ENABLED.with(|flag| flag.set(false));
}

/// The process-wide keyring entry handle. Cached so every reader/writer talks
/// to the same credential object: real backends read live state from the OS
/// store on each call, and the test mock keeps its state on the entry itself.
#[cfg(any(target_os = "macos", windows))]
fn keyring_entry() -> Result<&'static keyring::Entry, keyring::Error> {
    static ENTRY: std::sync::OnceLock<Result<keyring::Entry, keyring::Error>> =
        std::sync::OnceLock::new();
    match ENTRY
        .get_or_init(|| keyring::Entry::new(KEYRING_SERVICE, crate::auth::KIMI_CODE_OAUTH_SCOPE))
    {
        Ok(entry) => Ok(entry),
        // `keyring::Error` is not `Clone`; surface a stable equivalent.
        Err(e) => {
            tracing::warn!(error = %e, "auth: keyring entry construction failed");
            Err(keyring::Error::Invalid(
                "keyring entry".into(),
                e.to_string(),
            ))
        }
    }
}

/// Read the session credential from the system keyring.
#[cfg(any(target_os = "macos", windows))]
pub(crate) fn keyring_read_session() -> KeyringRead {
    if !keyring_enabled() {
        return KeyringRead::Unavailable;
    }
    let entry = match keyring_entry() {
        Ok(entry) => entry,
        Err(e) => {
            tracing::warn!(error = %e, "auth: keyring entry unavailable, falling back to file");
            return KeyringRead::Unavailable;
        }
    };
    match entry.get_password() {
        Ok(raw) => match serde_json::from_str::<KimiAuth>(&raw) {
            Ok(auth) => KeyringRead::Found(Box::new(auth)),
            Err(e) => {
                tracing::warn!(error = %e, "auth: keyring entry is not valid JSON, ignoring");
                KeyringRead::Missing
            }
        },
        Err(keyring::Error::NoEntry) => KeyringRead::Missing,
        Err(e) => {
            tracing::warn!(error = %e, "auth: keyring read failed, falling back to file");
            KeyringRead::Unavailable
        }
    }
}

#[cfg(not(any(target_os = "macos", windows)))]
pub(crate) fn keyring_read_session() -> KeyringRead {
    KeyringRead::Unavailable
}

/// Write the session credential to the system keyring.
#[cfg(any(target_os = "macos", windows))]
pub(crate) fn keyring_write_session(auth: &KimiAuth) -> anyhow::Result<()> {
    anyhow::ensure!(keyring_enabled(), "keyring storage disabled");
    let payload = serde_json::to_string(auth)?;
    keyring_entry()?.set_password(&payload)?;
    tracing::info!("auth: session credential written to system keyring");
    Ok(())
}

#[cfg(not(any(target_os = "macos", windows)))]
pub(crate) fn keyring_write_session(_auth: &KimiAuth) -> anyhow::Result<()> {
    anyhow::bail!("keyring storage is not supported on this platform")
}

/// Delete the session credential from the system keyring (Ok when absent).
#[cfg(any(target_os = "macos", windows))]
pub(crate) fn keyring_delete_session() -> anyhow::Result<()> {
    if !keyring_enabled() {
        return Ok(());
    }
    match keyring_entry()?.delete_credential() {
        Ok(()) => {
            tracing::info!("auth: session credential removed from system keyring");
            Ok(())
        }
        Err(keyring::Error::NoEntry) => Ok(()),
        Err(e) => Err(e.into()),
    }
}

#[cfg(not(any(target_os = "macos", windows)))]
pub(crate) fn keyring_delete_session() -> anyhow::Result<()> {
    Ok(())
}

/// RAII guard for an exclusive advisory lock on `auth.json.lock`.
/// The lock is released when the inner `File` is dropped (closing the FD).
pub(crate) struct AuthFileLock {
    pub(super) _file: File,
}

impl AuthFileLock {
    /// Returns `true` while this guard still refers to the **live**
    /// `auth.json.lock` inode.
    ///
    /// A waiter that finds a holder stuck past the stale-lock timeout breaks
    /// the lock by `unlink`ing the file and recreating it on a fresh inode
    /// (see [`crate::auth::manager::lock`]). The usual cause of a "stuck"
    /// holder is a process **suspended across system sleep** while holding the
    /// lock: it stays alive (so the kernel never releases its flock) yet makes
    /// no progress, so siblings break it. When such a holder resumes, its
    /// flock lives on the now-deleted inode — it no longer holds the live lock
    /// even though this `AuthFileLock` still exists.
    ///
    /// Callers about to perform an irreversible, lock-protected action
    /// (sending a refresh token to the IdP, writing `auth.json`) MUST
    /// re-validate first; otherwise two processes can spend the same refresh
    /// token and trip token-family revocation.
    ///
    /// Non-Unix has no inode concept, so this conservatively returns `true`.
    #[cfg(unix)]
    pub(crate) fn still_live(&self, auth_json_path: &Path) -> bool {
        use std::os::unix::fs::MetadataExt;
        let lock_path = auth_json_path.with_file_name("auth.json.lock");
        let (Ok(fd_meta), Ok(path_meta)) = (self._file.metadata(), std::fs::metadata(&lock_path))
        else {
            // Lock file gone or unreadable → we no longer hold the live lock.
            return false;
        };
        fd_meta.ino() == path_meta.ino() && fd_meta.dev() == path_meta.dev()
    }

    #[cfg(not(unix))]
    pub(crate) fn still_live(&self, _auth_json_path: &Path) -> bool {
        true
    }
}

pub fn read_auth_json(auth_file: &Path) -> std::io::Result<AuthStore> {
    let mut file = File::open(auth_file)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;

    // Empty files are valid (recover from prior crash/partial write).
    let trimmed = contents.trim();
    if trimmed.is_empty() {
        return Ok(AuthStore::new());
    }

    let map = serde_json::from_str(trimmed)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    Ok(map)
}

/// Read auth.json, returning an empty map if the file does not exist.
///
/// Non-empty corrupt JSON, permission errors, etc. are returned as errors
/// so the caller can decide whether to skip the write (to avoid clobbering
/// sibling scopes).
///
/// Kept for the test-only `persist_and_swap` and as a strict reader.
#[cfg_attr(
    not(test),
    expect(
        dead_code,
        reason = "used from tests only; remove expect when wired in production"
    )
)]
pub(crate) fn read_auth_json_or_empty(auth_file: &Path) -> std::io::Result<AuthStore> {
    match read_auth_json(auth_file) {
        Ok(map) => Ok(map),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthStore::new()),
        Err(e) => Err(e),
    }
}

/// Best-effort backup of a corrupt (unparseable) auth.json.
///
/// If the file exists and `read_auth_json` fails with `InvalidData`,
/// it is renamed to `auth.json.corrupt.<millis>` (sibling in the same
/// directory) and the backup path is returned. Used before recovery
/// writes so the original bytes are never silently lost.
pub(crate) fn backup_corrupt_auth_file(path: &Path) -> Option<PathBuf> {
    if !path.exists() {
        return None;
    }
    if read_auth_json(path).is_ok() {
        return None;
    }

    let ts = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis();

    let file_name = path
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| "auth.json".to_string());

    let backup_name = format!("{}.corrupt.{}", file_name, ts);
    let backup = path.with_file_name(backup_name);

    match std::fs::rename(path, &backup) {
        Ok(()) => {
            tracing::warn!(
                original = %path.display(),
                backup = %backup.display(),
                "auth: backed up corrupt auth.json before recovery write"
            );
            // Must reach unified.jsonl: the tracing line above is invisible
            // in production captures, and this is the only record of both
            // the corruption and where the original bytes went.
            kigi_log::unified_log::error(
                "auth: corrupt auth.json backed up",
                None,
                Some(serde_json::json!({
                    "original": path.display().to_string(),
                    "backup": backup.display().to_string(),
                })),
            );
            Some(backup)
        }
        Err(e) => {
            tracing::warn!(error = %e, "auth: failed to rename corrupt auth.json for backup");
            kigi_log::unified_log::error(
                "auth: corrupt auth.json backup failed",
                None,
                Some(serde_json::json!({
                    "original": path.display().to_string(),
                    "error": e.to_string(),
                })),
            );
            None
        }
    }
}

/// Read auth.json for an upcoming write, with recovery for corrupt files.
///
/// - Missing/empty → empty map (safe to write fresh)
/// - Valid JSON → parsed map
/// - Non-empty corrupt JSON → backs up to `auth.json.corrupt.<millis>`,
///   then returns empty map so the caller can write the new credential.
///
/// Other I/O errors (PermissionDenied, etc.) are still returned as errors.
pub(crate) fn read_auth_json_or_empty_recovering_corrupt(
    auth_file: &Path,
) -> std::io::Result<AuthStore> {
    match read_auth_json(auth_file) {
        Ok(map) => Ok(map),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthStore::new()),
        Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
            let _ = backup_corrupt_auth_file(auth_file);
            Ok(AuthStore::new())
        }
        Err(e) => Err(e),
    }
}

/// Persist `auth.json`, preferring a crash-safe atomic write but falling
/// back to a non-atomic in-place write when the disk is full.
///
/// The atomic path (temp + rename) needs free space >= the file size,
/// because the old file and a full temp copy coexist until the rename. On a
/// nearly-full disk that temp copy can fail with `StorageFull` (ENOSPC)
/// even though the credentials themselves are tiny. When that happens we
/// retry with an in-place truncate+write, which only needs the freed blocks
/// of the old file — far less than the temp-copy approach.
///
/// The in-place path is non-atomic, with two accepted trade-offs:
/// - If the in-place write itself fails (e.g. a concurrent process grabs the
///   just-freed blocks, or a crash mid-write), the prior bytes are restored
///   best-effort so a torn/empty file never *replaces* the previous on-disk
///   credential — on-disk state ends up no worse than before the attempt.
/// - Unlocked concurrent readers can still observe a torn (partial) file
///   during the brief write window; a partial file is healed on the next
///   read via [`read_auth_json_or_empty_recovering_corrupt`] (backup +
///   relogin). This window is inherent to any sub-1×-free single-file
///   replace and is preferable to persisting nothing at all, which would
///   leave every concurrent process with a stale, already-revoked token.
pub(super) fn write_auth_json(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
    write_auth_json_with(auth_file, auth_store, write_auth_json_atomic)
}

/// Dispatch helper: run `atomic`, and on `StorageFull` fall back to an
/// in-place write. Split out (with `atomic` injectable) so the disk-full
/// fallback is unit-testable without an actually-full filesystem.
fn write_auth_json_with(
    auth_file: &Path,
    auth_store: &AuthStore,
    atomic: fn(&Path, &AuthStore) -> std::io::Result<()>,
) -> std::io::Result<()> {
    match atomic(auth_file, auth_store) {
        Err(e) if e.kind() == std::io::ErrorKind::StorageFull => {
            tracing::warn!(
                path = %auth_file.display(),
                "auth: disk full during atomic write, falling back to in-place write"
            );
            // Must reach unified.jsonl: a silent in-memory-only credential
            // (the prior behavior) leaves sibling processes with a stale
            // refresh token and no record of why. Surface it loudly.
            kigi_log::unified_log::warn(
                "auth: disk full, falling back to non-atomic in-place write",
                None,
                Some(serde_json::json!({
                    "path": auth_file.display().to_string(),
                })),
            );
            write_auth_json_in_place(auth_file, auth_store)
        }
        other => other,
    }
}

/// Serialize `auth_store` to `path` (truncate + rewrite), owner-only (0o600)
/// and `fsync`'d. Shared core of the atomic path (which targets the temp
/// file) and the in-place fallback (which targets `auth.json` directly).
///
/// Uses streaming `to_writer_pretty` through a `BufWriter` to avoid
/// allocating the entire JSON string in memory — eliminates OOM risk under
/// severe memory pressure.
fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
    use crate::util::secure_file::open_secure_file;

    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let file = open_secure_file(path)?;
    let mut writer = std::io::BufWriter::new(file);
    serde_json::to_writer_pretty(&mut writer, auth_store)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    writer.flush()?;
    writer
        .into_inner()
        .map_err(|e| e.into_error())?
        .sync_all()?;
    #[cfg(windows)]
    {
        crate::util::secure_file::set_windows_secure_permissions(path)?;
    }
    Ok(())
}

/// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`,
/// which this site's inline delete-first pattern graduated into).
fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
    let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id()));
    write_store_to(&tmp, auth_store)?;
    crate::util::fs::replace_file(&tmp, auth_file)
}

/// Non-atomic fallback: truncate and rewrite `auth.json` in place.
///
/// Used only when [`write_auth_json_atomic`] fails with `StorageFull`.
/// Opening with truncation first frees the old content's blocks before the
/// new bytes are written, so this needs only the file size in free space
/// rather than the temp-copy approach's file-size-of-headroom.
///
/// Truncation is destructive, so the prior bytes are snapshotted first and
/// restored best-effort if the rewrite fails partway — a failed fallback
/// must not leave an empty/torn file where a parseable (if stale) credential
/// used to be. A partial file that survives (because even the restore failed)
/// is healed on the next read via [`read_auth_json_or_empty_recovering_corrupt`].
fn write_auth_json_in_place(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
    write_auth_json_in_place_with(auth_file, auth_store, write_store_to)
}

/// Inner of [`write_auth_json_in_place`] with `write` injectable so the
/// rollback-on-failure path is unit-testable without an actually-full disk.
fn write_auth_json_in_place_with(
    auth_file: &Path,
    auth_store: &AuthStore,
    write: fn(&Path, &AuthStore) -> std::io::Result<()>,
) -> std::io::Result<()> {
    // Snapshot the prior bytes so a torn/empty write can be rolled back to
    // the previous on-disk credential. `None` when the file is absent.
    let prior = std::fs::read(auth_file).ok();
    match write(auth_file, auth_store) {
        Ok(()) => Ok(()),
        Err(e) => {
            if let Some(prior) = prior
                && let Err(restore_err) = restore_prior_bytes(auth_file, &prior)
            {
                tracing::warn!(
                    error = %restore_err,
                    "auth: failed to restore prior auth.json after in-place write failure"
                );
            }
            Err(e)
        }
    }
}

/// Best-effort rollback: rewrite `bytes` (owner-only, `fsync`'d) after a
/// failed in-place write so a torn/empty file does not replace the prior
/// credential.
fn restore_prior_bytes(auth_file: &Path, bytes: &[u8]) -> std::io::Result<()> {
    use crate::util::secure_file::open_secure_file;

    let mut file = open_secure_file(auth_file)?;
    file.write_all(bytes)?;
    file.sync_all()?;
    #[cfg(windows)]
    {
        crate::util::secure_file::set_windows_secure_permissions(auth_file)?;
    }
    Ok(())
}

/// Read a single auth token from `auth.json` by scope key.
pub fn read_token_by_scope(kigi_home: &Path, scope: &str) -> anyhow::Result<String> {
    let path = kigi_home.join("auth.json");
    let store =
        read_auth_json(&path).map_err(|_| anyhow::anyhow!("Not logged in. Run `kigi login`."))?;
    lookup_auth(&store, scope).map(|a| a.key).ok_or_else(|| {
        anyhow::anyhow!("Your auth token is invalid. Run `kigi login` to re-authenticate.")
    })
}

/// Read the API key from the `kigi::api_key` scope in auth.json.
pub fn read_api_key(kigi_home: &Path) -> Option<String> {
    let path = kigi_home.join("auth.json");
    let map = read_auth_json(&path).ok()?;
    map.get(API_KEY_SCOPE).map(|a| a.key.clone())
}

/// Store a plain API key in auth.json under the `kigi::api_key` scope.
///
/// Uses the corrupt-recovery reader so a malformed auth.json (e.g. from a
/// previous crash) can be healed when the user sets an API key.
pub fn store_api_key(kigi_home: &Path, api_key: &str) -> std::io::Result<()> {
    let path = kigi_home.join("auth.json");
    let mut map = read_auth_json_or_empty_recovering_corrupt(&path)?;
    map.insert(
        API_KEY_SCOPE.to_owned(),
        KimiAuth {
            key: api_key.to_owned(),
            auth_mode: AuthMode::ApiKey,
            ..Default::default()
        },
    );
    write_auth_json(&path, &map)
}

/// Read an API-key platform's key from auth.json. The scope is the platform
/// id itself (`anthropic`, `moonshot-cn`, …) — the stable per-provider
/// auth.json key contract. `None` when absent or unreadable.
pub fn read_platform_api_key(
    kigi_home: &Path,
    platform: kigi_models::PlatformId,
) -> Option<String> {
    let path = kigi_home.join("auth.json");
    let map = read_auth_json(&path).ok()?;
    map.get(platform.as_str()).map(|a| a.key.clone())
}

/// Store an API-key platform's key in auth.json under its platform-id scope.
/// Same corrupt-recovery + atomic-write path as [`store_api_key`]; all other
/// scopes (OAuth session, other platforms) are preserved.
///
/// SECURITY: the key must never be logged; errors carry only IO context.
pub fn store_platform_api_key(
    kigi_home: &Path,
    platform: kigi_models::PlatformId,
    api_key: &str,
) -> std::io::Result<()> {
    if platform.uses_oauth() {
        // Real error, not debug_assert: an OAuth scope written here would
        // shadow the session entry in release builds too.
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            format!(
                "{} authenticates via OAuth and takes no API key",
                platform.as_str()
            ),
        ));
    }
    let path = kigi_home.join("auth.json");
    // Serialize with the manager's cross-process auth.json writers (token
    // refresh holds the same flock): an unlocked read-modify-write here
    // could write back a pre-refresh map and revert a rotated refresh
    // token (token-family revocation → forced re-login). Bounded retry;
    // a sustained holder fails loudly rather than racing.
    let mut lock = None;
    for _ in 0..20 {
        lock = super::manager::try_lock_auth_file_nonblocking(&path);
        if lock.is_some() {
            break;
        }
        std::thread::sleep(std::time::Duration::from_millis(25));
    }
    let Some(_lock) = lock else {
        return Err(std::io::Error::new(
            std::io::ErrorKind::WouldBlock,
            "auth.json is locked by another kigi process; try again",
        ));
    };
    let mut map = read_auth_json_or_empty_recovering_corrupt(&path)?;
    map.insert(
        platform.as_str().to_owned(),
        KimiAuth {
            key: api_key.to_owned(),
            auth_mode: AuthMode::ApiKey,
            ..Default::default()
        },
    );
    write_auth_json(&path, &map)
}

/// Remove the `kigi::api_key` scope from auth.json.
pub fn clear_api_key(kigi_home: &Path) -> std::io::Result<()> {
    let path = kigi_home.join("auth.json");
    if let Ok(mut map) = read_auth_json(&path) {
        map.remove(API_KEY_SCOPE);
        if map.is_empty() {
            let _ = std::fs::remove_file(&path);
        } else {
            write_auth_json(&path, &map)?;
        }
    }
    Ok(())
}

#[cfg(test)]
mod platform_key_tests {
    use super::*;

    /// Store/read round-trip under the platform-id scope; the OAuth session
    /// scope and other platform scopes in the same file are preserved.
    #[test]
    fn platform_key_round_trip_preserves_other_scopes() {
        let dir = tempfile::tempdir().unwrap();
        let home = dir.path();
        // Pre-existing OAuth session entry must survive platform-key writes.
        let path = home.join("auth.json");
        let mut map = AuthStore::new();
        map.insert(
            crate::auth::KIMI_CODE_OAUTH_SCOPE.to_owned(),
            KimiAuth {
                key: "oauth-token".to_owned(),
                auth_mode: AuthMode::OAuth,
                ..Default::default()
            },
        );
        write_auth_json(&path, &map).unwrap();

        store_platform_api_key(home, kigi_models::PlatformId::MoonshotCn, "sk-cn").unwrap();
        store_platform_api_key(home, kigi_models::PlatformId::MoonshotAi, "sk-ai").unwrap();

        assert_eq!(
            read_platform_api_key(home, kigi_models::PlatformId::MoonshotCn).as_deref(),
            Some("sk-cn")
        );
        assert_eq!(
            read_platform_api_key(home, kigi_models::PlatformId::MoonshotAi).as_deref(),
            Some("sk-ai")
        );
        let stored = read_auth_json(&path).unwrap();
        assert_eq!(
            stored
                .get(crate::auth::KIMI_CODE_OAUTH_SCOPE)
                .map(|a| a.key.as_str()),
            Some("oauth-token"),
            "platform-key writes must not clobber the OAuth session scope"
        );
        assert_eq!(
            stored.get("moonshot-cn").map(|a| a.auth_mode.clone()),
            Some(AuthMode::ApiKey),
            "platform keys are stored as api_key mode under the platform id"
        );
    }

    /// The OAuth platform takes no API key — a real error in release builds.
    #[test]
    fn storing_key_for_oauth_platform_is_invalid_input() {
        let dir = tempfile::tempdir().unwrap();
        let err = store_platform_api_key(dir.path(), kigi_models::PlatformId::KimiCode, "sk-x")
            .expect_err("oauth platform must reject api keys");
        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
        assert!(
            !dir.path().join("auth.json").exists(),
            "rejected write must not create auth.json"
        );
    }

    /// Missing file reads as None (not an error) — resolution treats absent
    /// auth.json as "no stored key".
    #[test]
    fn reading_platform_key_without_auth_json_is_none() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(
            read_platform_api_key(dir.path(), kigi_models::PlatformId::MoonshotCn),
            None
        );
    }
}

#[cfg(test)]
mod write_fallback_tests {
    use super::*;

    fn sample_store() -> AuthStore {
        let mut map = AuthStore::new();
        map.insert(
            API_KEY_SCOPE.to_owned(),
            KimiAuth {
                key: "secret-key".to_owned(),
                auth_mode: AuthMode::ApiKey,
                ..Default::default()
            },
        );
        map
    }

    fn read_key(path: &Path) -> Option<String> {
        read_auth_json(path)
            .ok()
            .and_then(|m| m.get(API_KEY_SCOPE).map(|a| a.key.clone()))
    }

    fn fake_storage_full(_: &Path, _: &AuthStore) -> std::io::Result<()> {
        Err(std::io::Error::from(std::io::ErrorKind::StorageFull))
    }

    fn fake_permission_denied(_: &Path, _: &AuthStore) -> std::io::Result<()> {
        Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
    }

    /// Simulates an in-place write that truncates the file (destroying the
    /// old content, as `open_secure_file` does) and then fails partway — the
    /// torn-write case the rollback must recover from.
    fn fake_truncate_then_fail(path: &Path, _: &AuthStore) -> std::io::Result<()> {
        crate::util::secure_file::open_secure_file(path)?; // truncates to 0 bytes
        Err(std::io::Error::from(std::io::ErrorKind::StorageFull))
    }

    #[test]
    fn in_place_write_roundtrips() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("auth.json");
        write_auth_json_in_place(&path, &sample_store()).unwrap();
        assert_eq!(read_key(&path).as_deref(), Some("secret-key"));
    }

    #[cfg(unix)]
    #[test]
    fn in_place_write_is_owner_only() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("auth.json");
        write_auth_json_in_place(&path, &sample_store()).unwrap();
        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
        assert_eq!(mode & 0o777, 0o600, "in-place write must stay 0o600");
    }

    /// A `StorageFull` (ENOSPC) failure on the atomic path must fall back to
    /// the in-place write so the credential still lands on disk.
    #[test]
    fn falls_back_to_in_place_on_storage_full() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("auth.json");
        write_auth_json_with(&path, &sample_store(), fake_storage_full).unwrap();
        assert_eq!(
            read_key(&path).as_deref(),
            Some("secret-key"),
            "disk-full atomic write must fall back to a successful in-place write"
        );
    }

    /// Non-ENOSPC errors must propagate unchanged and must NOT trigger the
    /// in-place fallback (e.g. a permission error should not write the file).
    #[test]
    fn propagates_non_storage_full_errors() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("auth.json");
        let err = write_auth_json_with(&path, &sample_store(), fake_permission_denied).unwrap_err();
        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
        assert!(!path.exists(), "non-ENOSPC failure must not write the file");
    }

    /// The normal (real atomic) path still works end to end.
    #[test]
    fn atomic_write_roundtrips() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("auth.json");
        write_auth_json(&path, &sample_store()).unwrap();
        assert_eq!(read_key(&path).as_deref(), Some("secret-key"));
    }

    /// A fallback write that truncates then fails must roll back to the prior
    /// bytes instead of leaving an empty/torn file — otherwise a second
    /// disk-full failure would destroy a previously-valid credential.
    #[test]
    fn in_place_restores_prior_bytes_on_failure() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("auth.json");
        // Seed a valid prior credential.
        write_auth_json_in_place(&path, &sample_store()).unwrap();
        assert_eq!(read_key(&path).as_deref(), Some("secret-key"));

        let mut replacement = AuthStore::new();
        replacement.insert(
            API_KEY_SCOPE.to_owned(),
            KimiAuth {
                key: "replacement-key".to_owned(),
                auth_mode: AuthMode::ApiKey,
                ..Default::default()
            },
        );
        let err = write_auth_json_in_place_with(&path, &replacement, fake_truncate_then_fail)
            .unwrap_err();
        assert_eq!(err.kind(), std::io::ErrorKind::StorageFull);
        assert_eq!(
            read_key(&path).as_deref(),
            Some("secret-key"),
            "a failed in-place write must restore the prior credential, not leave an empty file"
        );
    }

    /// Rollback after a failed write must keep the file owner-only (0o600).
    #[cfg(unix)]
    #[test]
    fn in_place_restore_is_owner_only() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("auth.json");
        write_auth_json_in_place(&path, &sample_store()).unwrap();
        let _ = write_auth_json_in_place_with(&path, &sample_store(), fake_truncate_then_fail);
        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
        assert_eq!(mode & 0o777, 0o600, "restored file must stay 0o600");
    }
}

#[cfg(all(test, any(target_os = "macos", windows)))]
mod keyring_tests {
    use super::*;
    use chrono::Utc;

    /// RAII teardown so a panicking test doesn't leave the process-global
    /// test-keyring toggle enabled for later tests.
    struct MockKeyringGuard;
    impl MockKeyringGuard {
        fn enable() -> Self {
            enable_mock_keyring_for_test();
            Self
        }
    }
    impl Drop for MockKeyringGuard {
        fn drop(&mut self) {
            disable_mock_keyring_for_test();
        }
    }

    fn session_auth(key: &str, rt: &str) -> KimiAuth {
        KimiAuth {
            key: key.into(),
            refresh_token: Some(rt.into()),
            expires_at: Some(Utc::now() + chrono::Duration::seconds(3600)),
            expires_in: Some(3600),
            scope: Some("kimi-code".into()),
            token_type: Some("bearer".into()),
            ..KimiAuth::test_default()
        }
    }

    #[test]
    #[serial_test::serial(kigi_keyring)]
    fn keyring_session_roundtrip() {
        let _guard = MockKeyringGuard::enable();
        // Fresh mock store: nothing there yet.
        assert!(matches!(keyring_read_session(), KeyringRead::Missing));

        keyring_write_session(&session_auth("at-1", "rt-1")).unwrap();
        let KeyringRead::Found(read) = keyring_read_session() else {
            panic!("expected Found after write");
        };
        assert_eq!(read.key, "at-1");
        assert_eq!(read.refresh_token.as_deref(), Some("rt-1"));
        assert_eq!(read.expires_in, Some(3600));

        // Overwrite rotates in place.
        keyring_write_session(&session_auth("at-2", "rt-2")).unwrap();
        let KeyringRead::Found(read) = keyring_read_session() else {
            panic!("expected Found after rotate");
        };
        assert_eq!(read.key, "at-2");

        // Delete is idempotent.
        keyring_delete_session().unwrap();
        assert!(matches!(keyring_read_session(), KeyringRead::Missing));
        keyring_delete_session().unwrap();
    }

    #[test]
    #[serial_test::serial(kigi_keyring)]
    fn keyring_disabled_reads_unavailable() {
        disable_mock_keyring_for_test();
        assert!(matches!(keyring_read_session(), KeyringRead::Unavailable));
        assert!(keyring_write_session(&session_auth("a", "r")).is_err());
        // Delete when disabled is a no-op success (logout stays best-effort).
        keyring_delete_session().unwrap();
    }
}
