//! Internet Exchange (IX) detection via PeeringDB
//!
//! Identifies when a hop is at an Internet Exchange point by matching
//! IP addresses against IX peering LAN prefixes from PeeringDB.

use anyhow::{Result, anyhow};
use ipnetwork::IpNetwork;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime};
use tokio::sync::OnceCell;
use tokio_util::sync::CancellationToken;

use super::sanitize_display;
use crate::state::IxInfo;
use crate::trace::receiver::SessionMap;

/// PeeringDB API response wrapper
#[derive(Debug, Deserialize)]
struct PdbResponse<T> {
    data: Vec<T>,
}

/// IX record from PeeringDB /api/ix
#[derive(Debug, Deserialize)]
struct PdbIx {
    id: u32,
    name: String,
    city: Option<String>,
    country: Option<String>,
}

/// IX LAN record from PeeringDB /api/ixlan
#[derive(Debug, Deserialize)]
struct PdbIxlan {
    id: u32,
    ix_id: u32,
}

/// IX prefix record from PeeringDB /api/ixpfx
#[derive(Debug, Deserialize)]
struct PdbIxpfx {
    ixlan_id: u32,
    prefix: String,
}

/// Cached IX data for fast lookups
#[derive(Debug, Clone, Serialize, Deserialize)]
struct IxCacheEntry {
    name: String,
    city: Option<String>,
    country: Option<String>,
}

/// Cached prefix to IX mapping
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PrefixCacheEntry {
    prefix: String, // Store as string for serialization
    ix_name: String,
    ix_city: Option<String>,
    ix_country: Option<String>,
}

/// Serializable cache format
#[derive(Debug, Serialize, Deserialize)]
struct IxCache {
    version: u32,
    fetched_at: u64, // Unix timestamp
    prefixes: Vec<PrefixCacheEntry>,
}

impl IxCache {
    const VERSION: u32 = 1;
    const MAX_AGE_SECS: u64 = 24 * 60 * 60; // 24 hours

    fn is_expired(&self) -> bool {
        let now = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        now - self.fetched_at > Self::MAX_AGE_SECS
    }
}

/// Node in a binary radix trie over IP address bits
///
/// Children are indices into the trie's flat node arena; `info` is an index
/// into the owning table's `IxInfo` vec, set on nodes that terminate a prefix.
#[derive(Debug, Default, Clone)]
struct TrieNode {
    children: [Option<u32>; 2],
    info: Option<u32>,
}

/// Binary radix trie for longest-prefix-match over IP address bits
///
/// Lookup walks one bit per level from the most significant bit, so it costs
/// O(address bits) regardless of how many prefixes are loaded. Address bits
/// are passed left-aligned in a `u128` (IPv4 occupies the top 32 bits).
#[derive(Debug)]
struct PrefixTrie {
    /// Node arena; index 0 is the root
    nodes: Vec<TrieNode>,
}

impl PrefixTrie {
    fn new() -> Self {
        Self {
            nodes: vec![TrieNode::default()],
        }
    }

    /// Insert a prefix, mapping its terminal node to `info_idx`
    ///
    /// Only the first `prefix_len` bits are walked, so host bits in the
    /// network address are ignored (matching `IpNetwork::contains`). The
    /// first insertion of a given prefix wins, preserving the old behavior
    /// where a stable sort kept earlier duplicates ahead of later ones.
    fn insert(&mut self, bits: u128, prefix_len: u8, info_idx: u32) {
        let mut node = 0usize;
        for i in 0..prefix_len {
            let bit = ((bits >> (127 - i)) & 1) as usize;
            node = match self.nodes[node].children[bit] {
                Some(child) => child as usize,
                None => {
                    self.nodes.push(TrieNode::default());
                    let child = (self.nodes.len() - 1) as u32;
                    self.nodes[node].children[bit] = Some(child);
                    child as usize
                }
            };
        }
        if self.nodes[node].info.is_none() {
            self.nodes[node].info = Some(info_idx);
        }
    }

