//! Cross-platform secret store for Common Agent Runtime.
//!
//! Unifies OS-native secure storage across the three platforms CAR targets:
//!
//! - **macOS** — `/usr/bin/security` over Keychain Services
//! - **Windows** — Credential Manager (DPAPI)
//! - **Linux** — Secret Service (GNOME Keyring / KWallet / KeePassXC /
//!   anything else that speaks `org.freedesktop.secrets`)
//!
//! The API is intentionally small: `put`, `get`, `delete`, `status`, `list`.
//! Callers choose a namespace (`service`) and a key (`account`); values are
//! UTF-8 strings. JSON helpers are provided for structured values.
//!
//! # Availability
//!
//! On headless Linux without a Secret Service daemon, `put`/`get`/`delete`
//! return [`SecretError::Unavailable`]. This is explicit: there is no silent
//! plaintext fallback. Callers should probe [`is_available`] before relying on
//! the store, or handle `Unavailable` with their own fallback.
//!
//! # Security boundary
//!
//! Secrets never enter CAR memory, state, or prompt context unless a caller
//! explicitly reads them and passes them into one of those systems. The store
//! treats a missing backend as a hard error so misconfigured environments are
//! loud, not silently insecure.

use keyring::Entry;
use serde::{Deserialize, Serialize};
use thiserror::Error;

/// Default service (namespace) used when callers don't supply one.
///
/// `"car"` is the per-app namespace shared by every CAR component
/// (`car-cli`, `car-inference` model-key fallback, FFI bindings, WebSocket
/// `secret.*` methods). One shared bucket means `car secrets put OPENAI_API_KEY`
/// stores the same entry that `car-inference` reads at runtime — no namespace
/// translation in users' heads.
///
/// Pre-v0.5.2 this was `"car-runtime"`. The rename was a one-time UX change;
/// any keychain entries written before that date live under the old service
/// name and need to be migrated (or just `car secrets put` again).
pub const DEFAULT_SERVICE: &str = "car";

/// Resolve a raw key value for `env_var` from the standard CAR
/// sources, in priority order:
///
/// 1. **Process env var** — `std::env::var(env_var)`. Wins
///    everything (containers, CI, K8s pods, systemd units).
///    `~/.car/env` is loaded into the process env at server
///    startup, so file-based config flows through this path too.
/// 2. **OS keychain via [`SecretStore`]** — looked up under
///    [`DEFAULT_SERVICE`] = `"car"` with account = `env_var`.
///    Skipped silently when [`SecretStore::is_available`] is
///    false so we never wake pinentry on a locked desktop or
///    dial DBus on a headless Linux box.
/// 3. **Missing** — returns `None`.
///
/// This is the single source of truth for CAR's API-key
/// resolution. Every call site that wants "env first, then
/// keychain" should go through here so the priority can't drift
/// (`car-inference::key_pool`, `car-voice::elevenlabs_*`, and
/// any future remote backend land here, not on their own
/// re-implementation).
pub fn resolve_env_or_keychain(env_var: &str) -> Option<String> {
    if let Ok(v) = std::env::var(env_var) {
        if !v.is_empty() {
            return Some(v);
        }
    }
    let store = SecretStore::new();
    if !store.is_available() {
        return None;
    }
    let secret_ref = SecretRef::new(DEFAULT_SERVICE, env_var);
    match store.get(&secret_ref) {
        Ok(v) if !v.is_empty() => {
            tracing::debug!(env_var = %env_var, "resolved API key from OS keychain");
            Some(v)
        }
        Ok(_) => None, // empty value — treat as missing
        Err(SecretError::NotFound { .. }) => None,
        Err(e) => {
            tracing::warn!(env_var = %env_var, error = %e, "keychain lookup failed");
            None
        }
    }
}

/// Errors the secret store can produce.
#[derive(Debug, Error)]
pub enum SecretError {
    /// No OS backend is available (e.g. headless Linux with no Secret
    /// Service daemon, or a keychain that refused to unlock).
    #[error("secret store unavailable: {0}")]
    Unavailable(String),

    /// The requested entry does not exist.
    #[error("no entry for service={service:?} key={key:?}")]
    NotFound { service: String, key: String },

    /// An OS-native error the store couldn't classify — usually surfaced
    /// verbatim from the underlying keychain API.
    #[error("secret store error: {0}")]
    Backend(String),

    /// A JSON helper was used but the stored value wasn't valid JSON.
    #[error("stored value is not valid JSON: {0}")]
    InvalidJson(String),
}

/// Status of an entry — no value data, safe to log.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretStatus {
    pub service: String,
    pub key: String,
    pub exists: bool,
}

/// Result of `SecretStore::availability` — `available` mirrors what
/// `is_available` returns, and `reason` carries the platform-specific
/// detail (e.g. "no Secret Service daemon", "keychain locked") so the
/// FFI surface can report an actionable message instead of a bare
/// boolean.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailabilityCheck {
    pub available: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

/// Logical handle for a secret — (service, key) pair.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SecretRef {
    pub service: String,
    pub key: String,
}

impl SecretRef {
    pub fn new(service: impl Into<String>, key: impl Into<String>) -> Self {
        Self {
            service: service.into(),
            key: key.into(),
        }
    }

    pub fn with_default_service(key: impl Into<String>) -> Self {
        Self {
            service: DEFAULT_SERVICE.to_string(),
            key: key.into(),
        }
    }
}

/// Cross-platform secret store backed by the host OS keychain.
///
/// Stateless by design — it holds no cached secrets. Every call round-trips
/// to the OS. That makes concurrent usage safe and avoids any in-process
/// leak surface beyond the immediate call's return value.
#[derive(Debug, Default, Clone, Copy)]
pub struct SecretStore;

