//! Cross-platform secret store for Common Agent Runtime.
//!
//! Unifies OS-native secure storage across the three platforms CAR targets:
//!
//! - **macOS** — Security.framework / 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 the read fallback chain
    /// uses, 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, falls back to a full default-search-list lookup if the
    /// keyring crate's User-domain-scoped read returns NoEntry. This
    /// covers the case where the item was written to a keychain that
    /// isn't `SecKeychain::default_for_domain(User)` — issue #142.
    pub fn get(&self, r: &SecretRef) -> Result<String, SecretError> {
        let entry = self.entry(r)?;
        match entry.get_password() {
            Ok(v) => Ok(v),
            Err(keyring::Error::NoEntry) => mac_fallback_get(r),
            Err(other) => Err(classify(other, "get_password")),
        }
    }

    /// 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, falls back to `/usr/bin/security delete-generic-password`
    /// when the keyring path fails for a non-NoEntry reason. Items
    /// written by older versions of `car-secrets` may carry a
    /// restrictive *delete* ACL too; the system CLI sidesteps it the
    /// same way it sidesteps the restrictive read ACL. Issue #142
    /// migration path.
    pub fn delete(&self, r: &SecretRef) -> Result<(), SecretError> {
        let entry = self.entry(r)?;
        match entry.delete_credential() {
            Ok(_) => Ok(()),
            Err(keyring::Error::NoEntry) => mac_delete_fallback(r),
            Err(other) => match mac_delete_fallback(r) {
                Ok(()) => Ok(()),
                Err(_) => Err(classify(other, "delete_credential")),
            },
        }
    }

    /// Existence check without returning the value. Safe to log.
    ///
    /// On macOS, falls back to a full default-search-list lookup if the
    /// keyring crate's User-domain-scoped read returns NoEntry — same
    /// motivation as `get`. Issue #142.
    pub fn status(&self, r: &SecretRef) -> Result<SecretStatus, SecretError> {
        let entry = self.entry(r)?;
        let exists = match entry.get_password() {
            Ok(_) => true,
            Err(keyring::Error::NoEntry) => mac_fallback_exists(r)?,
            Err(other) => return Err(classify(other, "status")),
        };
        Ok(SecretStatus {
            service: r.service.clone(),
            key: r.key.clone(),
            exists,
        })
    }

    /// 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.
        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 write path.
//
// macOS: shell out to `/usr/bin/security add-generic-password -U -A`. The
// `-A` flag installs a permissive ACL — any application the user runs can
// read the item without re-prompting. This solves the "rebuild-revokes-ACL"
// failure mode that bit cargo-built debug binaries on issue #142.
//
// Other platforms: pass through to keyring (its native backends behave
// correctly).
// ---------------------------------------------------------------------------