    /// Find the most specific prefix containing the address bits
    fn lookup(&self, bits: u128, addr_len: u8) -> Option<u32> {
        let mut node = 0usize;
        // Root info covers a /0 prefix (matches everything)
        let mut best = self.nodes[0].info;
        for i in 0..addr_len {
            let bit = ((bits >> (127 - i)) & 1) as usize;
            match self.nodes[node].children[bit] {
                Some(child) => {
                    node = child as usize;
                    if let Some(info) = self.nodes[node].info {
                        best = Some(info);
                    }
                }
                None => break,
            }
        }
        best
    }
}

/// Convert an IP address to left-aligned bits and its bit width
fn addr_bits(ip: IpAddr) -> (u128, u8) {
    match ip {
        IpAddr::V4(v4) => ((u32::from(v4) as u128) << 96, 32),
        IpAddr::V6(v6) => (u128::from(v6), 128),
    }
}

/// In-memory prefix table: per-family radix tries plus the IX info they reference
#[derive(Debug)]
struct PrefixTable {
    v4: PrefixTrie,
    v6: PrefixTrie,
    infos: Vec<IxInfo>,
    /// Number of inserted prefixes (for cache status display)
    len: usize,
}

impl PrefixTable {
    fn new() -> Self {
        Self {
            v4: PrefixTrie::new(),
            v6: PrefixTrie::new(),
            infos: Vec::new(),
            len: 0,
        }
    }

    /// Insert a network prefix with its IX info
    fn insert(&mut self, network: &IpNetwork, info: IxInfo) {
        let info_idx = self.infos.len() as u32;
        self.infos.push(info);
        let (bits, _) = addr_bits(network.network());
        match network {
            IpNetwork::V4(_) => self.v4.insert(bits, network.prefix(), info_idx),
            IpNetwork::V6(_) => self.v6.insert(bits, network.prefix(), info_idx),
        }
        self.len += 1;
    }

    /// Longest-prefix-match lookup for an IP address
    fn lookup(&self, ip: IpAddr) -> Option<&IxInfo> {
        let (bits, addr_len) = addr_bits(ip);
        let trie = match ip {
            IpAddr::V4(_) => &self.v4,
            IpAddr::V6(_) => &self.v6,
        };
        trie.lookup(bits, addr_len)
            .map(|idx| &self.infos[idx as usize])
    }

    fn len(&self) -> usize {
        self.len
    }
}

/// Status of the PeeringDB cache for display in settings
#[derive(Debug, Clone)]
pub struct CacheStatus {
    /// Whether data has been successfully loaded
    pub loaded: bool,
    /// Number of IX prefixes in cache
    pub prefix_count: usize,
    /// Unix timestamp when cache was fetched (from disk or API)
    pub fetched_at: Option<u64>,
    /// Whether the cached data is past its TTL
    pub expired: bool,
    /// Whether a refresh operation is in progress
    pub refreshing: bool,
}

/// IX lookup via PeeringDB prefix matching
pub struct IxLookup {
    /// Parsed prefixes for lookup (populated from cache or API)
    /// Radix tries keyed on address bits for O(prefix_len) longest-prefix-match
    prefixes: RwLock<PrefixTable>,
    /// Cache file path
    cache_path: PathBuf,
    /// OnceCell ensures successful load runs exactly once
    /// Uses get_or_try_init so failures don't fill the cell
    load_once: OnceCell<()>,
    /// Timestamp of last load failure (for backoff)
    last_failure: AtomicU64,
    /// Per-IP result cache (to avoid repeated lookups)
    ip_cache: RwLock<HashMap<IpAddr, Option<IxInfo>>>,
    /// IP cache TTL
    ip_cache_ttl: Duration,
    /// Timestamps for IP cache entries
    ip_cache_times: RwLock<HashMap<IpAddr, Instant>>,
    /// Stored API key (env var takes precedence)
    api_key: RwLock<Option<String>>,
    /// Unix timestamp when cache was last fetched
    cache_fetched_at: AtomicU64,
    /// Whether a cache refresh is in progress
    refreshing: AtomicBool,
}

/// Backoff period after load failure (5 minutes)
const LOAD_FAILURE_BACKOFF_SECS: u64 = 300;