impl SecretStore {
    pub fn new() -> Self {
        Self
    }

    /// Store a UTF-8 secret under `(service, key)`. Replaces any existing
    /// value at the same ref.
    ///
    /// On macOS, writes via `/usr/bin/security add-generic-password -U -A`
    /// so the resulting item has a permissive ACL — readable by any
    /// binary the user runs. This is necessary because the legacy
    /// keychain's default ACL binds an item to the calling binary's
    /// code-signing hash, which changes on every cargo rebuild and
    /// silently revokes read access from later versions of the same
    /// CLI tool. (`/usr/bin/security` is Apple-signed with full
    /// keychain entitlements — the same path reads, status checks,
    /// and deletes use, and the same path users invoke manually.)
    ///
    /// Trade-off: the value transits argv during the spawn (visible to
    /// `ps` from the same user for ~milliseconds). Acceptable for the
    /// "single-user developer machine" threat model; any process that
    /// can see argv on this machine can also read the keychain
    /// directly via `security`. On other platforms, behavior is
    /// unchanged (keyring crate's native backend).
    pub fn put(&self, r: &SecretRef, value: &str) -> Result<(), SecretError> {
        platform_put(self, r, value)
    }

    /// Store a structured value serialized as JSON.
    pub fn put_json<T: Serialize>(&self, r: &SecretRef, value: &T) -> Result<(), SecretError> {
        let s = serde_json::to_string(value)
            .map_err(|e| SecretError::Backend(format!("serialize: {}", e)))?;
        self.put(r, &s)
    }

    /// Read a UTF-8 secret. Returns `NotFound` if no entry exists.
    ///
    /// On macOS, reads through `/usr/bin/security` first so repeated
    /// helper rebuilds do not churn Keychain prompts against each
    /// binary's CDHash. Backend/authorization failures are returned
    /// directly instead of falling back to an in-process read path that
    /// can trigger a second prompt.
    pub fn get(&self, r: &SecretRef) -> Result<String, SecretError> {
        platform_get(self, r)
    }

    /// Read a structured value previously stored via `put_json`.
    pub fn get_json<T: for<'de> Deserialize<'de>>(&self, r: &SecretRef) -> Result<T, SecretError> {
        let raw = self.get(r)?;
        serde_json::from_str(&raw).map_err(|e| SecretError::InvalidJson(e.to_string()))
    }

    /// Delete an entry. Returns Ok even if the entry didn't exist — idempotent
    /// from the caller's perspective.
    ///
    /// On macOS, deletes through `/usr/bin/security` first so the
    /// Apple-signed helper, not the rebuilt caller binary, owns
    /// Keychain authorization.
    pub fn delete(&self, r: &SecretRef) -> Result<(), SecretError> {
        platform_delete(self, r)
    }

    /// Existence check without returning the value. Safe to log.
    ///
    /// On macOS, checks status through `/usr/bin/security` first for
    /// the same CDHash-stable authorization behavior as `get`.
    pub fn status(&self, r: &SecretRef) -> Result<SecretStatus, SecretError> {
        platform_status(self, r)
    }

    /// Reserved internal service name used for availability probing.
    /// Consumers must not write user secrets under this service. Kept
    /// in sync with `DEFAULT_SERVICE` ("car") so all CAR-owned
    /// keychain entries share the `car-` prefix and a future cleanup
    /// pass can sweep them with one wildcard.
    const PROBE_SERVICE: &'static str = "car-internal";
    const PROBE_KEY: &'static str = "__availability_probe__";

    /// Probe whether the OS secret store is reachable.
    ///
    /// Opens an Entry for an internal-only sentinel and attempts to read
    /// it. Returns `true` iff the backend responds with either a value or
    /// `NoEntry` — both mean the store is reachable; `PlatformFailure` /
    /// `NoStorageAccess` mean it isn't.
    ///
    /// # Side effects
    ///
    /// - On macOS with a locked keychain, this may trigger a user
    ///   unlock prompt. Call only when the caller is ready to handle
    ///   that UX.
    /// - On Linux it opens a DBus connection to Secret Service.
    /// - Performance: one round-trip to the OS store. Not cached.
    pub fn is_available(&self) -> bool {
        self.availability().available
    }

    /// Detailed availability probe. Same round-trip as `is_available`,
    /// but distinguishes "no backend at all" from a specific platform
    /// failure so the FFI surface can emit a `reason` matching the
    /// pattern used by the other v0.4 capability probes
    /// (`accountsList`, `calendarList`, etc.).
    pub fn availability(&self) -> AvailabilityCheck {
        // Reason is only populated when `available == false`. Reachable
        // backends never carry a reason — callers can rely on
        // `available && reason.is_none()` for happy-path branching.
        // The opt-in file backend (test/headless redirect) is always
        // "available" — it is just the local filesystem.
        if file_backend_dir().is_some() {
            return AvailabilityCheck {
                available: true,
                reason: None,
            };
        }
        let probe = SecretRef::new(Self::PROBE_SERVICE, Self::PROBE_KEY);
        match self.entry(&probe) {
            Ok(entry) => match entry.get_password() {
                Ok(_) | Err(keyring::Error::NoEntry) => AvailabilityCheck {
                    available: true,
                    reason: None,
                },
                Err(keyring::Error::PlatformFailure(e)) => AvailabilityCheck {
                    available: false,
                    reason: Some(format!("platform failure: {e}")),
                },
                Err(keyring::Error::NoStorageAccess(e)) => AvailabilityCheck {
                    available: false,
                    reason: Some(format!("no storage access: {e}")),
                },
                // Other keyring errors (BadEncoding etc.) on the
                // probe key indicate the backend responded but
                // returned something unexpected. Treat as available
                // so the caller can still try real ops; the failure
                // mode shows up at the next put/get with proper
                // typed error.
                Err(_) => AvailabilityCheck {
                    available: true,
                    reason: None,
                },
            },
            Err(SecretError::Unavailable(reason)) => AvailabilityCheck {
                available: false,
                reason: Some(reason),
            },
            Err(other) => AvailabilityCheck {
                available: false,
                reason: Some(other.to_string()),
            },
        }
    }

