//! File-based stale-while-revalidate cache for crypto prices.
//!
//! Each cache is a plain text file in `/tmp/claude-statusline-*`. The cache
//! strategy adapts to the short-lived `--render` process:
//!
//! - **No cache (first run)**: synchronous fetch with 5s timeout, blocks until
//!   data is written so the segment renders immediately.
//! - **Stale cache**: background thread fetches fresh data while stale content
//!   is returned immediately (classic SWR).
//! - **Fresh cache**: returned directly, no fetch.
//!
//! A `mkdir`-based lock prevents concurrent refresh attempts. Stale locks
//! (older than 30s) are automatically cleaned up.
//!
//! Key functions:
//! - `ensure_caches_fresh(config)` -- called from `render::run()` to trigger
//!   refreshes for enabled crypto segment
//! - `read_or_refresh(cache, lock, max_age, fetch_fn)` -- core SWR logic
//! - `fetch_crypto(coins)` -- fetches prices from Binance API

use std::fs;
use std::time::{Duration, SystemTime};

// ─── Cache file paths ────────────────────────────────────────────────────────

const CRYPTO_CACHE: &str = "/tmp/claude-statusline-crypto-cache";
const CRYPTO_LOCK: &str = "/tmp/claude-statusline-crypto-lock";

/// HTTP request timeout for all fetchers.
const FETCH_TIMEOUT: Duration = Duration::from_secs(5);

/// Lock directories older than this are considered stale and removed.
const STALE_LOCK_SECS: u64 = 30;

#[allow(dead_code)]
pub fn crypto_cache_path() -> &'static str {
    CRYPTO_CACHE
}

// ─── Cache core ──────────────────────────────────────────────────────────────

/// Read cached content, refreshing synchronously or in background as needed.
///
/// - **Stale or missing cache**: fetches synchronously (blocks), writes
///   result to cache, returns fresh data. Synchronous fetch is required
///   because `--render` is a short-lived process -- background threads
///   would be killed on exit before completing.
/// - **Fresh cache**: returns cached content directly.
pub fn read_or_refresh<F>(
    cache_path: &str,
    lock_path: &str,
    max_age_secs: u64,
    fetch_fn: F,
) -> Option<String>
where
    F: FnOnce() -> Option<String> + Send + 'static,
{
    let content = fs::read_to_string(cache_path).ok();
    let age = file_age_secs(cache_path);

    if age.is_none_or(|a| a >= max_age_secs) {
        // Clean up stale locks (e.g. from a previous crash)
        if let Some(lock_age) = file_age_secs(lock_path) {
            if lock_age > STALE_LOCK_SECS {
                let _ = fs::remove_dir(lock_path);
            }
        }

        // Synchronous fetch: block until data is available
        if fs::create_dir(lock_path).is_ok() {
            let result = fetch_fn();
            if let Some(data) = &result {
                let _ = fs::write(cache_path, data);
            }
            let _ = fs::remove_dir(lock_path);
            if result.is_some() {
                return result;
            }
        }
    }

    content.filter(|s| !s.is_empty())
}

fn file_age_secs(path: &str) -> Option<u64> {
    let meta = fs::metadata(path).ok()?;
    let modified = meta.modified().ok()?;
    SystemTime::now()
        .duration_since(modified)
        .ok()
        .map(|d| d.as_secs())
}

// ─── HTTP agent ──────────────────────────────────────────────────────────────

/// Build a ureq agent with connection and read timeouts.
fn http_agent() -> ureq::Agent {
    ureq::AgentBuilder::new()
        .timeout_connect(FETCH_TIMEOUT)
        .timeout_read(FETCH_TIMEOUT)
        .build()
}

// ─── Crypto fetcher ──────────────────────────────────────────────────────────

/// Fetch prices for multiple coins from Binance API (one request per coin).
///
/// Returns `Some("price1|price2|...")` if all coins succeed, `None` otherwise.
/// Each request has a 5s timeout to avoid blocking the render pipeline.
pub fn fetch_crypto(coins: &[String]) -> Option<String> {
    let agent = http_agent();
    let prices: Vec<String> = coins
        .iter()
        .map(|coin| {
            let pair = format!("{}USDT", coin);
            let url = format!(
                "https://api.binance.com/api/v3/ticker/price?symbol={}",
                pair
            );
            match agent.get(&url).call() {
                Ok(resp) => resp
                    .into_json::<serde_json::Value>()
                    .ok()
                    .and_then(|v| v["price"].as_str().map(String::from))
                    .unwrap_or_default(),
                Err(e) => {
                    crate::log::error(&format!("crypto fetch {}: {}", coin, e));
                    String::new()
                }
            }
        })
        .collect();

    if prices.iter().all(|p: &String| !p.is_empty()) {
        Some(prices.join("|"))
    } else {
        None
    }
}