impl IxLookup {
    /// Create a new IX lookup instance
    pub fn new() -> Result<Self> {
        // Use standard cache directory
        let cache_dir = dirs::cache_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("ttl")
            .join("peeringdb");

        // Create cache directory if needed
        fs::create_dir_all(&cache_dir)?;

        let cache_path = cache_dir.join("ix_cache.json");

        Ok(Self {
            prefixes: RwLock::new(PrefixTable::new()),
            cache_path,
            load_once: OnceCell::new(),
            last_failure: AtomicU64::new(0),
            ip_cache: RwLock::new(HashMap::new()),
            ip_cache_ttl: Duration::from_secs(3600), // 1 hour for IP results
            ip_cache_times: RwLock::new(HashMap::new()),
            api_key: RwLock::new(None),
            cache_fetched_at: AtomicU64::new(0),
            refreshing: AtomicBool::new(false),
        })
    }

    /// Lookup IX info for an IP address
    ///
    /// Lazily loads PeeringDB data on first lookup.
    pub async fn lookup(&self, ip: IpAddr) -> Option<IxInfo> {
        // Check IP cache first
        {
            let ip_cache = self.ip_cache.read();
            let ip_times = self.ip_cache_times.read();
            if let (Some(result), Some(time)) = (ip_cache.get(&ip), ip_times.get(&ip))
                && time.elapsed() < self.ip_cache_ttl
            {
                return result.clone();
            }
        }

        // Ensure data is loaded
        // OnceCell is only filled on success; failures can be retried after backoff
        if self.load_once.get().is_none() {
            // Check backoff period after previous failure
            let now = SystemTime::now()
                .duration_since(SystemTime::UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs();
            let last_fail = self.last_failure.load(Ordering::Relaxed);
            if last_fail > 0 && now - last_fail < LOAD_FAILURE_BACKOFF_SECS {
                // Still in backoff period, skip loading
                return None;
            }

            // Use get_or_try_init: only fills cell on Ok, leaves unfilled on Err
            // This allows retries after backoff period expires
            let result = self
                .load_once
                .get_or_try_init(|| async {
                    self.load_data_inner().await.inspect_err(|_e| {
                        let now = SystemTime::now()
                            .duration_since(SystemTime::UNIX_EPOCH)
                            .unwrap_or_default()
                            .as_secs();
                        self.last_failure.store(now, Ordering::Relaxed);
                        // Don't print to stderr - it corrupts TUI
                        // Silently fail; IX detection is optional enrichment
                    })
                })
                .await;

            if result.is_err() {
                return None;
            }
        }

        // Radix trie lookup returns the longest matching prefix
        let result = self.prefixes.read().lookup(ip).cloned();

        // Cache result
        {
            let mut ip_cache = self.ip_cache.write();
            let mut ip_times = self.ip_cache_times.write();
            ip_cache.insert(ip, result.clone());
            ip_times.insert(ip, Instant::now());
        }

        result
    }

    /// Load IX data from cache or API
    async fn load_data_inner(&self) -> Result<()> {
        // Try loading from cache first
        if let Ok(cache) = self.load_cache()
            && !cache.is_expired()
        {
            self.cache_fetched_at
                .store(cache.fetched_at, Ordering::Relaxed);
            self.populate_from_cache(&cache)?;
            return Ok(());
        }

        // Fetch from API
        match self.fetch_from_api().await {
            Ok(cache) => {
                // Save to disk (ignore errors - cache is optional)
                let _ = self.save_cache(&cache);
                self.cache_fetched_at
                    .store(cache.fetched_at, Ordering::Relaxed);
                self.populate_from_cache(&cache)?;
                Ok(())
            }
            Err(e) => {
                // If API fails, try to use expired cache as fallback
                if let Ok(cache) = self.load_cache() {
                    // Silently use expired cache - better than nothing
                    self.cache_fetched_at
                        .store(cache.fetched_at, Ordering::Relaxed);
                    self.populate_from_cache(&cache)?;
                    return Ok(());
                }
                Err(e)
            }
        }
    }

    /// Load cache from disk
    fn load_cache(&self) -> Result<IxCache> {
        let data = fs::read_to_string(&self.cache_path)?;
        let cache: IxCache = serde_json::from_str(&data)?;
        if cache.version != IxCache::VERSION {
            return Err(anyhow!("cache version mismatch"));
        }
        Ok(cache)
    }

    /// Save cache to disk
    fn save_cache(&self, cache: &IxCache) -> Result<()> {
        let data = serde_json::to_string_pretty(cache)?;
        fs::write(&self.cache_path, data)?;
        Ok(())
    }

    /// Populate prefixes from cache
    fn populate_from_cache(&self, cache: &IxCache) -> Result<()> {
        let mut table = PrefixTable::new();

        for p in &cache.prefixes {
            if let Ok(network) = p.prefix.parse::<IpNetwork>() {
                // Sanitize IX names for safe terminal display
                table.insert(
                    &network,
                    IxInfo {
                        name: sanitize_display(&p.ix_name),
                        city: p.ix_city.as_ref().map(|s| sanitize_display(s)),
                        country: p.ix_country.as_ref().map(|s| sanitize_display(s)),
                    },
                );
            }
        }

        *self.prefixes.write() = table;
        Ok(())
    }

    /// Fetch IX data from PeeringDB API
    async fn fetch_from_api(&self) -> Result<IxCache> {
        // PeeringDB requires User-Agent to prevent scraping blocks
        let mut builder = reqwest::Client::builder()
            .timeout(Duration::from_secs(30))
            .user_agent(format!(
                "ttl/{} (https://github.com/lance0/ttl)",
                env!("CARGO_PKG_VERSION")
            ));

        // Add API key header if available (higher rate limits)
        // See: https://docs.peeringdb.com/howto/api_keys/
        // Priority: env var > stored key
        if let Some(key) = self.get_effective_api_key() {
            let mut headers = reqwest::header::HeaderMap::new();
            if let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Api-Key {}", key)) {
                headers.insert(reqwest::header::AUTHORIZATION, value);
                builder = builder.default_headers(headers);
            }
        }

        let client = builder.build()?;

        // Fetch all three endpoints in parallel
        let (ix_result, ixlan_result, ixpfx_result) = tokio::join!(
            self.fetch_ix(&client),
            self.fetch_ixlan(&client),
            self.fetch_ixpfx(&client),
        );

        let ix_data = ix_result?;
        let ixlan_data = ixlan_result?;
        let ixpfx_data = ixpfx_result?;

        // Build lookup maps
        // ixlan_id -> ix_id
        let ixlan_to_ix: HashMap<u32, u32> =
            ixlan_data.iter().map(|lan| (lan.id, lan.ix_id)).collect();

        // ix_id -> IX info
        let ix_info: HashMap<u32, IxCacheEntry> = ix_data
            .iter()
            .map(|ix| {
                (
                    ix.id,
                    IxCacheEntry {
                        name: ix.name.clone(),
                        city: ix.city.clone(),
                        country: ix.country.clone(),
                    },
                )
            })
            .collect();

        // Build prefix cache entries (sanitize for safe terminal display)
        let mut prefixes = Vec::with_capacity(ixpfx_data.len());
        for pfx in ixpfx_data {
            if let Some(&ix_id) = ixlan_to_ix.get(&pfx.ixlan_id)
                && let Some(ix) = ix_info.get(&ix_id)
            {
                prefixes.push(PrefixCacheEntry {
                    prefix: pfx.prefix,
                    ix_name: sanitize_display(&ix.name),
                    ix_city: ix.city.as_ref().map(|s| sanitize_display(s)),
                    ix_country: ix.country.as_ref().map(|s| sanitize_display(s)),
                });
            }
        }

        let now = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        Ok(IxCache {
            version: IxCache::VERSION,
            fetched_at: now,
            prefixes,
        })
    }