    fn entry(&self, r: &SecretRef) -> Result<Entry, SecretError> {
        Entry::new(&r.service, &r.key).map_err(|e| classify(e, "entry"))
    }
}

// ---------------------------------------------------------------------------
// Platform-dispatched keychain operations.
//
// macOS: shell out to `/usr/bin/security` for reads, writes, status checks,
// and deletes. The Apple-signed helper keeps Keychain authorization stable
// across rebuilt CAR helper binaries whose CDHash changes. Writes also use
// `-A` so the item itself is not bound to one transient debug binary.
//
// Other platforms: pass through to keyring (its native backends behave
// correctly).
// ---------------------------------------------------------------------------

/// Test/headless redirect: when `CAR_SECRETS_FILE_DIR` names a directory, the
/// store is backed by plaintext files there instead of the OS keychain. This is
/// the SAME env-keyed-redirect idiom as `resolve_env_or_keychain`'s
/// process-env precedence — a no-op in production (the daemon never sets this
/// var), but it lets tests drive the real `put`/`get`/`delete` code path WITHOUT
/// the macOS keychain's interactive-authorization prompt (which cancels
/// unattended, `code=154`). Each secret lands at `<dir>/<service>.<key>`.
///
/// SECURITY: plaintext on disk — acceptable ONLY because this is opt-in via an
/// env var production never sets. The keychain remains the sole production
/// backing store.
///
/// Two hard guards make a production leak structurally impossible:
///
/// 1. **Release builds refuse it entirely.** The redirect is honored ONLY under
///    `cfg!(debug_assertions)` (debug/test builds). A RELEASE binary — which is
///    what production ships — returns `None` even when the env var is set, so a
///    stray `CAR_SECRETS_FILE_DIR` can never route real secrets to plaintext in
///    prod.
/// 2. **First engagement warns loudly.** The first time the redirect is honored
///    in a process, a one-time `tracing::warn!` fires so a misconfigured dev /
///    CI run is visible, not silent.
fn file_backend_dir() -> Option<std::path::PathBuf> {
    // Release builds (production) NEVER honor the redirect — secrets always go
    // to the OS keychain. The env var is a debug/test-only seam.
    if !cfg!(debug_assertions) {
        return None;
    }
    match std::env::var_os("CAR_SECRETS_FILE_DIR") {
        Some(d) if !d.is_empty() => {
            // One-time loud warning the first time the plaintext file backend
            // engages in this process.
            static WARNED: std::sync::Once = std::sync::Once::new();
            WARNED.call_once(|| {
                tracing::warn!(
                    "CAR_SECRETS_FILE_DIR set — secrets are PLAINTEXT ON DISK; \
                     test-only, never production"
                );
            });
            Some(std::path::PathBuf::from(d))
        }
        _ => None,
    }
}

fn file_backend_path(dir: &std::path::Path, r: &SecretRef) -> std::path::PathBuf {
    // Sanitize path separators so a service/key never escapes the dir.
    let sanitize = |s: &str| s.replace(['/', '\\', '.'], "_");
    dir.join(format!("{}.{}", sanitize(&r.service), sanitize(&r.key)))
}

fn file_backend_put(dir: &std::path::Path, r: &SecretRef, value: &str) -> Result<(), SecretError> {
    std::fs::create_dir_all(dir)
        .map_err(|e| SecretError::Backend(format!("file backend mkdir: {e}")))?;
    std::fs::write(file_backend_path(dir, r), value)
        .map_err(|e| SecretError::Backend(format!("file backend write: {e}")))
}

fn file_backend_get(dir: &std::path::Path, r: &SecretRef) -> Result<String, SecretError> {
    match std::fs::read_to_string(file_backend_path(dir, r)) {
        Ok(v) => Ok(v),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(SecretError::NotFound {
            service: r.service.clone(),
            key: r.key.clone(),
        }),
        Err(e) => Err(SecretError::Backend(format!("file backend read: {e}"))),
    }
}

fn file_backend_delete(dir: &std::path::Path, r: &SecretRef) -> Result<(), SecretError> {
    match std::fs::remove_file(file_backend_path(dir, r)) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(SecretError::Backend(format!("file backend delete: {e}"))),
    }
}

fn file_backend_status(dir: &std::path::Path, r: &SecretRef) -> SecretStatus {
    SecretStatus {
        service: r.service.clone(),
        key: r.key.clone(),
        exists: file_backend_path(dir, r).exists(),
    }
}

#[cfg(target_os = "macos")]
fn platform_put(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
    if let Some(dir) = file_backend_dir() {
        return file_backend_put(&dir, r, value);
    }
    mac_put_via_security_cli(&r.service, &r.key, value)
}

#[cfg(not(target_os = "macos"))]
fn platform_put(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
    if let Some(dir) = file_backend_dir() {
        return file_backend_put(&dir, r, value);
    }
    let entry = store.entry(r)?;
    entry
        .set_password(value)
        .map_err(|e| classify(e, "set_password"))
}

