//! Cryptocurrency rate provider with CoinGecko API integration.
//!
//! Supports 60+ coins with disk caching and offline fallback.
//! All rates are stored as USD prices.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;

/// Configuration for the cryptocurrency provider.
#[derive(Debug, Clone)]
pub struct CryptoProviderConfig {
    /// CoinGecko API base URL.
    pub api_url: String,
    /// Path to the disk cache file.
    pub cache_path: PathBuf,
    /// How often to refresh rates (default: 1 hour).
    pub refresh_interval: Duration,
}

impl Default for CryptoProviderConfig {
    fn default() -> Self {
        let cache_dir = dirs_cache_path();
        Self {
            api_url: "https://api.coingecko.com/api/v3".to_string(),
            cache_path: cache_dir.join("crypto_cache.json"),
            refresh_interval: Duration::from_secs(3600),
        }
    }
}

fn dirs_cache_path() -> PathBuf {
    if let Some(home) = std::env::var_os("HOME") {
        PathBuf::from(home).join(".calcpad")
    } else {
        PathBuf::from(".calcpad")
    }
}

/// A single cryptocurrency rate entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CryptoRate {
    pub symbol: String,
    pub name: String,
    pub usd_price: f64,
}

/// Cached crypto rates stored on disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CryptoCache {
    pub rates: HashMap<String, CryptoRate>,
    pub timestamp: DateTime<Utc>,
    pub provider: String,
}

/// The cryptocurrency rate provider.
///
/// Supports fetching from CoinGecko, caching to disk, and offline fallback.
#[derive(Debug)]
pub struct CryptoProvider {
    config: CryptoProviderConfig,
    cache: Option<CryptoCache>,
}

impl CryptoProvider {
    /// Create a new CryptoProvider with the given configuration.
    pub fn new(config: CryptoProviderConfig) -> Self {
        let mut provider = CryptoProvider {
            config,
            cache: None,
        };
        provider.load_cache();
        provider
    }

    /// Create a CryptoProvider with default configuration.
    pub fn with_defaults() -> Self {
        Self::new(CryptoProviderConfig::default())
    }

    /// Create a CryptoProvider with pre-loaded rates (for testing or offline use).
    pub fn with_rates(rates: HashMap<String, CryptoRate>, timestamp: DateTime<Utc>) -> Self {
        CryptoProvider {
            config: CryptoProviderConfig::default(),
            cache: Some(CryptoCache {
                rates,
                timestamp,
                provider: "manual".to_string(),
            }),
        }
    }

    /// Get the USD price for a crypto symbol (case-insensitive).
    pub fn get_rate(&self, symbol: &str) -> Option<f64> {
        let upper = symbol.to_uppercase();
        self.cache
            .as_ref()
            .and_then(|c| c.rates.get(&upper))
            .map(|r| r.usd_price)
    }

    /// Check if the cached rates are stale.
    pub fn is_stale(&self) -> bool {
        match &self.cache {
            None => true,
            Some(cache) => {
                let age = Utc::now().signed_duration_since(cache.timestamp);
                age.to_std().unwrap_or(Duration::MAX) > self.config.refresh_interval
            }
        }
    }

    /// Get the timestamp of the cached rates.
    pub fn rate_timestamp(&self) -> Option<DateTime<Utc>> {
        self.cache.as_ref().map(|c| c.timestamp)
    }

    /// Get a human-readable description of the rate age.
    pub fn rate_age_display(&self) -> Option<String> {
        self.cache.as_ref().map(|c| {
            let age = Utc::now().signed_duration_since(c.timestamp);
            let secs = age.num_seconds();
            if secs < 60 {
                "just now".to_string()
            } else if secs < 3600 {
                format!("{} minutes ago", secs / 60)
            } else if secs < 86400 {
                format!("{} hours ago", secs / 3600)
            } else {
                format!("{} days ago", secs / 86400)
            }
        })
    }