#[cfg(target_os = "macos")]
fn platform_put(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
    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> {
    let entry = store.entry(r)?;
    entry
        .set_password(value)
        .map_err(|e| classify(e, "set_password"))
}

/// macOS-only: try `/usr/bin/security delete-generic-password`. Treats
/// "no such item" as success (idempotent delete contract). Used both
/// when keyring reports NoEntry (so we still clean up any duplicate
/// the keyring path can't see) and as a fallback when keyring fails
/// for ACL reasons.
#[cfg(target_os = "macos")]
fn mac_delete_fallback(r: &SecretRef) -> Result<(), SecretError> {
    use std::process::{Command, Stdio};
    let output = Command::new("/usr/bin/security")
        .arg("delete-generic-password")
        .arg("-s")
        .arg(&r.service)
        .arg("-a")
        .arg(&r.key)
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .output()
        .map_err(|e| SecretError::Backend(format!("/usr/bin/security spawn: {}", e)))?;
    if output.status.success() {
        return Ok(());
    }
    // Exit 44 = "The specified item could not be found in the keychain."
    // (`security` documents this as `errSecItemNotFound`-equivalent.)
    // Treat as success to preserve idempotency.
    if output.status.code() == Some(44) {
        return Ok(());
    }
    let stderr = String::from_utf8_lossy(&output.stderr);
    Err(SecretError::Backend(format!(
        "/usr/bin/security delete-generic-password failed: code={} {}",
        output.status.code().unwrap_or(-1),
        stderr.trim()
    )))
}

#[cfg(not(target_os = "macos"))]
fn mac_delete_fallback(_r: &SecretRef) -> Result<(), SecretError> {
    Ok(())
}

/// 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.
#[cfg(target_os = "macos")]
fn mac_put_via_security_cli(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
    use std::process::{Command, Stdio};
    let output = Command::new("/usr/bin/security")
        .arg("add-generic-password")
        .arg("-U") // update if exists
        .arg("-A") // permissive ACL — any app can read
        .arg("-s")
        .arg(service)
        .arg("-a")
        .arg(account)
        .arg("-w")
        .arg(value)
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .output()
        .map_err(|e| {
            SecretError::Backend(format!("/usr/bin/security spawn: {}", e))
        })?;
    if output.status.success() {
        return Ok(());
    }
    let stderr = String::from_utf8_lossy(&output.stderr);
    Err(SecretError::Backend(format!(
        "/usr/bin/security add-generic-password failed: code={} {}",
        output.status.code().unwrap_or(-1),
        stderr.trim()
    )))
}

/// macOS fallback chain for keychain reads after the keyring crate's
/// User-domain-scoped lookup misses. Three layers, each tried in order:
///
/// 1. **`SecItemCopyMatching`** via `security_framework::passwords::get_generic_password`
///    — the modern unified Keychain Services API. Doesn't go through
///    `SecKeychain*` legacy calls, so it isn't affected by per-domain
///    `SecPreferencesDomain` quirks. Closest analogue to what new code
///    on macOS should use.
/// 2. **`SecKeychainFindGenericPassword(NULL, ...)`** via
///    `security_framework::os::macos::passwords::find_generic_password(None, ...)`
///    — legacy API with the unscoped (full default search list) form.
/// 3. **`/usr/bin/security find-generic-password`** — shell-out to the
///    OS-shipped binary. This is the path the user confirmed works on
///    machines where (1) and (2) miss. It's a code-signed Apple binary
///    with full keychain entitlements; the in-process calls run with
///    whatever entitlements the host binary has (often none, for
///    cargo-built debug binaries), which is the most likely reason the
///    in-process searches fail while the CLI succeeds.
///
/// Each layer's failure is logged at `debug` (or `warn` for the value
/// path) so #142-style mismatches stay observable. Returns `NotFound`
/// only when all three layers miss.
#[cfg(target_os = "macos")]
fn mac_fallback_get(r: &SecretRef) -> Result<String, SecretError> {
    if let Some(value) = mac_try_secitem_get(r) {
        return Ok(value);
    }
    if let Some(value) = mac_try_legacy_get(r) {
        return Ok(value);
    }
    if let Some(value) = mac_try_cli_get(r) {
        return Ok(value);
    }
    Err(SecretError::NotFound {
        service: r.service.clone(),
        key: r.key.clone(),
    })
}

#[cfg(target_os = "macos")]
fn mac_try_secitem_get(r: &SecretRef) -> Option<String> {
    use security_framework::passwords::get_generic_password;
    match get_generic_password(&r.service, &r.key) {
        Ok(bytes) => match std::str::from_utf8(&bytes) {
            Ok(s) => Some(s.to_string()),
            Err(e) => {
                tracing::warn!(
                    target: "car_secrets",
                    error = %e,
                    "SecItemCopyMatching returned non-utf8 bytes; falling back"
                );
                None
            }
        },
        Err(e) => {
            tracing::debug!(
                target: "car_secrets",
                code = e.code(),
                "SecItemCopyMatching miss; trying legacy SecKeychainFindGenericPassword(NULL)"
            );
            None
        }
    }
}

#[cfg(target_os = "macos")]
fn mac_try_legacy_get(r: &SecretRef) -> Option<String> {
    use security_framework::os::macos::passwords::find_generic_password;
    match find_generic_password(None, &r.service, &r.key) {
        Ok((pw, _item)) => {
            let bytes: &[u8] = &pw;
            match std::str::from_utf8(bytes) {
                Ok(s) => Some(s.to_string()),
                Err(e) => {
                    tracing::warn!(
                        target: "car_secrets",
                        error = %e,
                        "legacy keychain returned non-utf8 bytes; falling back"
                    );
                    None
                }
            }
        }
        Err(e) => {
            tracing::debug!(
                target: "car_secrets",
                code = e.code(),
                "legacy keychain miss; trying /usr/bin/security shell-out"
            );
            None
        }
    }
}

/// Last-resort: shell out to `/usr/bin/security find-generic-password -s SVC -a KEY -w`.
/// `-w` prints only the password to stdout. 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_try_cli_get(r: &SecretRef) -> Option<String> {
    use std::process::Command;
    let output = match Command::new("/usr/bin/security")
        .arg("find-generic-password")
        .arg("-s")
        .arg(&r.service)
        .arg("-a")
        .arg(&r.key)
        .arg("-w")
        .output()
    {
        Ok(out) => out,
        Err(e) => {
            tracing::warn!(
                target: "car_secrets",
                error = %e,
                "/usr/bin/security spawn failed"
            );
            return None;
        }
    };
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        tracing::debug!(
            target: "car_secrets",
            code = output.status.code().unwrap_or(-1),
            stderr = %stderr.trim(),
            "/usr/bin/security exit non-zero on value path"
        );
        return None;
    }
    // -w writes the password followed by a newline. Strip exactly one
    // trailing newline to round-trip values that themselves end in a
    // newline (security adds one regardless).
    let mut s = String::from_utf8(output.stdout)
        .map_err(|e| {
            tracing::warn!(
                target: "car_secrets",
                error = %e,
                "/usr/bin/security stdout was not valid utf-8"
            );
        })
        .ok()?;
    if s.ends_with('\n') {
        s.pop();
    }
    Some(s)
}