#[cfg(target_os = "macos")]
fn platform_get(_store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
    if let Some(dir) = file_backend_dir() {
        return file_backend_get(&dir, r);
    }
    mac_get_via_security_cli(r)
}

#[cfg(not(target_os = "macos"))]
fn platform_get(store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
    if let Some(dir) = file_backend_dir() {
        return file_backend_get(&dir, r);
    }
    let entry = store.entry(r)?;
    match entry.get_password() {
        Ok(v) => Ok(v),
        Err(keyring::Error::NoEntry) => Err(SecretError::NotFound {
            service: r.service.clone(),
            key: r.key.clone(),
        }),
        Err(other) => Err(classify(other, "get_password")),
    }
}

#[cfg(target_os = "macos")]
fn platform_delete(_store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
    if let Some(dir) = file_backend_dir() {
        return file_backend_delete(&dir, r);
    }
    mac_delete_via_security_cli(r)
}

#[cfg(not(target_os = "macos"))]
fn platform_delete(store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
    if let Some(dir) = file_backend_dir() {
        return file_backend_delete(&dir, r);
    }
    let entry = store.entry(r)?;
    match entry.delete_credential() {
        Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
        Err(other) => Err(classify(other, "delete_credential")),
    }
}

#[cfg(target_os = "macos")]
fn platform_status(_store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
    if let Some(dir) = file_backend_dir() {
        return Ok(file_backend_status(&dir, r));
    }
    mac_status_via_security_cli(r)
}

#[cfg(not(target_os = "macos"))]
fn platform_status(store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
    if let Some(dir) = file_backend_dir() {
        return Ok(file_backend_status(&dir, r));
    }
    let entry = store.entry(r)?;
    let exists = match entry.get_password() {
        Ok(_) => true,
        Err(keyring::Error::NoEntry) => false,
        Err(other) => return Err(classify(other, "status")),
    };
    Ok(SecretStatus {
        service: r.service.clone(),
        key: r.key.clone(),
        exists,
    })
}

/// Shell-out write with the `-A` flag (any-app ACL).
///
/// `service`/`account` are passed as separate argv tokens so shell
/// metacharacters in either are inert. The value is the only argv slot
/// that's a secret; document the trade-off at the call site.
///
/// Always issues `delete-generic-password` first (best-effort, errors
/// ignored) so the subsequent `add-generic-password` creates a fresh
/// keychain item with a fresh ACL. Without the pre-delete,
/// `add-generic-password -U` would update the value in place but
/// preserve any existing CDHash-bound ACL from a previous binary —
/// causing the Apple-signed `/usr/bin/security` reader to be prompted
/// for authorization on every subsequent `-g` retrieval. The `-U` flag
/// is retained on `add` as a safety net for the (rare) case where
/// delete returned non-zero non-NotFound and the entry is somehow
/// still present.
#[cfg(target_os = "macos")]
fn mac_put_via_security_cli(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
    mac_put_via_security_cli_with(service, account, value, &SystemSecurityCli)
}

#[cfg(target_os = "macos")]
fn mac_put_via_security_cli_with(
    service: &str,
    account: &str,
    value: &str,
    cli: &impl SecurityCli,
) -> Result<(), SecretError> {
    // Best-effort delete: clears any pre-existing item so the add below
    // installs a brand-new ACL via `-A`. Failures (including NotFound) are
    // ignored — the add path handles the residual-item case via `-U`.
    let _ = cli.output(&["delete-generic-password", "-s", service, "-a", account]);

    let output = cli
        .output(&[
            "add-generic-password",
            "-U", // safety net if the pre-delete didn't actually remove the item
            "-A", // permissive ACL — any app can read
            "-s",
            service,
            "-a",
            account,
            "-w",
            value,
        ])
        .map_err(|e| security_cli_spawn_error("add-generic-password", e))?;
    if output.success {
        return Ok(());
    }
    Err(security_cli_backend_error("add-generic-password", output))
}

#[cfg(target_os = "macos")]
const SECURITY_ERR_SEC_ITEM_NOT_FOUND: i32 = 44;

#[cfg(target_os = "macos")]
#[derive(Debug)]
struct SecurityCliOutput {
    success: bool,
    code: Option<i32>,
    stdout: Vec<u8>,
    stderr: Vec<u8>,
}

#[cfg(target_os = "macos")]
trait SecurityCli {
    fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput>;
}

#[cfg(target_os = "macos")]
struct SystemSecurityCli;

#[cfg(target_os = "macos")]
impl SecurityCli for SystemSecurityCli {
    fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
        use std::process::{Command, Stdio};
        let output = Command::new("/usr/bin/security")
            .args(args)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .output()?;
        Ok(SecurityCliOutput {
            success: output.status.success(),
            code: output.status.code(),
            stdout: output.stdout,
            stderr: output.stderr,
        })
    }
}

/// Primary macOS value read:
/// `/usr/bin/security find-generic-password -s SVC -a KEY -g`.
/// `-g` prints the password metadata line to stderr and preserves the
/// password bytes as hex when the value contains non-printable UTF-8
/// bytes. Service/key are passed as separate argv values, never
/// interpolated into a shell, so there's no injection surface even if a
/// key contains shell metacharacters.
#[cfg(target_os = "macos")]
fn mac_get_via_security_cli(r: &SecretRef) -> Result<String, SecretError> {
    mac_get_via_security_cli_with(r, &SystemSecurityCli)
}