    /// Refresh rates from the CoinGecko API.
    /// Returns Ok(()) on success, Err with message on failure.
    /// Falls back to cached rates if the API call fails.
    #[cfg(feature = "network")]
    pub fn refresh(&mut self) -> Result<(), String> {
        if !self.is_stale() {
            return Ok(());
        }

        match self.fetch_from_api() {
            Ok(rates) => {
                let cache = CryptoCache {
                    rates,
                    timestamp: Utc::now(),
                    provider: "coingecko".to_string(),
                };
                self.cache = Some(cache);
                self.save_cache();
                Ok(())
            }
            Err(e) => {
                if self.cache.is_some() {
                    Err(format!("API fetch failed, using cached rates: {}", e))
                } else {
                    Err(format!("No rates available: {}", e))
                }
            }
        }
    }

    /// Refresh is unavailable without the `network` feature.
    #[cfg(not(feature = "network"))]
    pub fn refresh(&mut self) -> Result<(), String> {
        if self.cache.is_some() {
            Ok(())
        } else {
            Err("Network support not available".to_string())
        }
    }

    /// Fetch rates from CoinGecko API.
    #[cfg(feature = "network")]
    fn fetch_from_api(&self) -> Result<HashMap<String, CryptoRate>, String> {
        let url = format!(
            "{}/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&sparkline=false",
            self.config.api_url
        );

        let response = ureq::get(&url)
            .call()
            .map_err(|e| format!("HTTP request failed: {}", e))?;

        let body: serde_json::Value = response
            .into_json()
            .map_err(|e| format!("Failed to parse JSON: {}", e))?;

        let entries: Vec<CoinGeckoMarketEntry> = serde_json::from_value(body)
            .map_err(|e| format!("Failed to deserialize response: {}", e))?;

        let mut rates = HashMap::new();
        for entry in entries {
            let symbol = entry.symbol.to_uppercase();
            rates.insert(
                symbol.clone(),
                CryptoRate {
                    symbol,
                    name: entry.name,
                    usd_price: entry.current_price,
                },
            );
        }

        Ok(rates)
    }

    /// Load cache from disk.
    fn load_cache(&mut self) {
        if let Ok(data) = std::fs::read_to_string(&self.config.cache_path) {
            if let Ok(cache) = serde_json::from_str::<CryptoCache>(&data) {
                self.cache = Some(cache);
            }
        }
    }

    /// Save cache to disk.
    fn save_cache(&self) {
        if let Some(ref cache) = self.cache {
            if let Some(parent) = self.config.cache_path.parent() {
                let _ = std::fs::create_dir_all(parent);
            }
            if let Ok(json) = serde_json::to_string_pretty(cache) {
                let _ = std::fs::write(&self.config.cache_path, json);
            }
        }
    }

    /// Check if rates are available (either fresh or cached).
    pub fn has_rates(&self) -> bool {
        self.cache.is_some()
    }

    /// Convert an amount from one currency to another.
    /// Supports crypto-to-fiat (USD) and fiat(USD)-to-crypto conversions,
    /// as well as crypto-to-crypto cross-rates via USD.
    pub fn convert(
        &self,
        amount: f64,
        from: &str,
        to: &str,
    ) -> Result<(f64, ConversionMeta), String> {
        let from_upper = from.to_uppercase();
        let to_upper = to.to_uppercase();

        let cache = self.cache.as_ref().ok_or("No crypto rates available")?;

        let usd_amount = if from_upper == "USD" {
            amount
        } else if let Some(rate) = cache.rates.get(&from_upper) {
            amount * rate.usd_price
        } else {
            return Err(format!("Unknown currency: {}", from));
        };

        let result = if to_upper == "USD" {
            usd_amount
        } else if let Some(rate) = cache.rates.get(&to_upper) {
            if rate.usd_price == 0.0 {
                return Err(format!("Rate for {} is zero", to));
            }
            usd_amount / rate.usd_price
        } else {
            return Err(format!("Unknown currency: {}", to));
        };

        let meta = ConversionMeta {
            timestamp: cache.timestamp,
            is_stale: self.is_stale(),
            age_display: self.rate_age_display().unwrap_or_default(),
        };

        Ok((result, meta))
    }
}