    /// Fetch IX data from API
    /// Note: limit=0 disables pagination to fetch all records
    async fn fetch_ix(&self, client: &reqwest::Client) -> Result<Vec<PdbIx>> {
        let url = "https://www.peeringdb.com/api/ix?limit=0";
        let resp: PdbResponse<PdbIx> = client.get(url).send().await?.json().await?;
        Ok(resp.data)
    }

    /// Fetch IXLAN data from API
    async fn fetch_ixlan(&self, client: &reqwest::Client) -> Result<Vec<PdbIxlan>> {
        let url = "https://www.peeringdb.com/api/ixlan?limit=0";
        let resp: PdbResponse<PdbIxlan> = client.get(url).send().await?.json().await?;
        Ok(resp.data)
    }

    /// Fetch IX prefix data from API
    async fn fetch_ixpfx(&self, client: &reqwest::Client) -> Result<Vec<PdbIxpfx>> {
        let url = "https://www.peeringdb.com/api/ixpfx?limit=0";
        let resp: PdbResponse<PdbIxpfx> = client.get(url).send().await?.json().await?;
        Ok(resp.data)
    }

    /// Get the number of prefixes loaded
    #[allow(dead_code)]
    pub fn prefix_count(&self) -> usize {
        self.prefixes.read().len()
    }