#[cfg(target_os = "macos")]
fn mac_get_via_security_cli_with(
    r: &SecretRef,
    cli: &impl SecurityCli,
) -> Result<String, SecretError> {
    let output = cli
        .output(&[
            "find-generic-password",
            "-s",
            &r.service,
            "-a",
            &r.key,
            "-g",
        ])
        .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
    if !output.success {
        return security_cli_not_found_or_backend("find-generic-password", r, output);
    }
    mac_parse_security_cli_password(&output)
}

#[cfg(target_os = "macos")]
fn mac_parse_security_cli_password(output: &SecurityCliOutput) -> Result<String, SecretError> {
    let line = mac_security_cli_text(&output.stderr, "stderr")?
        .lines()
        .find(|line| line.starts_with("password:"))
        .or_else(|| {
            mac_security_cli_text(&output.stdout, "stdout")
                .ok()
                .and_then(|stdout| stdout.lines().find(|line| line.starts_with("password:")))
        })
        .ok_or_else(|| {
            SecretError::Backend(
                "/usr/bin/security find-generic-password -g did not print a password line"
                    .to_string(),
            )
        })?;

    let payload = line
        .strip_prefix("password:")
        .expect("password line prefix was checked")
        .trim_start();

    if payload.is_empty() {
        return Ok(String::new());
    }

    let bytes = if let Some(hex_and_preview) = payload.strip_prefix("0x") {
        mac_decode_security_cli_hex_password(hex_and_preview)?
    } else {
        mac_decode_security_cli_quoted_password(payload)?
    };

    String::from_utf8(bytes).map_err(|e| {
        SecretError::Backend(format!(
            "/usr/bin/security find-generic-password password was not valid utf-8: {}",
            e
        ))
    })
}

#[cfg(target_os = "macos")]
fn mac_security_cli_text<'a>(bytes: &'a [u8], stream: &str) -> Result<&'a str, SecretError> {
    std::str::from_utf8(bytes).map_err(|e| {
        SecretError::Backend(format!(
            "/usr/bin/security find-generic-password {stream} was not valid utf-8: {e}"
        ))
    })
}

#[cfg(target_os = "macos")]
fn mac_decode_security_cli_hex_password(hex_and_preview: &str) -> Result<Vec<u8>, SecretError> {
    let hex: String = hex_and_preview
        .chars()
        .take_while(|c| c.is_ascii_hexdigit())
        .collect();
    if hex.is_empty() || hex.len() % 2 != 0 {
        return Err(SecretError::Backend(format!(
            "/usr/bin/security find-generic-password printed invalid password hex: {hex:?}"
        )));
    }

    (0..hex.len())
        .step_by(2)
        .map(|i| {
            u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| {
                SecretError::Backend(format!(
                    "/usr/bin/security find-generic-password printed invalid password hex: {e}"
                ))
            })
        })
        .collect()
}

#[cfg(target_os = "macos")]
fn mac_decode_security_cli_quoted_password(payload: &str) -> Result<Vec<u8>, SecretError> {
    let quoted = payload.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
    match quoted {
        Some(value) => Ok(value.as_bytes().to_vec()),
        None => Err(SecretError::Backend(
            "/usr/bin/security find-generic-password printed an unrecognized password line"
                .to_string(),
        )),
    }
}

#[cfg(target_os = "macos")]
fn mac_status_via_security_cli(r: &SecretRef) -> Result<SecretStatus, SecretError> {
    mac_status_via_security_cli_with(r, &SystemSecurityCli)
}

#[cfg(target_os = "macos")]
fn mac_status_via_security_cli_with(
    r: &SecretRef,
    cli: &impl SecurityCli,
) -> Result<SecretStatus, SecretError> {
    let exists = mac_exists_via_security_cli_with(r, cli)?;
    Ok(SecretStatus {
        service: r.service.clone(),
        key: r.key.clone(),
        exists,
    })
}

/// Existence-only shell-out: `security find-generic-password -s SVC -a KEY`
/// (no `-w`). Exit 0 means found, exit 44 means absent. Other non-zero
/// exits are backend/authorization errors and must not fall through to
/// an in-process API that can prompt again under the caller binary's CDHash.
#[cfg(target_os = "macos")]
fn mac_exists_via_security_cli_with(
    r: &SecretRef,
    cli: &impl SecurityCli,
) -> Result<bool, SecretError> {
    let output = cli
        .output(&["find-generic-password", "-s", &r.service, "-a", &r.key])
        .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
    if output.success {
        return Ok(true);
    }
    if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
        return Ok(false);
    }
    Err(security_cli_backend_error("find-generic-password", output))
}

#[cfg(target_os = "macos")]
fn mac_delete_via_security_cli(r: &SecretRef) -> Result<(), SecretError> {
    mac_delete_via_security_cli_with(r, &SystemSecurityCli)
}

/// Primary macOS delete. Treats "no such item" as success to preserve
/// the public idempotent delete contract.
#[cfg(target_os = "macos")]
fn mac_delete_via_security_cli_with(
    r: &SecretRef,
    cli: &impl SecurityCli,
) -> Result<(), SecretError> {
    let output = cli
        .output(&["delete-generic-password", "-s", &r.service, "-a", &r.key])
        .map_err(|e| security_cli_spawn_error("delete-generic-password", e))?;
    if output.success || output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
        return Ok(());
    }
    Err(security_cli_backend_error(
        "delete-generic-password",
        output,
    ))
}

#[cfg(target_os = "macos")]
fn security_cli_not_found_or_backend<T>(
    command: &str,
    r: &SecretRef,
    output: SecurityCliOutput,
) -> Result<T, SecretError> {
    if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
        return Err(SecretError::NotFound {
            service: r.service.clone(),
            key: r.key.clone(),
        });
    }
    Err(security_cli_backend_error(command, output))
}

