//! Rate storage, caching, and staleness detection.
//!
//! Provides the core `ExchangeRates` type (a timestamped map of currency-code -> rate)
//! and `ExchangeRateCache` for persisting rates to disk as JSON.

use crate::currency::CurrencyError;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::time::Duration;

/// Fetched exchange rates with metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExchangeRates {
    /// Base currency (always "USD").
    pub base: String,
    /// Map of currency code -> rate (1 USD = rate units of that currency).
    pub rates: HashMap<String, f64>,
    /// When the rates were fetched.
    pub timestamp: DateTime<Utc>,
    /// Which provider supplied the rates.
    pub provider: String,
}

/// Describes the source of rates used for a conversion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RateSource {
    /// Freshly fetched from the provider.
    Live,
    /// Loaded from disk cache (still within staleness threshold).
    Cached,
    /// Loaded from stale cache because the provider was unreachable.
    Offline,
}

/// Metadata about the rates used for a currency conversion.
#[derive(Debug, Clone)]
pub struct RateMetadata {
    /// When the rates were last updated.
    pub updated_at: DateTime<Utc>,
    /// How the rates were obtained.
    pub source: RateSource,
    /// The provider that originally supplied the rates.
    pub provider: String,
}

impl RateMetadata {
    /// Format a human-readable status string.
    ///
    /// - Live/Cached: "rates updated 5 minutes ago"
    /// - Offline: "offline -- rates from 2026-03-16 14:30:00 UTC"
    pub fn display_status(&self) -> String {
        match self.source {
            RateSource::Offline => {
                format!(
                    "offline -- rates from {}",
                    self.updated_at.format("%Y-%m-%d %H:%M:%S UTC")
                )
            }
            RateSource::Live | RateSource::Cached => {
                let elapsed = Utc::now().signed_duration_since(self.updated_at);
                let secs = elapsed.num_seconds().max(0);
                let relative = if secs < 60 {
                    "just now".to_string()
                } else if secs < 3600 {
                    let mins = secs / 60;
                    format!(
                        "{} minute{} ago",
                        mins,
                        if mins == 1 { "" } else { "s" }
                    )
                } else if secs < 86400 {
                    let hours = secs / 3600;
                    format!(
                        "{} hour{} ago",
                        hours,
                        if hours == 1 { "" } else { "s" }
                    )
                } else {
                    let days = secs / 86400;
                    format!(
                        "{} day{} ago",
                        days,
                        if days == 1 { "" } else { "s" }
                    )
                };
                format!("rates updated {}", relative)
            }
        }
    }
}

/// Configuration for the fiat currency provider.
#[derive(Debug, Clone)]
pub struct ProviderConfig {
    /// API key for the provider (if required).
    pub api_key: Option<String>,
    /// How long before cached rates are considered stale.
    pub staleness_threshold: Duration,
    /// Path to the cache file on disk.
    pub cache_path: String,
}

impl Default for ProviderConfig {
    fn default() -> Self {
        Self {
            api_key: None,
            staleness_threshold: Duration::from_secs(3600), // 1 hour
            cache_path: "calcpad_exchange_rates.json".to_string(),
        }
    }
}

/// Disk-based cache for exchange rates.
pub struct ExchangeRateCache {
    /// Path to the JSON cache file.
    path: String,
}

impl ExchangeRateCache {
    pub fn new(path: &str) -> Self {
        Self {
            path: path.to_string(),
        }
    }

    /// Save exchange rates to disk as JSON.
    pub fn save(&self, rates: &ExchangeRates) -> Result<(), CurrencyError> {
        let json = serde_json::to_string_pretty(rates)
            .map_err(|e| CurrencyError::CacheError(format!("Failed to serialize rates: {}", e)))?;

        // Ensure parent directory exists
        if let Some(parent) = Path::new(&self.path).parent() {
            if !parent.as_os_str().is_empty() {
                fs::create_dir_all(parent).map_err(|e| {
                    CurrencyError::CacheError(format!(
                        "Failed to create cache directory: {}",
                        e
                    ))
                })?;
            }
        }

        fs::write(&self.path, json)
            .map_err(|e| CurrencyError::CacheError(format!("Failed to write cache file: {}", e)))?;

        Ok(())
    }

    /// Load exchange rates from disk.
    /// Returns None if the cache file doesn't exist or is empty.
    pub fn load(&self) -> Result<Option<ExchangeRates>, CurrencyError> {
        let path = Path::new(&self.path);
        if !path.exists() {
            return Ok(None);
        }

        let json = fs::read_to_string(path)
            .map_err(|e| CurrencyError::CacheError(format!("Failed to read cache file: {}", e)))?;

        if json.trim().is_empty() {
            return Ok(None);
        }

        let rates: ExchangeRates = serde_json::from_str(&json).map_err(|e| {
            CurrencyError::CacheError(format!("Failed to parse cache file: {}", e))
        })?;

        Ok(Some(rates))
    }

