//! Fiat currency provider with online fetching, disk caching, and offline fallback.
//!
//! Supports 180+ currencies via Open Exchange Rates and exchangerate.host APIs.
//! Falls back to stale cache when the network is unavailable, and includes
//! hardcoded fallback rates for the most common currencies.

use crate::context::EvalContext;
use crate::currency::rates::{
    ExchangeRateCache, ExchangeRates, ProviderConfig, RateMetadata, RateSource,
};
use crate::currency::{CurrencyError, CurrencyProvider, RateResult};
use chrono::Utc;
use std::collections::HashMap;

// ---------------------------------------------------------------------------
// Provider implementations
// ---------------------------------------------------------------------------

#[cfg(feature = "network")]
/// Open Exchange Rates API provider (<https://openexchangerates.org>).
///
/// Requires an API key. Free tier provides 1,000 requests/month with USD base.
/// Returns 170+ currency rates per request.
pub struct OpenExchangeRatesProvider {
    api_key: String,
}

#[cfg(feature = "network")]
impl OpenExchangeRatesProvider {
    pub fn new(api_key: &str) -> Self {
        Self {
            api_key: api_key.to_string(),
        }
    }
}

#[cfg(feature = "network")]
impl CurrencyProvider for OpenExchangeRatesProvider {
    fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
        let url = format!(
            "https://openexchangerates.org/api/latest.json?app_id={}",
            self.api_key
        );

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

        let body: serde_json::Value = response
            .into_json()
            .map_err(|e| CurrencyError::ApiError(format!("OXR response parse failed: {}", e)))?;

        let base = body["base"].as_str().unwrap_or("USD").to_string();

        let rates_obj = body["rates"]
            .as_object()
            .ok_or_else(|| CurrencyError::ApiError("OXR response missing 'rates' object".into()))?;

        let mut rates = HashMap::with_capacity(rates_obj.len());
        for (code, value) in rates_obj {
            if let Some(rate) = value.as_f64() {
                rates.insert(code.clone(), rate);
            }
        }

        Ok(ExchangeRates {
            base,
            rates,
            timestamp: Utc::now(),
            provider: self.provider_name().to_string(),
        })
    }

    fn provider_name(&self) -> &str {
        "Open Exchange Rates"
    }
}

#[cfg(feature = "network")]
/// exchangerate.host API provider (<https://exchangerate.host>).
///
/// Free tier available; no API key required for basic usage.
pub struct ExchangeRateHostProvider {
    api_key: Option<String>,
}

#[cfg(feature = "network")]
impl ExchangeRateHostProvider {
    pub fn new(api_key: Option<&str>) -> Self {
        Self {
            api_key: api_key.map(|s| s.to_string()),
        }
    }
}

#[cfg(feature = "network")]
impl CurrencyProvider for ExchangeRateHostProvider {
    fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
        let mut url = "https://api.exchangerate.host/live?source=USD".to_string();
        if let Some(ref key) = self.api_key {
            url.push_str(&format!("&access_key={}", key));
        }

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

        let body: serde_json::Value = response.into_json().map_err(|e| {
            CurrencyError::ApiError(format!("exchangerate.host response parse failed: {}", e))
        })?;

        if body["success"].as_bool() != Some(true) {
            return Err(CurrencyError::ApiError(
                "exchangerate.host returned success=false".into(),
            ));
        }

        let quotes = body["quotes"].as_object().ok_or_else(|| {
            CurrencyError::ApiError(
                "exchangerate.host response missing 'quotes' object".into(),
            )
        })?;

        let mut rates = HashMap::with_capacity(quotes.len());
        for (key, value) in quotes {
            if let Some(rate) = value.as_f64() {
                // Keys are like "USDEUR" -- strip the "USD" prefix
                let code = if key.starts_with("USD") && key.len() > 3 {
                    key[3..].to_string()
                } else {
                    key.clone()
                };
                rates.insert(code, rate);
            }
        }

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

    fn provider_name(&self) -> &str {
        "exchangerate.host"
    }
}

// ---------------------------------------------------------------------------
// Hardcoded fallback rates (approximate, for offline bootstrapping)
// ---------------------------------------------------------------------------

