//! Credential reading API for plugins.
//!
//! Plugins declaring the `credentials:read` capability can read credentials
//! from the system credential store. On macOS this reads from Keychain;
//! on Linux/Windows it reads from a JSON file in the user's home directory.
//!
//! Results are cached in-memory per service name (5 min TTL) to avoid
//! repeated OS keychain prompts within the same process.
//!
//! Returns the raw credential JSON string, or null if not found.

#[cfg(not(target_os = "macos"))]
use std::path::PathBuf;

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

const CACHE_TTL: Duration = Duration::from_secs(300);

struct CachedEntry {
    value: Option<String>,
    fetched_at: Instant,
}

static CACHE: Mutex<Option<HashMap<String, CachedEntry>>> = Mutex::new(None);

/// Validate service name format: alphanumeric, dots, hyphens, underscores, spaces.
/// Prevents shell injection when service_name is passed to macOS `security` CLI.
fn is_valid_service_name(name: &str) -> bool {
    name.chars()
        .all(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_' | ' '))
}

/// Read a credential by service name.
///
/// - macOS: reads from Keychain via `security find-generic-password`
/// - Linux/Windows: reads from `~/.claude/.credentials.json`
///
/// Returns `Ok(Some(json_string))` if found, `Ok(None)` if not found,
/// `Err` on I/O or permission errors.
#[cfg(feature = "desktop")]
#[tauri::command]
pub async fn plugin_read_credential(
    service_name: String,
    plugin_id: String,
    state: tauri::State<'_, std::sync::Arc<crate::AppState>>,
) -> Result<Option<String>, String> {
    crate::plugins::check_plugin_capability(&state, &plugin_id, "credentials:read")?;
    plugin_read_credential_inner(&service_name)
}

fn plugin_read_credential_inner(service_name: &str) -> Result<Option<String>, String> {
    if service_name.is_empty() {
        return Err("Service name is empty".into());
    }
    if !is_valid_service_name(service_name) {
        return Err(
            "Service name contains invalid characters (allow: a-z A-Z 0-9 . - _ space)".into(),
        );
    }

    cached_read(service_name)
}

pub(crate) fn cached_read(service_name: &str) -> Result<Option<String>, String> {
    let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
    let map = guard.get_or_insert_with(HashMap::new);

    if let Some(entry) = map.get(service_name)
        && entry.fetched_at.elapsed() < CACHE_TTL
    {
        return Ok(entry.value.clone());
    }
    drop(guard);

    let value = read_credential_uncached(service_name)?;

    let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
    let map = guard.get_or_insert_with(HashMap::new);
    map.insert(
        service_name.to_string(),
        CachedEntry {
            value: value.clone(),
            fetched_at: Instant::now(),
        },
    );
    Ok(value)
}

fn read_credential_uncached(service_name: &str) -> Result<Option<String>, String> {
    #[cfg(target_os = "macos")]
    {
        read_from_keychain(service_name)
    }
    #[cfg(not(target_os = "macos"))]
    {
        read_from_json_file(service_name)
    }
}

/// macOS: read from Keychain via `security find-generic-password -s <service> -w`.
/// This shells out to avoid adding a native Keychain crate dependency.
#[cfg(target_os = "macos")]
pub(crate) fn read_from_keychain(service_name: &str) -> Result<Option<String>, String> {
    let output = std::process::Command::new("security")
        .args(["find-generic-password", "-s", service_name, "-w"])
        .output()
        .map_err(|e| format!("Failed to run security command: {e}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        // "could not be found" means the credential doesn't exist — not an error
        if stderr.contains("could not be found") || stderr.contains("SecKeychainSearchCopyNext") {
            return Ok(None);
        }
        return Err(format!("Keychain read failed: {}", stderr.trim()));
    }

    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if raw.is_empty() {
        return Ok(None);
    }

    Ok(Some(raw))
}

/// Linux/Windows: read from `~/.claude/.credentials.json`.
#[cfg(not(target_os = "macos"))]
pub(crate) fn read_from_json_file(service_name: &str) -> Result<Option<String>, String> {
    let path = credentials_json_path()?;
    match std::fs::read_to_string(&path) {
        Ok(content) => {
            let parsed: serde_json::Value = serde_json::from_str(&content)
                .map_err(|e| format!("Failed to parse credentials file: {e}"))?;
            match parsed.get(service_name) {
                Some(val) => Ok(Some(val.to_string())),
                None => Ok(None),
            }
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(format!("Failed to read credentials file: {e}")),
    }
}

/// Path to the credentials JSON file on non-macOS platforms.
#[cfg(not(target_os = "macos"))]
fn credentials_json_path() -> Result<PathBuf, String> {
    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
    Ok(home.join(".claude").join(".credentials.json"))
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    #[test]
    fn valid_service_names() {
        assert!(is_valid_service_name("Claude Code-credentials"));
        assert!(is_valid_service_name("my_app.secret"));
        assert!(is_valid_service_name("simple"));
    }

    #[test]
    fn invalid_service_names() {
        assert!(!is_valid_service_name("$(whoami)"));
        assert!(!is_valid_service_name("test;rm -rf /"));
        assert!(!is_valid_service_name("test\ninjection"));
        assert!(!is_valid_service_name("path/../traversal"));
        assert!(!is_valid_service_name("test`id`"));
    }

    #[test]
    fn empty_service_name_is_rejected() {
        let result = plugin_read_credential_inner("");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("empty"));
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn keychain_returns_none_for_nonexistent_service() {
        let result = read_from_keychain("nonexistent-service-that-definitely-does-not-exist-12345");
        assert!(result.is_ok());
        assert!(result.unwrap().is_none());
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn keychain_returns_some_for_claude_credentials() {
        // This test verifies the integration works on this machine
        let result = read_from_keychain("Claude Code-credentials");
        assert!(result.is_ok());
        // May or may not exist on the test machine, but should not error
        if let Some(json) = result.unwrap() {
            // If it exists, it should be valid JSON
            assert!(json.starts_with('{'));
            let parsed: Result<serde_json::Value, _> = serde_json::from_str(&json);
            assert!(parsed.is_ok());
        }
    }
}