#[cfg(target_os = "macos")]
fn security_cli_spawn_error(command: &str, e: std::io::Error) -> SecretError {
    SecretError::Backend(format!("/usr/bin/security {command} spawn: {e}"))
}

#[cfg(target_os = "macos")]
fn security_cli_backend_error(command: &str, output: SecurityCliOutput) -> SecretError {
    let stderr = String::from_utf8_lossy(&output.stderr);
    SecretError::Backend(format!(
        "/usr/bin/security {command} failed: code={} {}",
        output.code.unwrap_or(-1),
        stderr.trim()
    ))
}

/// Map keyring crate errors into our typed error set.
fn classify(e: keyring::Error, op: &str) -> SecretError {
    use keyring::Error as K;
    match e {
        K::NoEntry => SecretError::NotFound {
            service: String::new(),
            key: String::new(),
        },
        K::PlatformFailure(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
        K::NoStorageAccess(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
        K::BadEncoding(_) => SecretError::Backend(format!("{}: value encoding", op)),
        other => SecretError::Backend(format!("{}: {}", op, other)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::{Deserialize, Serialize};

    /// Process-wide serialization lock for every test that touches the secret
    /// store. `CAR_SECRETS_FILE_DIR` is a process-global env var: the file-
    /// backend test sets it, and every other test reads it (via
    /// `file_backend_dir()` inside `is_available`/`put`/`get`). Without this
    /// lock the file-backend test could flip the global redirect while a
    /// keychain test is mid-flight, routing it to a temp dir that then gets
    /// removed — a real, observed flake. Every test below acquires this guard
    /// first, so the store backend is stable for the duration of each test.
    /// `unwrap_or_else(into_inner)` keeps the suite running if one test panics
    /// while holding it.
    static STORE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    fn lock_store() -> std::sync::MutexGuard<'static, ()> {
        STORE_LOCK.lock().unwrap_or_else(|e| e.into_inner())
    }

    // Tests use a unique service name per run to avoid colliding with any
    // real credentials a developer has in their keychain. On headless Linux
    // CI without a Secret Service daemon, these will return Unavailable; we
    // skip in that case rather than fake success.
    fn test_service() -> String {
        format!(
            "car-secrets-tests-{}-{}",
            std::process::id(),
            // Nanos since startup — good enough to isolate tests running
            // in parallel inside one process.
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        )
    }

    fn skip_if_unavailable() -> bool {
        !SecretStore::new().is_available()
    }

    /// S2 — the opt-in file backend is honored in a debug/test build, drives a
    /// real `put`/`get`/`delete` round-trip through the plaintext file path, and
    /// `availability` still reports healthy (it's just the local filesystem).
    ///
    /// `cargo test` builds with `debug_assertions` on, so `file_backend_dir()`
    /// honors `CAR_SECRETS_FILE_DIR` here. A RELEASE binary returns `None` from
    /// `file_backend_dir()` regardless — production can never reach this path.
    ///
    /// The env var is process-global; this test sets it, runs, then removes it.
    /// It must be the only test in this module that mutates the env var. (The
    /// other tests use the keychain with unique service names and do not read
    /// this var.)
    /// A `MakeWriter` that appends every emitted log line into a shared buffer
    /// so the test can assert the one-time file-backend warning actually fired.
    #[derive(Clone)]
    struct BufWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);

    impl std::io::Write for BufWriter {
        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
            self.0.lock().unwrap().extend_from_slice(buf);
            Ok(buf.len())
        }
        fn flush(&mut self) -> std::io::Result<()> {
            Ok(())
        }
    }

    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for BufWriter {
        type Writer = BufWriter;
        fn make_writer(&'a self) -> Self::Writer {
            self.clone()
        }
    }

    #[test]
    fn file_backend_roundtrip_and_warn_under_debug() {
        // Hold the store lock for the WHOLE test: while the global redirect env
        // var is set, no parallel keychain test may run.
        let _guard = lock_store();
        // Sanity: this whole seam only exists in debug builds.
        assert!(
            cfg!(debug_assertions),
            "the crate test suite runs in debug; the file backend depends on it"
        );

        let dir = std::env::temp_dir().join(format!(
            "car-secrets-filebackend-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        ));
        std::fs::create_dir_all(&dir).unwrap();
        std::env::set_var("CAR_SECRETS_FILE_DIR", &dir);

        // Capture logs so we can assert the one-time warning fires the first
        // time the redirect engages in this process.
        let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
        let subscriber = tracing_subscriber::fmt()
            .with_writer(BufWriter(buf.clone()))
            .with_max_level(tracing::Level::WARN)
            .finish();
        tracing::subscriber::with_default(subscriber, || {
            // The redirect is honored (Some) — and fires the one-time warn the
            // first time it engages in this process.
            assert_eq!(
                file_backend_dir().as_deref(),
                Some(dir.as_path()),
                "CAR_SECRETS_FILE_DIR must be honored under debug_assertions"
            );
        });
        let logged = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
        assert!(
            logged.contains("PLAINTEXT ON DISK"),
            "the file backend must emit the one-time PLAINTEXT warning, got logs: {logged:?}"
        );

        let store = SecretStore::new();
        // availability_check still reports healthy on the file backend.
        let check = store.availability();
        assert!(check.available, "file backend must report available");
        assert!(check.reason.is_none());

        // Real put/get/delete round-trip through the plaintext file path.
        let r = SecretRef::new("svc", "key");
        store.put(&r, "xoxb-plaintext-value").unwrap();
        assert_eq!(store.get(&r).unwrap(), "xoxb-plaintext-value");
        // The value really is plaintext on disk (the documented trade-off).
        let on_disk = std::fs::read_to_string(file_backend_path(&dir, &r)).unwrap();
        assert_eq!(on_disk, "xoxb-plaintext-value");
        store.delete(&r).unwrap();
        match store.get(&r) {
            Err(SecretError::NotFound { .. }) => {}
            other => panic!("expected NotFound after delete, got {other:?}"),
        }

        // Restore process state so no parallel/later test inherits the redirect.
        std::env::remove_var("CAR_SECRETS_FILE_DIR");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[cfg(target_os = "macos")]
    struct FakeSecurityCli {
        outputs: std::cell::RefCell<std::collections::VecDeque<std::io::Result<SecurityCliOutput>>>,
        calls: std::cell::RefCell<Vec<Vec<String>>>,
    }

    #[cfg(target_os = "macos")]
    impl FakeSecurityCli {
        fn new(outputs: Vec<std::io::Result<SecurityCliOutput>>) -> Self {
            Self {
                outputs: std::cell::RefCell::new(outputs.into()),
                calls: std::cell::RefCell::new(Vec::new()),
            }
        }

        fn calls(&self) -> Vec<Vec<String>> {
            self.calls.borrow().clone()
        }
    }

    #[cfg(target_os = "macos")]
    impl SecurityCli for FakeSecurityCli {
        fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
            self.calls
                .borrow_mut()
                .push(args.iter().map(|arg| (*arg).to_string()).collect());
            self.outputs
                .borrow_mut()
                .pop_front()
                .expect("missing fake security output")
        }
    }

    #[cfg(target_os = "macos")]
    fn security_output(
        code: i32,
        stdout: impl Into<Vec<u8>>,
        stderr: impl Into<Vec<u8>>,
    ) -> std::io::Result<SecurityCliOutput> {
        Ok(SecurityCliOutput {
            success: code == 0,
            code: Some(code),
            stdout: stdout.into(),
            stderr: stderr.into(),
        })
    }

    #[cfg(target_os = "macos")]
    fn args(values: &[&str]) -> Vec<String> {
        values.iter().map(|value| (*value).to_string()).collect()
    }

    #[cfg(target_os = "macos")]
    fn assert_backend_contains(err: SecretError, expected: &str) {
        match err {
            SecretError::Backend(message) => assert!(
                message.contains(expected),
                "expected backend error to contain {expected:?}, got {message:?}"
            ),
            other => panic!("expected Backend, got {:?}", other),
        }
    }

    #[test]
    fn roundtrip_string() {
        let _guard = lock_store();
        if skip_if_unavailable() {
            eprintln!("skipping: no secret store backend available");
            return;
        }
        let store = SecretStore::new();
        let svc = test_service();
        let r = SecretRef::new(&svc, "roundtrip");
        store.put(&r, "hello world").unwrap();
        assert_eq!(store.get(&r).unwrap(), "hello world");
        assert!(store.status(&r).unwrap().exists);
        store.delete(&r).unwrap();
        assert!(!store.status(&r).unwrap().exists);
    }

    #[test]
    fn roundtrip_string_with_trailing_newline() {
        let _guard = lock_store();
        if skip_if_unavailable() {
            eprintln!("skipping: no secret store backend available");
            return;
        }
        let store = SecretStore::new();
        let svc = test_service();
        let r = SecretRef::new(&svc, "roundtrip-newline");
        let value = "abc\n";
        store.put(&r, value).unwrap();
        assert_eq!(store.get(&r).unwrap(), value);
        store.delete(&r).unwrap();
    }

    #[test]
    fn get_missing_returns_not_found() {
        let _guard = lock_store();
        if skip_if_unavailable() {
            return;
        }
        let store = SecretStore::new();
        let r = SecretRef::new(test_service(), "never_written");
        match store.get(&r) {
            Err(SecretError::NotFound { .. }) => (),
            other => panic!("expected NotFound, got {:?}", other),
        }
    }

    #[test]
    fn delete_missing_is_idempotent() {
        let _guard = lock_store();
        if skip_if_unavailable() {
            return;
        }
        let store = SecretStore::new();
        let r = SecretRef::new(test_service(), "missing");
        // Two deletes in a row should both succeed.
        store.delete(&r).unwrap();
        store.delete(&r).unwrap();
    }

    #[test]
    fn json_roundtrip() {
        let _guard = lock_store();
        if skip_if_unavailable() {
            return;
        }
        #[derive(Serialize, Deserialize, PartialEq, Debug)]
        struct Session {
            cookies: Vec<String>,
            expires_at: i64,
        }
        let store = SecretStore::new();
        let svc = test_service();
        let r = SecretRef::new(&svc, "session");
        let s = Session {
            cookies: vec!["a=1".into(), "b=2".into()],
            expires_at: 1_700_000_000,
        };
        store.put_json(&r, &s).unwrap();
        let back: Session = store.get_json(&r).unwrap();
        assert_eq!(back, s);
        store.delete(&r).unwrap();
    }

    #[test]
    fn status_no_leak() {
        let _guard = lock_store();
        if skip_if_unavailable() {
            return;
        }
        let store = SecretStore::new();
        let r = SecretRef::new(test_service(), "status");
        store.put(&r, "secret-payload").unwrap();
        let st = store.status(&r).unwrap();
        // Status intentionally does not carry the value.
        let encoded = serde_json::to_string(&st).unwrap();
        assert!(!encoded.contains("secret-payload"));
        store.delete(&r).unwrap();
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn mac_get_uses_security_cli_and_maps_success() {
        let cli = FakeSecurityCli::new(vec![security_output(
            0,
            b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
            b"password: \"secret\"\n",
        )]);
        let r = SecretRef::new("svc", "key");

        assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "secret");
        assert_eq!(
            cli.calls(),
            vec![args(&[
                "find-generic-password",
                "-s",
                "svc",
                "-a",
                "key",
                "-g"
            ])]
        );
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn mac_get_decodes_hex_password_output_with_trailing_newline() {
        let cli = FakeSecurityCli::new(vec![security_output(
            0,
            b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
            b"password: 0x6162630A  \"abc\\012\"\n",
        )]);
        let r = SecretRef::new("svc", "key");

        assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "abc\n");
        assert_eq!(
            cli.calls(),
            vec![args(&[
                "find-generic-password",
                "-s",
                "svc",
                "-a",
                "key",
                "-g"
            ])]
        );
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn mac_get_maps_not_found_and_backend_errors_without_fallback() {
        let r = SecretRef::new("svc", "missing");
        let cli = FakeSecurityCli::new(vec![security_output(
            SECURITY_ERR_SEC_ITEM_NOT_FOUND,
            b"",
            b"The specified item could not be found in the keychain.\n",
        )]);

        match mac_get_via_security_cli_with(&r, &cli) {
            Err(SecretError::NotFound { service, key }) => {
                assert_eq!(service, "svc");
                assert_eq!(key, "missing");
            }
            other => panic!("expected NotFound, got {:?}", other),
        }
        assert_eq!(cli.calls().len(), 1);

        let cli = FakeSecurityCli::new(vec![security_output(
            51,
            b"",
            b"User interaction is not allowed.\n",
        )]);
        let err = mac_get_via_security_cli_with(&r, &cli).unwrap_err();
        assert_backend_contains(err, "code=51 User interaction is not allowed.");
        assert_eq!(cli.calls().len(), 1);
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn mac_status_uses_security_cli_and_maps_results() {
        let r = SecretRef::new("svc", "key");
        let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);

        let status = mac_status_via_security_cli_with(&r, &cli).unwrap();
        assert!(status.exists);
        assert_eq!(
            cli.calls(),
            vec![args(&["find-generic-password", "-s", "svc", "-a", "key"])]
        );

        let cli = FakeSecurityCli::new(vec![security_output(
            SECURITY_ERR_SEC_ITEM_NOT_FOUND,
            b"",
            b"The specified item could not be found in the keychain.\n",
        )]);
        assert!(!mac_status_via_security_cli_with(&r, &cli).unwrap().exists);

        let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
        let err = mac_status_via_security_cli_with(&r, &cli).unwrap_err();
        assert_backend_contains(err, "code=128 auth denied");
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn mac_put_pre_deletes_then_adds_so_acl_is_fresh() {
        // Regression for the "3 prompts on car-server startup" bug. Before
        // this fix `mac_put_via_security_cli` issued only
        // `add-generic-password -U -A`, which updates the value but
        // preserves any pre-existing ACL — so items first written by an
        // older binary stayed CDHash-bound forever and `find-generic-password -g`
        // prompted on every read. The fix: best-effort delete first, then add.
        let cli = FakeSecurityCli::new(vec![
            // Pre-delete returns NotFound — that's fine, ignored.
            security_output(
                SECURITY_ERR_SEC_ITEM_NOT_FOUND,
                b"",
                b"The specified item could not be found in the keychain.\n",
            ),
            // Add succeeds.
            security_output(0, b"", b""),
        ]);

        mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap();

        assert_eq!(
            cli.calls(),
            vec![
                args(&["delete-generic-password", "-s", "svc", "-a", "key"]),
                args(&[
                    "add-generic-password",
                    "-U",
                    "-A",
                    "-s",
                    "svc",
                    "-a",
                    "key",
                    "-w",
                    "secret",
                ]),
            ]
        );
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn mac_put_ignores_pre_delete_failure_and_still_adds() {
        // If the pre-delete shells back a non-NotFound non-zero (e.g.
        // transient backend error), we still attempt the add — `-U` is the
        // safety net that lets us update the value even if the old item is
        // somehow still around.
        let cli = FakeSecurityCli::new(vec![
            security_output(128, b"", b"some weird backend error\n"),
            security_output(0, b"", b""),
        ]);

        mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap();

        assert_eq!(cli.calls().len(), 2);
        assert_eq!(
            cli.calls()[1],
            args(&[
                "add-generic-password",
                "-U",
                "-A",
                "-s",
                "svc",
                "-a",
                "key",
                "-w",
                "secret",
            ])
        );
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn mac_put_surfaces_add_failure_as_backend_error() {
        let cli = FakeSecurityCli::new(vec![
            security_output(0, b"", b""),
            security_output(51, b"", b"User interaction is not allowed.\n"),
        ]);

        let err = mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap_err();
        assert_backend_contains(err, "code=51 User interaction is not allowed.");
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn mac_delete_uses_security_cli_and_maps_results() {
        let r = SecretRef::new("svc", "key");
        let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);

        mac_delete_via_security_cli_with(&r, &cli).unwrap();
        assert_eq!(
            cli.calls(),
            vec![args(&["delete-generic-password", "-s", "svc", "-a", "key"])]
        );

        let cli = FakeSecurityCli::new(vec![security_output(
            SECURITY_ERR_SEC_ITEM_NOT_FOUND,
            b"",
            b"The specified item could not be found in the keychain.\n",
        )]);
        mac_delete_via_security_cli_with(&r, &cli).unwrap();

        let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
        let err = mac_delete_via_security_cli_with(&r, &cli).unwrap_err();
        assert_backend_contains(err, "code=128 auth denied");
    }
}