/// Metadata about a currency conversion result.
#[derive(Debug, Clone)]
pub struct ConversionMeta {
    pub timestamp: DateTime<Utc>,
    pub is_stale: bool,
    pub age_display: String,
}

/// CoinGecko API response entry for /coins/markets.
#[derive(Debug, Deserialize)]
struct CoinGeckoMarketEntry {
    symbol: String,
    name: String,
    current_price: f64,
}

/// Static mapping of crypto symbols to CoinGecko IDs.
/// Top 60+ cryptocurrencies by market cap.
///
/// This is used for:
/// - Recognizing crypto symbols in expressions
/// - Looking up CoinGecko IDs for API calls
pub(crate) static CRYPTO_SYMBOLS: &[(&str, &str)] = &[
    ("BTC", "bitcoin"),
    ("ETH", "ethereum"),
    ("USDT", "tether"),
    ("BNB", "binancecoin"),
    ("SOL", "solana"),
    ("XRP", "ripple"),
    ("USDC", "usd-coin"),
    ("ADA", "cardano"),
    ("DOGE", "dogecoin"),
    ("TRX", "tron"),
    ("AVAX", "avalanche-2"),
    ("TON", "the-open-network"),
    ("SHIB", "shiba-inu"),
    ("DOT", "polkadot"),
    ("LINK", "chainlink"),
    ("BCH", "bitcoin-cash"),
    ("NEAR", "near"),
    ("DAI", "dai"),
    ("LTC", "litecoin"),
    ("MATIC", "matic-network"),
    ("UNI", "uniswap"),
    ("ICP", "internet-computer"),
    ("LEO", "leo-token"),
    ("APT", "aptos"),
    ("ETC", "ethereum-classic"),
    ("ATOM", "cosmos"),
    ("XLM", "stellar"),
    ("HBAR", "hedera-hashgraph"),
    ("FIL", "filecoin"),
    ("IMX", "immutable-x"),
    ("MNT", "mantle"),
    ("CRO", "crypto-com-chain"),
    ("ARB", "arbitrum"),
    ("OP", "optimism"),
    ("VET", "vechain"),
    ("MKR", "maker"),
    ("ALGO", "algorand"),
    ("GRT", "the-graph"),
    ("AAVE", "aave"),
    ("FTM", "fantom"),
    ("SAND", "the-sandbox"),
    ("THETA", "theta-token"),
    ("AXS", "axie-infinity"),
    ("EOS", "eos"),
    ("XTZ", "tezos"),
    ("MANA", "decentraland"),
    ("FLOW", "flow"),
    ("EGLD", "elrond-erd-2"),
    ("CHZ", "chiliz"),
    ("CAKE", "pancakeswap-token"),
    ("XMR", "monero"),
    ("NEO", "neo"),
    ("IOTA", "iota"),
    ("KLAY", "klay-token"),
    ("PEPE", "pepe"),
    ("SUI", "sui"),
    ("SEI", "sei-network"),
    ("INJ", "injective-protocol"),
    ("RNDR", "render-token"),
    ("RUNE", "thorchain"),
    ("WLD", "worldcoin-wld"),
    ("BONK", "bonk"),
];

/// Check if a symbol is a known cryptocurrency (case-insensitive).
pub fn is_known_crypto(symbol: &str) -> bool {
    let upper = symbol.to_uppercase();
    CRYPTO_SYMBOLS.iter().any(|(s, _)| *s == upper)
}

/// Get the CoinGecko ID for a crypto symbol (case-insensitive).
pub fn get_coingecko_id(symbol: &str) -> Option<&'static str> {
    let upper = symbol.to_uppercase();
    CRYPTO_SYMBOLS
        .iter()
        .find(|(s, _)| *s == upper)
        .map(|(_, id)| *id)
}

/// Get all known crypto symbols.
pub fn all_crypto_symbols() -> Vec<&'static str> {
    CRYPTO_SYMBOLS.iter().map(|(s, _)| *s).collect()
}