// ─── Integration with render pipeline ────────────────────────────────────────

/// Called from the render pipeline to ensure caches are fresh.
///
/// For each enabled segment that relies on a cache file (crypto),
/// this calls `read_or_refresh` which fetches synchronously when the
/// cache is stale or missing.
pub fn ensure_caches_fresh(config: &crate::config::Config) {
    let s = &config.segments;

    if s.crypto.enabled {
        let coins = s.crypto.coins.clone();
        read_or_refresh(
            CRYPTO_CACHE,
            CRYPTO_LOCK,
            s.crypto.refresh_interval,
            move || fetch_crypto(&coins),
        );
    }
}

// ─── Tests ───────────────────────────────────────────────────────────────────

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

    /// Helper: create a temp file with given content and return its path.
    fn temp_file(name: &str, content: &str) -> String {
        let path = format!("/tmp/claude-statusline-cache-test-{}", name);
        let mut f = fs::File::create(&path).unwrap();
        f.write_all(content.as_bytes()).unwrap();
        path
    }

    /// Helper: remove a file if it exists.
    fn cleanup(path: &str) {
        let _ = fs::remove_file(path);
        let _ = fs::remove_dir(path);
    }

    #[test]
    fn test_file_age_secs() {
        let path = temp_file("age", "hello");
        let age = file_age_secs(&path).expect("should get age");
        assert!(
            age <= 1,
            "freshly created file should be 0 or 1 second old, got {}",
            age
        );
        cleanup(&path);
    }

    #[test]
    fn test_read_or_refresh_fresh_cache() {
        let cache = temp_file("fresh", "cached-data");
        let lock = format!("{}-lock", cache);
        cleanup(&lock);

        // max_age very large → cache is fresh, no refresh triggered
        let result = read_or_refresh(&cache, &lock, 9999, || {
            panic!("fetch_fn should not be called for fresh cache");
        });

        assert_eq!(result, Some("cached-data".to_string()));
        cleanup(&cache);
        cleanup(&lock);
    }

    #[test]
    fn test_read_or_refresh_stale_cache() {
        let cache = temp_file("stale", "old-data");
        let lock = format!("{}-lock", cache);
        cleanup(&lock);

        // max_age=0 → cache is always stale, synchronous refresh
        let result = read_or_refresh(&cache, &lock, 0, || Some("new-data".to_string()));

        // Should return fresh data (synchronous fetch)
        assert_eq!(result, Some("new-data".to_string()));

        // Cache file should contain new data
        let updated = fs::read_to_string(&cache).unwrap();
        assert_eq!(updated, "new-data");

        // Lock should be cleaned up
        assert!(fs::metadata(&lock).is_err());

        cleanup(&cache);
        cleanup(&lock);
    }

    #[test]
    fn test_read_or_refresh_no_cache_sync_fetch() {
        let cache = "/tmp/claude-statusline-cache-test-sync";
        let lock = format!("{}-lock", cache);
        cleanup(cache);
        cleanup(&lock);

        // No cache → synchronous fetch, returns data immediately
        let result = read_or_refresh(cache, &lock, 60, || Some("fetched".to_string()));

        // Should return fetched data directly (no background thread)
        assert_eq!(result, Some("fetched".to_string()));

        // Cache file should exist
        let written = fs::read_to_string(cache).unwrap();
        assert_eq!(written, "fetched");

        // Lock should be cleaned up
        assert!(fs::metadata(&lock).is_err());

        cleanup(cache);
        cleanup(&lock);
    }

    #[test]
    fn test_read_or_refresh_empty_cache() {
        let cache = temp_file("empty", "");
        let lock = format!("{}-lock", cache);
        cleanup(&lock);

        // Empty cache treated as missing → sync fetch
        let result = read_or_refresh(&cache, &lock, 0, || Some("refetched".to_string()));

        assert_eq!(result, Some("refetched".to_string()));

        cleanup(&cache);
        cleanup(&lock);
    }

    #[test]
    fn test_crypto_cache_path() {
        assert_eq!(crypto_cache_path(), "/tmp/claude-statusline-crypto-cache");
    }

}