    /// Set the API key for PeeringDB requests
    ///
    /// Note: Environment variable PEERINGDB_API_KEY takes precedence over this.
    pub fn set_api_key(&self, key: Option<String>) {
        *self.api_key.write() = key;
    }

    /// Get the effective API key (env var takes precedence)
    fn get_effective_api_key(&self) -> Option<String> {
        std::env::var("PEERINGDB_API_KEY")
            .ok()
            .or_else(|| self.api_key.read().clone())
    }

    /// Get the current cache status for display in settings
    pub fn get_cache_status(&self) -> CacheStatus {
        let prefix_count = self.prefixes.read().len();
        let loaded = self.load_once.get().is_some();
        let fetched_at = self.cache_fetched_at.load(Ordering::Relaxed);
        let refreshing = self.refreshing.load(Ordering::Relaxed);

        // Check if expired
        let expired = if fetched_at > 0 {
            let now = SystemTime::now()
                .duration_since(SystemTime::UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs();
            now - fetched_at > IxCache::MAX_AGE_SECS
        } else {
            false
        };

        CacheStatus {
            loaded,
            prefix_count,
            fetched_at: if fetched_at > 0 {
                Some(fetched_at)
            } else {
                None
            },
            expired,
            refreshing,
        }
    }

    /// Force refresh the cache from the API
    ///
    /// This spawns a background task to fetch fresh data from PeeringDB.
    /// The refreshing flag is set while the operation is in progress.
    pub fn refresh_cache(self: &Arc<Self>) {
        if self.refreshing.swap(true, Ordering::SeqCst) {
            // Already refreshing
            return;
        }

        let this = Arc::clone(self);
        tokio::spawn(async move {
            let result = this.refresh_cache_inner().await;
            this.refreshing.store(false, Ordering::SeqCst);
            if let Err(_e) = result {
                // Silent failure - IX detection is optional enrichment
                // Store failure time for backoff
                let now = SystemTime::now()
                    .duration_since(SystemTime::UNIX_EPOCH)
                    .unwrap_or_default()
                    .as_secs();
                this.last_failure.store(now, Ordering::Relaxed);
            }
        });
    }

    /// Inner refresh logic
    async fn refresh_cache_inner(&self) -> Result<()> {
        // Fetch fresh data from API
        let cache = self.fetch_from_api().await?;

        // Save to disk
        let _ = self.save_cache(&cache);

        // Populate prefixes
        self.populate_from_cache(&cache)?;

        // Update fetched_at timestamp
        self.cache_fetched_at
            .store(cache.fetched_at, Ordering::Relaxed);

        // Clear IP cache so new lookups use fresh data
        self.ip_cache.write().clear();
        self.ip_cache_times.write().clear();

        Ok(())
    }
}

/// Maximum concurrent IX lookups
const MAX_CONCURRENT_LOOKUPS: usize = 10;

/// Background IX lookup worker that updates session state
pub async fn run_ix_worker(
    ix_lookup: Arc<IxLookup>,
    sessions: SessionMap,
    cancel: CancellationToken,
) {
    let mut interval = tokio::time::interval(Duration::from_millis(500));

    loop {
        tokio::select! {
            _ = cancel.cancelled() => {
                break;
            }
            _ = interval.tick() => {
                // Collect IPs that need IX lookup from all sessions
                let ips_to_lookup: Vec<IpAddr> = {
                    let sessions = sessions.read();
                    sessions.values()
                        .flat_map(|state| {
                            let session = state.read();
                            session.hops.iter()
                                .flat_map(|hop| hop.responders.values())
                                .filter(|stats| stats.ix.is_none())
                                .map(|stats| stats.ip)
                                .collect::<Vec<_>>()
                        })
                        .collect()
                };

                if ips_to_lookup.is_empty() {
                    continue;
                }

                // Perform parallel IX lookups (limited batch size)
                let batch: Vec<IpAddr> = ips_to_lookup
                    .into_iter()
                    .take(MAX_CONCURRENT_LOOKUPS)
                    .collect();

                // Spawn concurrent lookups
                let futures: Vec<_> = batch
                    .iter()
                    .map(|&ip| {
                        let ix = ix_lookup.clone();
                        async move { (ip, ix.lookup(ip).await) }
                    })
                    .collect();

                // Wait for all lookups to complete
                let results = futures::future::join_all(futures).await;

                // Update all sessions with results
                let sessions = sessions.read();
                for (ip, ix_info) in results {
                    if let Some(ix_info) = ix_info {
                        for state in sessions.values() {
                            let mut session = state.write();
                            for hop in &mut session.hops {
                                if let Some(stats) = hop.responders.get_mut(&ip) {
                                    stats.ix = Some(ix_info.clone());
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

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

    /// Build a PrefixTable from (prefix, ix_name) pairs
    fn table_with(entries: &[(&str, &str)]) -> PrefixTable {
        let mut table = PrefixTable::new();
        for (prefix, name) in entries {
            table.insert(
                &prefix.parse().unwrap(),
                IxInfo {
                    name: name.to_string(),
                    city: None,
                    country: None,
                },
            );
        }
        table
    }

    /// Lookup an IP in a PrefixTable, returning the matched IX name
    fn lookup_name(table: &PrefixTable, ip: &str) -> Option<String> {
        table
            .lookup(ip.parse().unwrap())
            .map(|info| info.name.clone())
    }

    #[test]
    fn test_trie_ipv4_match() {
        let table = table_with(&[("206.223.115.0/24", "Equinix Ashburn")]);

        assert_eq!(
            lookup_name(&table, "206.223.115.100"),
            Some("Equinix Ashburn".to_string())
        );
        assert_eq!(lookup_name(&table, "206.223.116.100"), None);
        assert_eq!(lookup_name(&table, "8.8.8.8"), None);
    }

    #[test]
    fn test_trie_ipv6_match() {
        let table = table_with(&[("2001:7f8::/32", "DE-CIX Frankfurt")]);

        assert_eq!(
            lookup_name(&table, "2001:7f8::1"),
            Some("DE-CIX Frankfurt".to_string())
        );
        assert_eq!(
            lookup_name(&table, "2001:7f8:ffff::1"),
            Some("DE-CIX Frankfurt".to_string())
        );
        assert_eq!(lookup_name(&table, "2001:7f9::1"), None);
        assert_eq!(lookup_name(&table, "2606:4700::1"), None);
    }

    #[test]
    fn test_trie_family_separation() {
        // An IPv4 prefix must not match IPv6 addresses and vice versa
        let table = table_with(&[("10.0.0.0/8", "V4 IX"), ("2001:db8::/32", "V6 IX")]);

        assert_eq!(lookup_name(&table, "10.1.2.3"), Some("V4 IX".to_string()));
        assert_eq!(
            lookup_name(&table, "2001:db8::1"),
            Some("V6 IX".to_string())
        );
        // IPv4-mapped IPv6 address is an IPv6 lookup; should not hit the v4 trie
        assert_eq!(lookup_name(&table, "::ffff:10.1.2.3"), None);
    }

    #[test]
    fn test_trie_host_prefix_edge_cases() {
        // /32 and /128 prefixes match exactly one address
        let table = table_with(&[("192.0.2.1/32", "V4 Host"), ("2001:db8::1/128", "V6 Host")]);

        assert_eq!(
            lookup_name(&table, "192.0.2.1"),
            Some("V4 Host".to_string())
        );
        assert_eq!(lookup_name(&table, "192.0.2.2"), None);
        assert_eq!(
            lookup_name(&table, "2001:db8::1"),
            Some("V6 Host".to_string())
        );
        assert_eq!(lookup_name(&table, "2001:db8::2"), None);
    }

    #[test]
    fn test_trie_empty() {
        let table = PrefixTable::new();
        assert_eq!(table.len(), 0);
        assert_eq!(lookup_name(&table, "192.0.2.1"), None);
        assert_eq!(lookup_name(&table, "2001:db8::1"), None);
    }

    #[test]
    fn test_ix_cache_expiry() {
        let now = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap()
            .as_secs();

        // Fresh cache
        let fresh = IxCache {
            version: IxCache::VERSION,
            fetched_at: now,
            prefixes: vec![],
        };
        assert!(!fresh.is_expired());

        // Expired cache (25 hours old)
        let old = IxCache {
            version: IxCache::VERSION,
            fetched_at: now - 25 * 60 * 60,
            prefixes: vec![],
        };
        assert!(old.is_expired());
    }

    #[test]
    fn test_trie_longest_prefix_match() {
        // Nested prefixes: lookup must return the most specific match
        let table = table_with(&[
            ("10.0.0.0/8", "Wide"),
            ("10.0.0.0/24", "Narrow"),
            ("10.0.0.0/16", "Medium"),
        ]);
        assert_eq!(table.len(), 3);

        assert_eq!(lookup_name(&table, "10.0.0.50"), Some("Narrow".to_string()));
        assert_eq!(lookup_name(&table, "10.0.5.50"), Some("Medium".to_string()));
        assert_eq!(lookup_name(&table, "10.5.0.50"), Some("Wide".to_string()));
        assert_eq!(lookup_name(&table, "11.0.0.50"), None);
    }

    #[test]
    fn test_trie_overlapping_v6_prefixes() {
        let table = table_with(&[("2001:db8::/32", "Wide"), ("2001:db8:1::/48", "Narrow")]);

        assert_eq!(
            lookup_name(&table, "2001:db8:1::1"),
            Some("Narrow".to_string())
        );
        assert_eq!(
            lookup_name(&table, "2001:db8:2::1"),
            Some("Wide".to_string())
        );
    }

    #[test]
    fn test_backoff_period_check() {
        // Test that backoff period logic works correctly
        let now = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap()
            .as_secs();

        // Simulate a recent failure (should be in backoff)
        let recent_failure = now - 60; // 1 minute ago
        assert!(now - recent_failure < LOAD_FAILURE_BACKOFF_SECS);

        // Simulate an old failure (backoff should have expired)
        let old_failure = now - 400; // 6+ minutes ago
        assert!(now - old_failure >= LOAD_FAILURE_BACKOFF_SECS);
    }

    #[tokio::test]
    async fn test_lookup_returns_none_during_backoff() {
        // Create IxLookup with temp directory (no cache, will fail to load)
        let temp_dir = std::env::temp_dir().join(format!("ix_test_{}", std::process::id()));
        let _ = fs::create_dir_all(&temp_dir);
        let cache_path = temp_dir.join("ix_cache.json");

        let lookup = IxLookup {
            prefixes: RwLock::new(PrefixTable::new()),
            cache_path,
            load_once: OnceCell::new(),
            last_failure: AtomicU64::new(0),
            ip_cache: RwLock::new(HashMap::new()),
            ip_cache_ttl: Duration::from_secs(3600),
            ip_cache_times: RwLock::new(HashMap::new()),
            api_key: RwLock::new(None),
            cache_fetched_at: AtomicU64::new(0),
            refreshing: AtomicBool::new(false),
        };

        // Set last_failure to now (simulate recent failure)
        let now = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap()
            .as_secs();
        lookup.last_failure.store(now, Ordering::Relaxed);

        // Lookup should return None immediately without attempting load
        let ip = IpAddr::V4(Ipv4Addr::new(206, 223, 115, 100));
        let result = lookup.lookup(ip).await;
        assert!(result.is_none());

        // OnceCell should still be empty (no load attempted due to backoff)
        assert!(lookup.load_once.get().is_none());

        // Cleanup
        let _ = fs::remove_dir_all(&temp_dir);
    }

    #[tokio::test]
    async fn test_oncecell_empty_after_failure() {
        // Create IxLookup that will fail (no cache, API will fail in test env)
        let temp_dir = std::env::temp_dir().join(format!("ix_test_fail_{}", std::process::id()));
        let _ = fs::create_dir_all(&temp_dir);
        let cache_path = temp_dir.join("ix_cache.json");

        let lookup = IxLookup {
            prefixes: RwLock::new(PrefixTable::new()),
            cache_path: cache_path.clone(),
            load_once: OnceCell::new(),
            last_failure: AtomicU64::new(0),
            ip_cache: RwLock::new(HashMap::new()),
            ip_cache_ttl: Duration::from_secs(3600),
            ip_cache_times: RwLock::new(HashMap::new()),
            api_key: RwLock::new(None),
            cache_fetched_at: AtomicU64::new(0),
            refreshing: AtomicBool::new(false),
        };

        // No cache exists, API will timeout/fail - OnceCell should stay empty
        // We use get_or_try_init which doesn't fill on error

        // This will attempt to load and fail (no cache, no API in test)
        // But we can't easily test the API failure without mocking
        // Instead, verify the structure is correct for retry behavior

        // Verify OnceCell starts empty
        assert!(lookup.load_once.get().is_none());

        // Verify last_failure starts at 0
        assert_eq!(lookup.last_failure.load(Ordering::Relaxed), 0);

        // Cleanup
        let _ = fs::remove_dir_all(&temp_dir);
    }

    #[tokio::test]
    async fn test_lookup_with_preloaded_data() {
        // Test that lookup works correctly with pre-populated prefixes
        let temp_dir = std::env::temp_dir().join(format!("ix_test_pre_{}", std::process::id()));
        let _ = fs::create_dir_all(&temp_dir);
        let cache_path = temp_dir.join("ix_cache.json");

        let mut table = PrefixTable::new();
        table.insert(
            &"206.223.115.0/24".parse().unwrap(),
            IxInfo {
                name: "Test IX".to_string(),
                city: Some("Test City".to_string()),
                country: Some("US".to_string()),
            },
        );

        let lookup = IxLookup {
            prefixes: RwLock::new(table),
            cache_path,
            load_once: OnceCell::const_new_with(()), // Pre-filled = loaded
            last_failure: AtomicU64::new(0),
            ip_cache: RwLock::new(HashMap::new()),
            ip_cache_ttl: Duration::from_secs(3600),
            ip_cache_times: RwLock::new(HashMap::new()),
            api_key: RwLock::new(None),
            cache_fetched_at: AtomicU64::new(0),
            refreshing: AtomicBool::new(false),
        };

        // Lookup should find the pre-loaded prefix
        let ip = IpAddr::V4(Ipv4Addr::new(206, 223, 115, 100));
        let result = lookup.lookup(ip).await;
        assert!(result.is_some());
        let ix_info = result.unwrap();
        assert_eq!(ix_info.name, "Test IX");
        assert_eq!(ix_info.city, Some("Test City".to_string()));

        // Lookup for non-matching IP should return None
        let other_ip = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
        let result2 = lookup.lookup(other_ip).await;
        assert!(result2.is_none());

        // Cleanup
        let _ = fs::remove_dir_all(&temp_dir);
    }

    #[tokio::test]
    async fn test_ip_cache_prevents_repeated_prefix_search() {
        let temp_dir = std::env::temp_dir().join(format!("ix_test_cache_{}", std::process::id()));
        let _ = fs::create_dir_all(&temp_dir);
        let cache_path = temp_dir.join("ix_cache.json");

        let lookup = IxLookup {
            prefixes: RwLock::new(table_with(&[("206.223.115.0/24", "Cached IX")])),
            cache_path,
            load_once: OnceCell::const_new_with(()),
            last_failure: AtomicU64::new(0),
            ip_cache: RwLock::new(HashMap::new()),
            ip_cache_ttl: Duration::from_secs(3600),
            ip_cache_times: RwLock::new(HashMap::new()),
            api_key: RwLock::new(None),
            cache_fetched_at: AtomicU64::new(0),
            refreshing: AtomicBool::new(false),
        };

        let ip = IpAddr::V4(Ipv4Addr::new(206, 223, 115, 50));

        // First lookup populates IP cache
        let result1 = lookup.lookup(ip).await;
        assert!(result1.is_some());

        // Verify IP is now in cache
        assert!(lookup.ip_cache.read().contains_key(&ip));

        // Second lookup should use cached result
        let result2 = lookup.lookup(ip).await;
        assert_eq!(result1, result2);

        // Cleanup
        let _ = fs::remove_dir_all(&temp_dir);
    }
}