#[cfg(not(target_os = "macos"))]
fn mac_fallback_get(r: &SecretRef) -> Result<String, SecretError> {
    Err(SecretError::NotFound {
        service: r.service.clone(),
        key: r.key.clone(),
    })
}

/// Existence variant of [`mac_fallback_get`]. Same three-layer chain,
/// but only checks for presence — no password value travels through any
/// process boundary on the existence path.
#[cfg(target_os = "macos")]
fn mac_fallback_exists(r: &SecretRef) -> Result<bool, SecretError> {
    if mac_try_secitem_exists(r) {
        return Ok(true);
    }
    if mac_try_legacy_exists(r) {
        return Ok(true);
    }
    if mac_try_cli_exists(r) {
        return Ok(true);
    }
    Ok(false)
}

#[cfg(target_os = "macos")]
fn mac_try_secitem_exists(r: &SecretRef) -> bool {
    use security_framework::passwords::get_generic_password;
    match get_generic_password(&r.service, &r.key) {
        Ok(_) => true,
        Err(e) => {
            tracing::debug!(
                target: "car_secrets",
                code = e.code(),
                "SecItemCopyMatching exists-probe miss"
            );
            false
        }
    }
}

#[cfg(target_os = "macos")]
fn mac_try_legacy_exists(r: &SecretRef) -> bool {
    use security_framework::os::macos::passwords::find_generic_password;
    match find_generic_password(None, &r.service, &r.key) {
        Ok(_) => true,
        Err(e) => {
            tracing::debug!(
                target: "car_secrets",
                code = e.code(),
                "legacy SecKeychainFindGenericPassword(NULL) exists-probe miss"
            );
            false
        }
    }
}

/// Existence-only shell-out: `security find-generic-password -s SVC -a KEY`
/// (no `-w`). Exit 0 means found, non-zero means absent. We discard
/// stdout (no `-w` means it's empty anyway) but capture stderr so a
/// non-zero exit can be logged with the actual reason — without that,
/// keychain ACL/TCC failures are indistinguishable from "item absent".
#[cfg(target_os = "macos")]
fn mac_try_cli_exists(r: &SecretRef) -> bool {
    use std::process::{Command, Stdio};
    let output = Command::new("/usr/bin/security")
        .arg("find-generic-password")
        .arg("-s")
        .arg(&r.service)
        .arg("-a")
        .arg(&r.key)
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .output();
    match output {
        Ok(out) if out.status.success() => true,
        Ok(out) => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            tracing::debug!(
                target: "car_secrets",
                code = out.status.code().unwrap_or(-1),
                stderr = %stderr.trim(),
                "/usr/bin/security exists-probe exit non-zero"
            );
            false
        }
        Err(e) => {
            tracing::warn!(
                target: "car_secrets",
                error = %e,
                "/usr/bin/security exists-probe spawn failed"
            );
            false
        }
    }
}

#[cfg(not(target_os = "macos"))]
fn mac_fallback_exists(_r: &SecretRef) -> Result<bool, SecretError> {
    Ok(false)
}

/// 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};

    // 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()
    }

    #[test]
    fn roundtrip_string() {
        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 get_missing_returns_not_found() {
        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() {
        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() {
        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() {
        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();
    }

    /// Issue #142: the `mac_fallback_get` helper for missing keys
    /// returns `NotFound`. Exercises the new code path in the
    /// not-found branch since simulating a divergent default-keychain
    /// configuration in a unit test isn't reliable.
    #[cfg(target_os = "macos")]
    #[test]
    fn mac_fallback_get_returns_not_found_for_missing_key() {
        let r = SecretRef::new(
            "car-secrets-tests-issue142-missing",
            "definitely-never-written",
        );
        match mac_fallback_get(&r) {
            Err(SecretError::NotFound { service, key }) => {
                assert_eq!(service, "car-secrets-tests-issue142-missing");
                assert_eq!(key, "definitely-never-written");
            }
            other => panic!("expected NotFound, got {:?}", other),
        }
        assert!(matches!(mac_fallback_exists(&r), Ok(false)));
    }
}