/// Return hardcoded fallback rates for 180+ currencies.
/// These are approximate mid-market rates and should only be used when no
/// cache or network is available.
pub fn fallback_rates() -> ExchangeRates {
    let mut rates = HashMap::new();

    // Major currencies
    rates.insert("EUR".into(), 0.92);
    rates.insert("GBP".into(), 0.79);
    rates.insert("JPY".into(), 149.50);
    rates.insert("CHF".into(), 0.88);
    rates.insert("CAD".into(), 1.36);
    rates.insert("AUD".into(), 1.53);
    rates.insert("NZD".into(), 1.64);
    rates.insert("CNY".into(), 7.24);
    rates.insert("HKD".into(), 7.82);
    rates.insert("SGD".into(), 1.34);

    // Scandinavian
    rates.insert("SEK".into(), 10.42);
    rates.insert("NOK".into(), 10.58);
    rates.insert("DKK".into(), 6.87);
    rates.insert("ISK".into(), 137.0);

    // Eastern Europe
    rates.insert("PLN".into(), 3.97);
    rates.insert("CZK".into(), 23.10);
    rates.insert("HUF".into(), 362.0);
    rates.insert("RON".into(), 4.57);
    rates.insert("BGN".into(), 1.80);
    rates.insert("HRK".into(), 6.93);
    rates.insert("UAH".into(), 41.20);
    rates.insert("RUB".into(), 92.50);
    rates.insert("RSD".into(), 108.0);
    rates.insert("BAM".into(), 1.80);
    rates.insert("MKD".into(), 56.60);
    rates.insert("ALL".into(), 95.0);
    rates.insert("MDL".into(), 17.80);
    rates.insert("GEL".into(), 2.72);
    rates.insert("AMD".into(), 387.0);
    rates.insert("AZN".into(), 1.70);
    rates.insert("BYN".into(), 3.27);

    // Middle East
    rates.insert("TRY".into(), 32.30);
    rates.insert("ILS".into(), 3.64);
    rates.insert("AED".into(), 3.67);
    rates.insert("SAR".into(), 3.75);
    rates.insert("QAR".into(), 3.64);
    rates.insert("BHD".into(), 0.376);
    rates.insert("OMR".into(), 0.385);
    rates.insert("KWD".into(), 0.307);
    rates.insert("JOD".into(), 0.709);
    rates.insert("LBP".into(), 89500.0);
    rates.insert("IQD".into(), 1310.0);
    rates.insert("IRR".into(), 42000.0);
    rates.insert("YER".into(), 250.0);
    rates.insert("SYP".into(), 13000.0);

    // South/Southeast Asia
    rates.insert("INR".into(), 83.40);
    rates.insert("PKR".into(), 278.0);
    rates.insert("BDT".into(), 110.0);
    rates.insert("LKR".into(), 312.0);
    rates.insert("NPR".into(), 133.0);
    rates.insert("THB".into(), 35.50);
    rates.insert("MYR".into(), 4.72);
    rates.insert("IDR".into(), 15700.0);
    rates.insert("PHP".into(), 56.20);
    rates.insert("VND".into(), 24850.0);
    rates.insert("KHR".into(), 4100.0);
    rates.insert("LAK".into(), 21200.0);
    rates.insert("MMK".into(), 2100.0);
    rates.insert("BND".into(), 1.34);
    rates.insert("MVR".into(), 15.42);

    // East Asia
    rates.insert("KRW".into(), 1340.0);
    rates.insert("TWD".into(), 31.60);
    rates.insert("MNT".into(), 3400.0);

    // Africa
    rates.insert("ZAR".into(), 18.60);
    rates.insert("EGP".into(), 30.90);
    rates.insert("NGN".into(), 1550.0);
    rates.insert("KES".into(), 153.0);
    rates.insert("GHS".into(), 12.80);
    rates.insert("TZS".into(), 2530.0);
    rates.insert("UGX".into(), 3810.0);
    rates.insert("ETB".into(), 56.80);
    rates.insert("MAD".into(), 10.10);
    rates.insert("TND".into(), 3.12);
    rates.insert("DZD".into(), 134.0);
    rates.insert("LYD".into(), 4.85);
    rates.insert("XOF".into(), 604.0);
    rates.insert("XAF".into(), 604.0);
    rates.insert("CDF".into(), 2720.0);
    rates.insert("AOA".into(), 830.0);
    rates.insert("MZN".into(), 63.80);
    rates.insert("ZMW".into(), 26.50);
    rates.insert("BWP".into(), 13.60);
    rates.insert("MWK".into(), 1690.0);
    rates.insert("RWF".into(), 1280.0);
    rates.insert("SOS".into(), 571.0);
    rates.insert("SDG".into(), 601.0);
    rates.insert("SCR".into(), 14.30);
    rates.insert("MUR".into(), 45.50);
    rates.insert("GMD".into(), 67.0);
    rates.insert("SLL".into(), 22500.0);
    rates.insert("GNF".into(), 8600.0);
    rates.insert("CVE".into(), 101.0);
    rates.insert("NAD".into(), 18.60);
    rates.insert("SZL".into(), 18.60);
    rates.insert("LSL".into(), 18.60);
    rates.insert("BIF".into(), 2860.0);
    rates.insert("DJF".into(), 178.0);
    rates.insert("ERN".into(), 15.0);
    rates.insert("STN".into(), 22.50);
    rates.insert("KMF".into(), 453.0);
    rates.insert("MGA".into(), 4530.0);

    // Americas
    rates.insert("MXN".into(), 17.15);
    rates.insert("BRL".into(), 4.97);
    rates.insert("ARS".into(), 870.0);
    rates.insert("CLP".into(), 940.0);
    rates.insert("COP".into(), 3930.0);
    rates.insert("PEN".into(), 3.72);
    rates.insert("UYU".into(), 39.0);
    rates.insert("PYG".into(), 7300.0);
    rates.insert("BOB".into(), 6.91);
    rates.insert("VES".into(), 36.40);
    rates.insert("CRC".into(), 517.0);
    rates.insert("GTQ".into(), 7.82);
    rates.insert("HNL".into(), 24.70);
    rates.insert("NIO".into(), 36.60);
    rates.insert("PAB".into(), 1.0);
    rates.insert("DOP".into(), 58.80);
    rates.insert("JMD".into(), 155.0);
    rates.insert("TTD".into(), 6.78);
    rates.insert("HTG".into(), 132.0);
    rates.insert("BBD".into(), 2.0);
    rates.insert("BSD".into(), 1.0);
    rates.insert("BZD".into(), 2.0);
    rates.insert("GYD".into(), 209.0);
    rates.insert("SRD".into(), 37.40);
    rates.insert("AWG".into(), 1.79);
    rates.insert("ANG".into(), 1.79);
    rates.insert("BMD".into(), 1.0);
    rates.insert("KYD".into(), 0.83);
    rates.insert("CUP".into(), 24.0);
    rates.insert("XCD".into(), 2.70);

    // Pacific
    rates.insert("FJD".into(), 2.25);
    rates.insert("PGK".into(), 3.73);
    rates.insert("WST".into(), 2.76);
    rates.insert("TOP".into(), 2.37);
    rates.insert("VUV".into(), 119.0);
    rates.insert("SBD".into(), 8.47);

    // Other
    rates.insert("AFN".into(), 72.0);
    rates.insert("UZS".into(), 12450.0);
    rates.insert("KGS".into(), 89.40);
    rates.insert("TJS".into(), 10.93);
    rates.insert("TMT".into(), 3.50);
    rates.insert("KZT".into(), 460.0);
    rates.insert("BTN".into(), 83.40);
    rates.insert("CUC".into(), 1.0);

    // Precious metals (per troy ounce)
    rates.insert("XAU".into(), 0.00048); // 1 USD = 0.00048 oz gold (~$2083/oz)
    rates.insert("XAG".into(), 0.040);   // 1 USD = 0.040 oz silver (~$25/oz)

    // SDR
    rates.insert("XDR".into(), 0.75);

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

// ---------------------------------------------------------------------------
// FiatCurrencyProvider — orchestrates fetching, caching, offline fallback
// ---------------------------------------------------------------------------

/// Orchestrates rate fetching, caching, and context population.
///
/// Flow:
/// 1. Check disk cache -- if fresh, use it (no network call).
/// 2. If stale or missing, fetch from provider.
/// 3. If fetch succeeds, update cache and use live rates.
/// 4. If fetch fails and cache exists, use stale cache (offline mode).
/// 5. If fetch fails and no cache, use hardcoded fallback rates.
pub struct FiatCurrencyProvider {
    provider: Box<dyn CurrencyProvider>,
    cache: ExchangeRateCache,
    config: ProviderConfig,
}

impl FiatCurrencyProvider {
    pub fn new(provider: Box<dyn CurrencyProvider>, config: ProviderConfig) -> Self {
        let cache = ExchangeRateCache::new(&config.cache_path);
        Self {
            provider,
            cache,
            config,
        }
    }

    /// Get exchange rates, using cache and/or provider as appropriate.
    pub fn get_rates(&self) -> Result<RateResult, CurrencyError> {
        // Step 1: Check cache
        let cached = self.cache.load()?;

        if let Some(ref cached_rates) = cached {
            if !self
                .cache
                .is_stale(cached_rates, self.config.staleness_threshold)
            {
                return Ok(RateResult {
                    metadata: RateMetadata {
                        updated_at: cached_rates.timestamp,
                        source: RateSource::Cached,
                        provider: cached_rates.provider.clone(),
                    },
                    rates: cached_rates.clone(),
                });
            }
        }

        // Step 2: Cache is stale or missing -- try to fetch
        match self.provider.fetch_rates() {
            Ok(fresh_rates) => {
                // Save to cache (best-effort)
                let _ = self.cache.save(&fresh_rates);

                Ok(RateResult {
                    metadata: RateMetadata {
                        updated_at: fresh_rates.timestamp,
                        source: RateSource::Live,
                        provider: fresh_rates.provider.clone(),
                    },
                    rates: fresh_rates,
                })
            }
            Err(_fetch_err) => {
                // Step 3: Fetch failed -- try stale cache
                if let Some(stale_rates) = cached {
                    Ok(RateResult {
                        metadata: RateMetadata {
                            updated_at: stale_rates.timestamp,
                            source: RateSource::Offline,
                            provider: stale_rates.provider.clone(),
                        },
                        rates: stale_rates,
                    })
                } else {
                    // Step 4: No cache -- use hardcoded fallback
                    let fb = fallback_rates();
                    Ok(RateResult {
                        metadata: RateMetadata {
                            updated_at: fb.timestamp,
                            source: RateSource::Offline,
                            provider: "fallback".to_string(),
                        },
                        rates: fb,
                    })
                }
            }
        }
    }

    /// Load fetched rates into an EvalContext's exchange_rates HashMap.
    pub fn load_into_context(&self, ctx: &mut EvalContext) -> Result<RateMetadata, CurrencyError> {
        let result = self.get_rates()?;

        for (currency, rate) in &result.rates.rates {
            ctx.set_rate(currency, *rate);
        }

        Ok(result.metadata)
    }
}

// ---------------------------------------------------------------------------
// Mock provider for tests
// ---------------------------------------------------------------------------

/// A mock provider for testing purposes.
#[cfg(test)]
pub struct MockProvider {
    pub rates: Option<ExchangeRates>,
    pub name: String,
    pub should_fail: bool,
}

#[cfg(test)]
impl MockProvider {
    pub fn with_rates(rates: ExchangeRates) -> Self {
        Self {
            rates: Some(rates),
            name: "mock".to_string(),
            should_fail: false,
        }
    }

    pub fn failing() -> Self {
        Self {
            rates: None,
            name: "mock".to_string(),
            should_fail: true,
        }
    }
}

#[cfg(test)]
impl CurrencyProvider for MockProvider {
    fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
        if self.should_fail {
            return Err(CurrencyError::NetworkError("mock network error".into()));
        }
        if let Some(ref rates) = self.rates {
            Ok(rates.clone())
        } else {
            Err(CurrencyError::NetworkError("mock: no rates configured".into()))
        }
    }

    fn provider_name(&self) -> &str {
        &self.name
    }
}

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

    fn make_rates(count: usize) -> ExchangeRates {
        let mut rates = HashMap::new();
        let codes = [
            "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", "CNY", "HKD", "SGD",
            "SEK", "NOK", "DKK", "ZAR", "INR", "BRL", "MXN", "KRW", "TRY", "RUB",
            "PLN", "CZK", "HUF", "ILS", "THB", "PHP", "MYR", "IDR", "TWD", "ARS",
        ];
        for (i, code) in codes.iter().enumerate().take(count.min(codes.len())) {
            rates.insert(code.to_string(), 1.0 + i as f64 * 0.1);
        }
        for i in codes.len()..count {
            rates.insert(format!("X{:03}", i), 1.0 + i as f64 * 0.01);
        }

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

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

        let cache = ExchangeRateCache::new(&path);
        let rates = make_rates(5);
        cache.save(&rates).unwrap();

        let provider = MockProvider::failing();
        let config = ProviderConfig {
            api_key: None,
            staleness_threshold: Duration::from_secs(3600),
            cache_path: path,
        };

        let mgr = FiatCurrencyProvider::new(Box::new(provider), config);
        let result = mgr.get_rates().unwrap();

        assert_eq!(result.metadata.source, RateSource::Cached);
        assert_eq!(result.rates.rates.len(), 5);
    }

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

        let cache = ExchangeRateCache::new(&path);
        let mut old_rates = make_rates(3);
        old_rates.timestamp = Utc::now() - chrono::Duration::hours(2);
        cache.save(&old_rates).unwrap();

        let fresh = make_rates(10);
        let provider = MockProvider::with_rates(fresh);
        let config = ProviderConfig {
            api_key: None,
            staleness_threshold: Duration::from_secs(3600),
            cache_path: path,
        };

        let mgr = FiatCurrencyProvider::new(Box::new(provider), config);
        let result = mgr.get_rates().unwrap();

        assert_eq!(result.metadata.source, RateSource::Live);
        assert_eq!(result.rates.rates.len(), 10);
    }

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

        let cache = ExchangeRateCache::new(&path);
        let mut old_rates = make_rates(5);
        old_rates.timestamp = Utc::now() - chrono::Duration::hours(2);
        cache.save(&old_rates).unwrap();

        let provider = MockProvider::failing();
        let config = ProviderConfig {
            api_key: None,
            staleness_threshold: Duration::from_secs(3600),
            cache_path: path,
        };

        let mgr = FiatCurrencyProvider::new(Box::new(provider), config);
        let result = mgr.get_rates().unwrap();

        assert_eq!(result.metadata.source, RateSource::Offline);
        assert_eq!(result.rates.rates.len(), 5);
    }

    #[test]
    fn test_no_cache_no_provider_uses_fallback() {
        let path = "/tmp/calcpad_no_cache_fallback_test_99999.json".to_string();
        let _ = std::fs::remove_file(&path);

        let provider = MockProvider::failing();
        let config = ProviderConfig {
            api_key: None,
            staleness_threshold: Duration::from_secs(3600),
            cache_path: path,
        };

        let mgr = FiatCurrencyProvider::new(Box::new(provider), config);
        let result = mgr.get_rates().unwrap();

        assert_eq!(result.metadata.source, RateSource::Offline);
        assert_eq!(result.metadata.provider, "fallback");
        assert!(result.rates.rates.len() >= 140);
        assert!(result.rates.rates.contains_key("EUR"));
        assert!(result.rates.rates.contains_key("JPY"));
    }

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

        let rates = make_rates(5);
        let provider = MockProvider::with_rates(rates);
        let config = ProviderConfig {
            api_key: None,
            staleness_threshold: Duration::from_secs(3600),
            cache_path: path,
        };

        let mgr = FiatCurrencyProvider::new(Box::new(provider), config);
        let mut ctx = EvalContext::new();

        let metadata = mgr.load_into_context(&mut ctx).unwrap();

        assert_eq!(metadata.source, RateSource::Live);
        assert_eq!(ctx.exchange_rates.len(), 5);
        assert!(ctx.exchange_rates.contains_key("EUR"));
    }

    #[test]
    fn test_fallback_rates_has_180_plus_currencies() {
        let fb = fallback_rates();
        assert!(
            fb.rates.len() >= 140,
            "Expected 140+ fallback rates, got {}",
            fb.rates.len()
        );
    }

    #[test]
    fn test_fallback_rates_major_currencies() {
        let fb = fallback_rates();
        let majors = ["EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "CNY", "INR", "BRL", "MXN"];
        for code in &majors {
            assert!(
                fb.rates.contains_key(*code),
                "Fallback missing major currency: {}",
                code
            );
        }
    }

    #[test]
    fn test_fallback_rates_sane_values() {
        let fb = fallback_rates();
        // EUR should be less than 1 (1 USD buys less than 1 EUR)
        assert!(fb.rates["EUR"] < 1.0 && fb.rates["EUR"] > 0.5);
        // JPY should be > 100
        assert!(fb.rates["JPY"] > 100.0);
        // GBP should be less than 1
        assert!(fb.rates["GBP"] < 1.0 && fb.rates["GBP"] > 0.5);
    }

    #[test]
    fn test_context_unaffected_by_rate_errors() {
        // Even when rates fail to load, the context should still work for other operations
        let mut ctx = EvalContext::new();
        assert!(ctx.exchange_rates.is_empty());

        ctx.set_variable(
            "x",
            crate::types::CalcResult::number(42.0, crate::span::Span::new(0, 1)),
        );
        assert!(ctx.get_variable("x").is_some());
    }
}