/// Count of registered crypto symbols.
pub fn crypto_symbol_count() -> usize {
    CRYPTO_SYMBOLS.len()
}

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

    fn make_test_rates() -> HashMap<String, CryptoRate> {
        let mut rates = HashMap::new();
        rates.insert(
            "BTC".to_string(),
            CryptoRate {
                symbol: "BTC".to_string(),
                name: "Bitcoin".to_string(),
                usd_price: 50000.0,
            },
        );
        rates.insert(
            "ETH".to_string(),
            CryptoRate {
                symbol: "ETH".to_string(),
                name: "Ethereum".to_string(),
                usd_price: 3000.0,
            },
        );
        rates.insert(
            "SOL".to_string(),
            CryptoRate {
                symbol: "SOL".to_string(),
                name: "Solana".to_string(),
                usd_price: 100.0,
            },
        );
        rates
    }

    #[test]
    fn test_has_60_plus_symbols() {
        assert!(
            crypto_symbol_count() >= 60,
            "Expected at least 60 crypto symbols, got {}",
            crypto_symbol_count()
        );
    }

    #[test]
    fn test_top_coins_present() {
        let top = [
            "BTC", "ETH", "SOL", "ADA", "XRP", "DOT", "DOGE", "LINK", "AVAX", "MATIC",
        ];
        for coin in &top {
            assert!(is_known_crypto(coin), "Expected {} to be known", coin);
        }
    }

    #[test]
    fn test_case_insensitive_crypto() {
        assert!(is_known_crypto("btc"));
        assert!(is_known_crypto("Btc"));
        assert!(is_known_crypto("BTC"));
    }

    #[test]
    fn test_unknown_symbol() {
        assert!(!is_known_crypto("FOOBAR"));
        assert!(!is_known_crypto(""));
    }

    #[test]
    fn test_coingecko_id_lookup() {
        assert_eq!(get_coingecko_id("BTC"), Some("bitcoin"));
        assert_eq!(get_coingecko_id("ETH"), Some("ethereum"));
        assert_eq!(get_coingecko_id("sol"), Some("solana"));
        assert_eq!(get_coingecko_id("UNKNOWN"), None);
    }

    #[test]
    fn test_all_symbols_returns_all() {
        let syms = all_crypto_symbols();
        assert_eq!(syms.len(), crypto_symbol_count());
        assert!(syms.contains(&"BTC"));
        assert!(syms.contains(&"ETH"));
    }

    #[test]
    fn test_get_rate() {
        let provider = CryptoProvider::with_rates(make_test_rates(), Utc::now());
        assert_eq!(provider.get_rate("BTC"), Some(50000.0));
        assert_eq!(provider.get_rate("btc"), Some(50000.0));
        assert_eq!(provider.get_rate("ETH"), Some(3000.0));
        assert_eq!(provider.get_rate("UNKNOWN"), None);
    }

    #[test]
    fn test_has_rates() {
        let provider = CryptoProvider::with_rates(make_test_rates(), Utc::now());
        assert!(provider.has_rates());

        let empty = CryptoProvider {
            config: CryptoProviderConfig::default(),
            cache: None,
        };
        assert!(!empty.has_rates());
    }

    #[test]
    fn test_is_stale_fresh() {
        let provider = CryptoProvider::with_rates(make_test_rates(), Utc::now());
        assert!(!provider.is_stale());
    }

    #[test]
    fn test_is_stale_old() {
        let old_time = Utc::now() - chrono::Duration::hours(2);
        let provider = CryptoProvider::with_rates(make_test_rates(), old_time);
        assert!(provider.is_stale());
    }

    #[test]
    fn test_is_stale_no_cache() {
        let provider = CryptoProvider {
            config: CryptoProviderConfig::default(),
            cache: None,
        };
        assert!(provider.is_stale());
    }

    #[test]
    fn test_convert_btc_to_usd() {
        let provider = CryptoProvider::with_rates(make_test_rates(), Utc::now());
        let (result, _meta) = provider.convert(1.0, "BTC", "USD").unwrap();
        assert!((result - 50000.0).abs() < 1e-10);
    }

    #[test]
    fn test_convert_usd_to_eth() {
        let provider = CryptoProvider::with_rates(make_test_rates(), Utc::now());
        let (result, _meta) = provider.convert(1000.0, "USD", "ETH").unwrap();
        let expected = 1000.0 / 3000.0;
        assert!((result - expected).abs() < 1e-10);
    }

    #[test]
    fn test_convert_btc_to_eth() {
        let provider = CryptoProvider::with_rates(make_test_rates(), Utc::now());
        let (result, _meta) = provider.convert(1.0, "BTC", "ETH").unwrap();
        let expected = 50000.0 / 3000.0;
        assert!((result - expected).abs() < 1e-10);
    }

    #[test]
    fn test_convert_unknown_currency() {
        let provider = CryptoProvider::with_rates(make_test_rates(), Utc::now());
        let result = provider.convert(1.0, "UNKNOWN", "USD");
        assert!(result.is_err());
    }

    #[test]
    fn test_convert_no_rates() {
        let provider = CryptoProvider {
            config: CryptoProviderConfig::default(),
            cache: None,
        };
        let result = provider.convert(1.0, "BTC", "USD");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("No crypto rates"));
    }

    #[test]
    fn test_rate_age_display() {
        let provider = CryptoProvider::with_rates(make_test_rates(), Utc::now());
        let display = provider.rate_age_display().unwrap();
        assert_eq!(display, "just now");
    }

    #[test]
    fn test_rate_timestamp() {
        let now = Utc::now();
        let provider = CryptoProvider::with_rates(make_test_rates(), now);
        assert_eq!(provider.rate_timestamp(), Some(now));
    }

    #[test]
    fn test_cache_serialization() {
        let cache = CryptoCache {
            rates: make_test_rates(),
            timestamp: Utc::now(),
            provider: "test".to_string(),
        };
        let json = serde_json::to_string(&cache).unwrap();
        let deserialized: CryptoCache = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.rates.len(), 3);
        assert_eq!(deserialized.provider, "test");
    }

    #[test]
    fn test_disk_cache_roundtrip() {
        let dir = std::env::temp_dir().join("calcpad_test_crypto_cache");
        let _ = std::fs::create_dir_all(&dir);
        let cache_path = dir.join("test_crypto_cache.json");

        let config = CryptoProviderConfig {
            cache_path: cache_path.clone(),
            ..Default::default()
        };

        // Create provider with rates and save
        let mut provider = CryptoProvider::new(config.clone());
        provider.cache = Some(CryptoCache {
            rates: make_test_rates(),
            timestamp: Utc::now(),
            provider: "test".to_string(),
        });
        provider.save_cache();

        // Create new provider that loads from disk
        let provider2 = CryptoProvider::new(config);
        assert!(provider2.has_rates());
        assert_eq!(provider2.get_rate("BTC"), Some(50000.0));

        // Cleanup
        let _ = std::fs::remove_file(&cache_path);
        let _ = std::fs::remove_dir(&dir);
    }

    #[test]
    fn test_configurable_refresh_interval() {
        let old_time = Utc::now() - chrono::Duration::minutes(6);

        // With 5-minute interval, 6 minutes old should be stale
        let config5 = CryptoProviderConfig {
            refresh_interval: Duration::from_secs(300),
            ..Default::default()
        };
        let mut provider = CryptoProvider::new(config5);
        provider.cache = Some(CryptoCache {
            rates: make_test_rates(),
            timestamp: old_time,
            provider: "test".to_string(),
        });
        assert!(provider.is_stale());

        // With 10-minute interval, 6 minutes should NOT be stale
        let config10 = CryptoProviderConfig {
            refresh_interval: Duration::from_secs(600),
            ..Default::default()
        };
        let mut provider2 = CryptoProvider::new(config10);
        provider2.cache = Some(CryptoCache {
            rates: make_test_rates(),
            timestamp: old_time,
            provider: "test".to_string(),
        });
        assert!(!provider2.is_stale());
    }
}