    /// Check whether cached rates are stale based on the given threshold.
    pub fn is_stale(&self, rates: &ExchangeRates, threshold: Duration) -> bool {
        let elapsed = Utc::now()
            .signed_duration_since(rates.timestamp)
            .num_seconds()
            .max(0) as u64;
        elapsed >= threshold.as_secs()
    }

    /// Check if the cache file exists.
    pub fn exists(&self) -> bool {
        Path::new(&self.path).exists()
    }
}

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

    fn sample_rates() -> ExchangeRates {
        let mut rates = HashMap::new();
        rates.insert("EUR".to_string(), 0.85);
        rates.insert("GBP".to_string(), 0.73);
        rates.insert("JPY".to_string(), 110.0);

        ExchangeRates {
            base: "USD".to_string(),
            rates,
            timestamp: Utc::now(),
            provider: "test".to_string(),
        }
    }

    #[test]
    fn test_save_and_load() {
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_str().unwrap();

        let cache = ExchangeRateCache::new(path);
        let rates = sample_rates();

        cache.save(&rates).unwrap();
        let loaded = cache.load().unwrap().unwrap();

        assert_eq!(loaded.base, "USD");
        assert_eq!(loaded.provider, "test");
        assert_eq!(loaded.rates.len(), 3);
        assert!((loaded.rates["EUR"] - 0.85).abs() < f64::EPSILON);
    }

    #[test]
    fn test_load_nonexistent() {
        let cache = ExchangeRateCache::new("/tmp/calcpad_nonexistent_test_cache_12345.json");
        let result = cache.load().unwrap();
        assert!(result.is_none());
    }

    #[test]
    fn test_is_stale_fresh() {
        let rates = sample_rates(); // timestamp = now
        let cache = ExchangeRateCache::new("/tmp/test_stale.json");

        assert!(!cache.is_stale(&rates, Duration::from_secs(3600)));
    }

    #[test]
    fn test_is_stale_old() {
        let mut rates = sample_rates();
        rates.timestamp = Utc::now() - chrono::Duration::hours(2);
        let cache = ExchangeRateCache::new("/tmp/test_stale.json");

        assert!(cache.is_stale(&rates, Duration::from_secs(3600)));
    }

    #[test]
    fn test_exists() {
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_str().unwrap();
        let cache = ExchangeRateCache::new(path);
        assert!(cache.exists());

        let cache2 = ExchangeRateCache::new("/tmp/calcpad_no_such_file_99999.json");
        assert!(!cache2.exists());
    }

    #[test]
    fn test_provider_config_default() {
        let config = ProviderConfig::default();
        assert!(config.api_key.is_none());
        assert_eq!(config.staleness_threshold, Duration::from_secs(3600));
        assert!(!config.cache_path.is_empty());
    }

    #[test]
    fn test_metadata_display_live() {
        let metadata = RateMetadata {
            updated_at: Utc::now(),
            source: RateSource::Live,
            provider: "test".to_string(),
        };
        let display = metadata.display_status();
        assert!(display.starts_with("rates updated "));
        assert!(display.contains("just now"));
    }

    #[test]
    fn test_metadata_display_offline() {
        let metadata = RateMetadata {
            updated_at: Utc::now() - chrono::Duration::hours(2),
            source: RateSource::Offline,
            provider: "test".to_string(),
        };
        let display = metadata.display_status();
        assert!(display.starts_with("offline -- rates from "));
    }

    #[test]
    fn test_metadata_display_minutes_ago() {
        let metadata = RateMetadata {
            updated_at: Utc::now() - chrono::Duration::minutes(5),
            source: RateSource::Cached,
            provider: "test".to_string(),
        };
        let display = metadata.display_status();
        assert!(display.contains("minute"));
    }

    #[test]
    fn test_metadata_display_hours_ago() {
        let metadata = RateMetadata {
            updated_at: Utc::now() - chrono::Duration::hours(3),
            source: RateSource::Live,
            provider: "test".to_string(),
        };
        let display = metadata.display_status();
        assert!(display.contains("hour"));
    }

    #[test]
    fn test_metadata_display_days_ago() {
        let metadata = RateMetadata {
            updated_at: Utc::now() - chrono::Duration::days(2),
            source: RateSource::Cached,
            provider: "test".to_string(),
        };
        let display = metadata.display_status();
        assert!(display.contains("day"));
    }

    #[test]
    fn test_exchange_rates_serialization_roundtrip() {
        let rates = sample_rates();
        let json = serde_json::to_string(&rates).unwrap();
        let deserialized: ExchangeRates = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.base, "USD");
        assert_eq!(deserialized.rates.len(), 3);
        assert!((deserialized.rates["EUR"] - 0.85).abs() < f64::EPSILON);
    }
}
