//! Background telemetry collection service.
//!
//! This module handles asynchronous collection of network telemetry data
//! including public IP address, ISP information, latency measurements,
//! DNS configuration, and IPv6 leak detection.
//!
//! The telemetry worker runs in a background thread and communicates
//! updates via an MPSC channel to the main application.

use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::Duration;

use crate::constants;
use crate::logger::LogLevel;
use serde::Deserialize;

/// Configuration subset needed by the telemetry worker thread.
#[derive(Debug, Clone)]
pub struct TelemetryConfig {
    /// Telemetry polling interval.
    pub poll_rate: Duration,
    /// HTTP API timeout in seconds.
    pub api_timeout: u64,
    /// Ping command timeout in seconds.
    pub ping_timeout: u64,
    /// Ping targets for latency measurement.
    pub ping_targets: Vec<String>,
    /// IPv6 leak detection endpoints.
    pub ipv6_check_apis: Vec<String>,
    /// Primary API endpoint for IP lookup.
    pub ip_api_primary: String,
    /// Fallback API endpoints for IP lookup.
    pub ip_api_fallbacks: Vec<String>,
}

impl From<&crate::config::AppConfig> for TelemetryConfig {
    fn from(config: &crate::config::AppConfig) -> Self {
        Self {
            poll_rate: Duration::from_secs(config.telemetry_poll_rate),
            api_timeout: config.api_timeout,
            ping_timeout: config.ping_timeout,
            ping_targets: config.ping_targets.clone(),
            ipv6_check_apis: config.ipv6_check_apis.clone(),
            ip_api_primary: config.ip_api_primary.clone(),
            ip_api_fallbacks: config.ip_api_fallbacks.clone(),
        }
    }
}

/// Telemetry update messages sent from background workers to the main application.
#[derive(Debug, Clone)]
pub enum TelemetryUpdate {
    /// Updated public IP address.
    PublicIp(String),
    /// Updated latency measurement in milliseconds.
    Latency(u64),
    /// Packet loss percentage (0.0-100.0).
    PacketLoss(f32),
    /// Jitter (latency standard deviation) in milliseconds.
    Jitter(u64),
    /// Updated ISP/organization name.
    Isp(String),
    /// Updated DNS server address.
    Dns(String),
    /// Updated physical location (City, Country).
    Location(String),
    /// Public IPv6 observed by the probe, `None` if unreachable.
    PublicIpv6(Option<String>),
    DnsLeak(crate::core::dns_leak::DnsLeakStatus),
    /// Log message with level for production logging (uses centralized logger)
    Log(LogLevel, String),
}

/// Spawns a background telemetry worker that periodically fetches network information.
///
/// # Returns
///
/// A tuple of:
/// - `Receiver<TelemetryUpdate>` — yields telemetry data as it arrives
/// - `Sender<()>` — send on this to trigger an immediate refresh (e.g. after connect/disconnect)
///
/// # Panics
///
/// This function does not panic. All errors in background threads are silently handled.
#[must_use]
pub fn spawn_telemetry_worker(config: TelemetryConfig) -> (Receiver<TelemetryUpdate>, Sender<()>) {
    let (tx, rx) = mpsc::channel();
    let (nudge_tx, nudge_rx) = mpsc::channel::<()>();
    let config = std::sync::Arc::new(config);

    thread::spawn(move || loop {
        fetch_ip_and_isp(&tx, &config);
        fetch_latency(&tx, &config);
        fetch_security_info(&tx, &config);

        // Wait for the poll interval, but wake up immediately if nudged.
        // Drain any extra nudges that accumulated while we were fetching.
        let _ = nudge_rx.recv_timeout(config.poll_rate);
        while nudge_rx.try_recv().is_ok() {}
    });

    (rx, nudge_tx)
}

/// Fetches public IP address and ISP information with fallback APIs.
fn fetch_ip_and_isp(tx: &Sender<TelemetryUpdate>, cfg: &std::sync::Arc<TelemetryConfig>) {
    let tx_clone = tx.clone();
    let cfg = std::sync::Arc::clone(cfg);
    thread::spawn(move || {
        // Log start of fetch
        let _ = tx_clone.send(TelemetryUpdate::Log(
            LogLevel::Debug,
            "Starting IP/Location fetch...".to_string(),
        ));

        // Primary: ipinfo.io (provides IP + ISP + Location)
        let _ = tx_clone.send(TelemetryUpdate::Log(
            LogLevel::Debug,
            "Trying ipinfo.io (primary API with location data)...".to_string(),
        ));

        if let Some((ip, isp, loc)) = try_ipinfo_api(&tx_clone, &cfg) {
            let _ = tx_clone.send(TelemetryUpdate::Log(
                LogLevel::Info,
                format!(
                    "✓ ipinfo.io: IP={}, ISP={}, Location={}",
                    ip,
                    isp.as_ref().unwrap_or(&"Unknown".to_string()),
                    loc.as_ref().unwrap_or(&"Unknown".to_string())
                ),
            ));
            let _ = tx_clone.send(TelemetryUpdate::PublicIp(ip));
            if let Some(org) = isp {
                let _ = tx_clone.send(TelemetryUpdate::Isp(org));
            }
            if let Some(location) = loc {
                let _ = tx_clone.send(TelemetryUpdate::Location(location));
            }
            return;
        }

        let _ = tx_clone.send(TelemetryUpdate::Log(
            LogLevel::Warning,
            "ipinfo.io failed, trying fallback APIs (no location data)...".to_string(),
        ));

        // Fallback 1: ipify.org (IP only, very reliable)
        let _ = tx_clone.send(TelemetryUpdate::Log(
            LogLevel::Debug,
            "Trying ipify.org (fallback 1, IP only)...".to_string(),
        ));

        if let Some(ip) = try_ipify_api(&tx_clone, &cfg) {
            let _ = tx_clone.send(TelemetryUpdate::Log(
                LogLevel::Info,
                format!("✓ ipify.org: IP={ip} (no ISP/location)"),
            ));
            let _ = tx_clone.send(TelemetryUpdate::PublicIp(ip));
            let _ = tx_clone.send(TelemetryUpdate::Isp("Unknown".to_string()));
            let _ = tx_clone.send(TelemetryUpdate::Location("Unknown".to_string()));
            return;
        }

        let _ = tx_clone.send(TelemetryUpdate::Log(
            LogLevel::Warning,
            "ipify.org failed, trying icanhazip.com...".to_string(),
        ));

        // Fallback 2: icanhazip.com (IP only)
        let _ = tx_clone.send(TelemetryUpdate::Log(
            LogLevel::Debug,
            "Trying icanhazip.com (fallback 2)...".to_string(),
        ));

        if let Some(ip) = try_icanhazip_api(&tx_clone, &cfg) {
            let _ = tx_clone.send(TelemetryUpdate::Log(
                LogLevel::Info,
                format!("✓ icanhazip.com: IP={ip}"),
            ));
            let _ = tx_clone.send(TelemetryUpdate::PublicIp(ip));
            let _ = tx_clone.send(TelemetryUpdate::Isp("Unknown".to_string()));
            let _ = tx_clone.send(TelemetryUpdate::Location("Unknown".to_string()));
            return;
        }

        let _ = tx_clone.send(TelemetryUpdate::Log(
            LogLevel::Warning,
            "icanhazip.com failed, trying ifconfig.me (last resort)...".to_string(),
        ));

        // Fallback 3: ifconfig.me (IP only)
        if let Some(ip) = try_ifconfig_api(&tx_clone, &cfg) {
            let _ = tx_clone.send(TelemetryUpdate::Log(
                LogLevel::Info,
                format!("✓ ifconfig.me: IP={ip}"),
            ));
            let _ = tx_clone.send(TelemetryUpdate::PublicIp(ip));
            let _ = tx_clone.send(TelemetryUpdate::Isp("Unknown".to_string()));
            let _ = tx_clone.send(TelemetryUpdate::Location("Unknown".to_string()));
            return;
        }

        // All APIs failed - report error
        let _ = tx_clone.send(TelemetryUpdate::Log(
            LogLevel::Error,
            "✗ ALL IP APIs FAILED! Check: 1) Network 2) curl installed 3) VPN routing 4) Firewall"
                .to_string(),
        ));
        let _ = tx_clone.send(TelemetryUpdate::PublicIp("Unavailable".to_string()));
    });
}

/// Try ipinfo.io API (returns IP and optionally ISP + Location) with retry
fn try_ipinfo_api(
    tx: &Sender<TelemetryUpdate>,
    cfg: &TelemetryConfig,
) -> Option<(String, Option<String>, Option<String>)> {
    let timeout = Duration::from_secs(cfg.api_timeout);

    for attempt in 0..constants::RETRY_ATTEMPTS {
        let Some(text) = crate::core::telemetry_http::get_text(&cfg.ip_api_primary, timeout) else {
            let _ = tx.send(TelemetryUpdate::Log(
                LogLevel::Debug,
                format!(
                    "ipinfo.io attempt {}: HTTP request failed (timeout / non-2xx / network)",
                    attempt + 1
                ),
            ));
            if attempt == 0 {
                thread::sleep(Duration::from_millis(constants::RETRY_DELAY_MS));
            }
            continue;
        };

        let _ = tx.send(TelemetryUpdate::Log(
            LogLevel::Debug,
            format!(
                "ipinfo.io attempt {}: received {} bytes",
                attempt + 1,
                text.len()
            ),
        ));

        if let Some(result) = parse_ip_api_response(&text) {
            return Some(result);
        }
        let _ = tx.send(TelemetryUpdate::Log(
            LogLevel::Warning,
            format!("ipinfo.io attempt {}: failed to parse JSON", attempt + 1),
        ));

        if attempt == 0 {
            thread::sleep(Duration::from_millis(constants::RETRY_DELAY_MS));
        }
    }

    let _ = tx.send(TelemetryUpdate::Log(
        LogLevel::Warning,
        format!(
            "ipinfo.io: all {} attempts exhausted",
            constants::RETRY_ATTEMPTS
        ),
    ));
    None
}

/// Validates if a string is a valid IPv4 address
fn is_valid_ipv4(ip: &str) -> bool {
    let parts: Vec<&str> = ip.split('.').collect();
    if parts.len() != 4 {
        return false;
    }

    // Verify each octet is a valid u8 (0-255) and doesn't have leading zeros
    parts.iter().all(|part| {
        // Reject empty parts or parts with leading zeros (except "0" itself)
        if part.is_empty() || (part.len() > 1 && part.starts_with('0')) {
            return false;
        }
        part.parse::<u8>().is_ok()
    })
}

/// Try ipify.org API (IP only, very reliable) with retry
fn try_ipify_api(tx: &Sender<TelemetryUpdate>, cfg: &TelemetryConfig) -> Option<String> {
    let timeout = Duration::from_secs(cfg.api_timeout);
    let url = cfg
        .ip_api_fallbacks
        .first()
        .map_or("https://api.ipify.org", String::as_str);

    for attempt in 0..constants::RETRY_ATTEMPTS {
        match crate::core::telemetry_http::get_text(url, timeout) {
            Some(body) => {
                let ip = body.trim().to_string();
                if !ip.is_empty() && is_valid_ipv4(&ip) {
                    return Some(ip);
                }
                let _ = tx.send(TelemetryUpdate::Log(
                    LogLevel::Warning,
                    format!(
                        "ipify.org attempt {}: invalid IP format: '{}'",
                        attempt + 1,
                        ip
                    ),
                ));
            }
            None => {
                let _ = tx.send(TelemetryUpdate::Log(
                    LogLevel::Debug,
                    format!("ipify.org attempt {}: HTTP error", attempt + 1),
                ));
            }
        }

        if attempt == 0 {
            thread::sleep(Duration::from_millis(constants::RETRY_DELAY_MS));
        }
    }

    let _ = tx.send(TelemetryUpdate::Log(
        LogLevel::Warning,
        "ipify.org: all attempts failed".to_string(),
    ));
    None
}

/// Try icanhazip.com API (IP only) with retry
fn try_icanhazip_api(tx: &Sender<TelemetryUpdate>, cfg: &TelemetryConfig) -> Option<String> {
    let timeout = Duration::from_secs(cfg.api_timeout);
    let url = cfg
        .ip_api_fallbacks
        .get(1)
        .map_or("https://icanhazip.com", String::as_str);

    for attempt in 0..constants::RETRY_ATTEMPTS {
        if let Some(body) = crate::core::telemetry_http::get_text(url, timeout) {
            let ip = body.trim().to_string();
            if !ip.is_empty() {
                return Some(ip);
            }
        } else {
            let _ = tx.send(TelemetryUpdate::Log(
                LogLevel::Error,
                "icanhazip.com: HTTP request failed".to_string(),
            ));
        }

        if attempt == 0 {
            thread::sleep(Duration::from_millis(constants::RETRY_DELAY_MS));
        }
    }
    None
}

/// Try ifconfig.me API (IP only) with retry
fn try_ifconfig_api(tx: &Sender<TelemetryUpdate>, cfg: &TelemetryConfig) -> Option<String> {
    let timeout = Duration::from_secs(cfg.api_timeout);
    let url = cfg
        .ip_api_fallbacks
        .get(2)
        .map_or("https://ifconfig.me/ip", String::as_str);

    for attempt in 0..constants::RETRY_ATTEMPTS {
        if let Some(body) = crate::core::telemetry_http::get_text(url, timeout) {
            let ip = body.trim().to_string();
            if !ip.is_empty() {
                return Some(ip);
            }
        } else {
            let _ = tx.send(TelemetryUpdate::Log(
                LogLevel::Error,
                "ifconfig.me: HTTP request failed".to_string(),
            ));
        }

        if attempt == 0 {
            thread::sleep(Duration::from_millis(constants::RETRY_DELAY_MS));
        }
    }
    None
}

/// IP API response structure (supports both ipinfo.io and ip-api.com formats)
#[derive(Debug, Deserialize)]
struct IpApiResponse {
    #[serde(alias = "query")] // ip-api.com uses "query"
    ip: Option<String>,
    isp: Option<String>,
    org: Option<String>,
    city: Option<String>,
    country: Option<String>,
    status: Option<String>,
}

/// Parse IP API JSON response using proper JSON deserialization
/// This replaces the unsafe string-matching approach with proper parsing
/// that handles escaped quotes, unicode, and nested JSON correctly.
///
/// # Safety Benefits
/// - Handles escaped quotes: `"org": "Company \"Premium\" Networks"`
/// - Handles unicode: `"city": "São Paulo"` or `"city": "S\u00e3o Paulo"`
/// - Validates JSON structure
/// - Fails gracefully on malformed JSON
fn parse_ip_api_response(json: &str) -> Option<(String, Option<String>, Option<String>)> {
    let response: IpApiResponse = serde_json::from_str(json).ok()?;

    // Check if API returned success (ip-api.com includes status field)
    if let Some(status) = &response.status {
        if status != "success" {
            return None;
        }
    }

    let ip = response.ip?;

    // Validate that the returned IP is a valid IPv4 address
    if !is_valid_ipv4(&ip) {
        return None;
    }

    // Prefer "org" over "isp" as it's usually more specific (ipinfo.io uses "org")
    let isp = response.org.or(response.isp);

    // Build location string from city and country
    let location = match (response.city, response.country) {
        (Some(city), Some(country)) => Some(format!("{city}, {country}")),
        (Some(city), None) => Some(city),
        (None, Some(country)) => Some(country),
        (None, None) => None,
    };

    Some((ip, isp, location))
}

/// Legacy function kept for backward compatibility with tests
/// DEPRECATED: Use `parse_ip_api_response` instead
#[cfg(test)]
fn extract_json_string(json: &str, key: &str) -> Option<String> {
    // Use proper JSON parsing now
    let value: serde_json::Value = serde_json::from_str(json).ok()?;
    value.get(key)?.as_str().map(String::from)
}

/// Parsed ping output statistics.
#[derive(Debug, Default, PartialEq)]
pub struct PingStats {
    pub latency_ms: u64,
    pub packet_loss: f32,
    pub jitter_ms: u64,
}

/// Parse ping command output to extract latency, packet loss, and jitter.
///
/// Handles both macOS and Linux output formats:
/// - macOS: "round-trip min/avg/max/stddev = 1.234/5.678/9.012/3.456 ms"
/// - Linux: "rtt min/avg/max/mdev = 1.234/5.678/9.012/3.456 ms"
/// - macOS loss: "10 packets transmitted, 8 packets received, 20.0% packet loss"
/// - Linux loss: "10 packets transmitted, 8 received, 20% packet loss, time 9001ms"
#[must_use]
pub fn parse_ping_output(output: &str) -> PingStats {
    let mut stats = PingStats::default();

    for line in output.lines() {
        if line.contains("packet loss") {
            if let Some(loss_idx) = line.find("% packet loss") {
                let before_loss = &line[..loss_idx];
                if let Some(percent_str) = before_loss
                    .split([',', ' '])
                    .filter(|s| !s.is_empty())
                    .rfind(|s| s.chars().all(|c| c.is_ascii_digit() || c == '.'))
                {
                    if let Ok(val) = percent_str.parse::<f32>() {
                        stats.packet_loss = val;
                    }
                }
            }
        }

        // Handle both "min/avg/max/stddev" (Linux mdev) and "round-trip min/avg/max/stddev" (macOS)
        if line.contains("min/avg/max") {
            if let Some(eq_pos) = line.find('=') {
                let values_str = &line[eq_pos + 1..].trim();
                let values: Vec<&str> = values_str.split('/').collect();
                if values.len() >= 4 {
                    if let Ok(avg) = values[1].trim().parse::<f64>() {
                        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
                        {
                            stats.latency_ms = avg.max(0.0) as u64;
                        }
                    }
                    let stddev_str = values[3].trim_end_matches(" ms").trim();
                    if let Ok(stddev) = stddev_str.parse::<f64>() {
                        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
                        {
                            stats.jitter_ms = stddev.max(0.0) as u64;
                        }
                    }
                }
            }
        }
    }

    stats
}

// Note: `parse_proc_net_dev` and `parse_ip_addr_output` moved to
// `vortix-platform-linux::network_stats` and `vortix-platform-linux::interface`
// respectively as part of plan 003 U2.

/// Measures network latency, packet loss, and jitter by pinging reliable hosts.
///
/// Plan 002 U10: replaced the `ping -c 3 -i 0.2 -W <timeout>` shell-out
/// with `core::icmp::measure_latency`. Same outputs (`latency_ms`,
/// `packet_loss` %, `jitter_ms`); same retry-across-targets behavior;
/// same `Latency(0) + PacketLoss(100) + Jitter(0)` final message when
/// every target fails.
fn fetch_latency(tx: &Sender<TelemetryUpdate>, cfg: &std::sync::Arc<TelemetryConfig>) {
    // 3 probes, matching the prior `ping -c 3 -i 0.2` cadence.
    const PROBES_PER_TARGET: u32 = 3;

    let tx_clone = tx.clone();
    let cfg = std::sync::Arc::clone(cfg);
    thread::spawn(move || {
        let per_attempt_timeout = Duration::from_secs(cfg.ping_timeout);

        for target in &cfg.ping_targets {
            for attempt in 0..constants::RETRY_ATTEMPTS {
                if let Some(stats) = crate::core::icmp::measure_latency(
                    target,
                    PROBES_PER_TARGET,
                    per_attempt_timeout,
                ) {
                    if stats.latency_ms > 0 {
                        let _ = tx_clone.send(TelemetryUpdate::Latency(stats.latency_ms));
                        let _ = tx_clone.send(TelemetryUpdate::PacketLoss(stats.packet_loss));
                        let _ = tx_clone.send(TelemetryUpdate::Jitter(stats.jitter_ms));
                        return;
                    }
                }

                if attempt == 0 {
                    thread::sleep(Duration::from_millis(constants::RETRY_DELAY_MS));
                }
            }
        }

        let _ = tx_clone.send(TelemetryUpdate::Latency(0));
        let _ = tx_clone.send(TelemetryUpdate::PacketLoss(100.0));
        let _ = tx_clone.send(TelemetryUpdate::Jitter(0));
    });
}

/// Fetches DNS configuration and probes the current public IPv6.
fn fetch_security_info(tx: &Sender<TelemetryUpdate>, cfg: &std::sync::Arc<TelemetryConfig>) {
    let tx_clone = tx.clone();
    let cfg = std::sync::Arc::clone(cfg);
    thread::spawn(move || {
        let dns = crate::platform::current_platform().dns.get_dns_server();
        if let Some(dns_server) = dns {
            let _ = tx_clone.send(TelemetryUpdate::Dns(dns_server));
        }

        let ipv6_timeout = Duration::from_secs(cfg.api_timeout);
        let mut public_v6: Option<String> = None;
        for endpoint in &cfg.ipv6_check_apis {
            if let Some(ip) = crate::core::telemetry_http::probe_ipv6(endpoint, ipv6_timeout) {
                public_v6 = Some(ip);
                break;
            }
        }
        let _ = tx_clone.send(TelemetryUpdate::PublicIpv6(public_v6));
    });
}

// Network stats delta calculation is now handled directly in App::poll_network_stats()
// using last_bytes_in / last_bytes_out fields on the App struct.

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

    #[test]
    fn test_extract_json_string_ip() {
        let json = r#"{"ip": "1.2.3.4", "org": "Test ISP"}"#;
        assert_eq!(extract_json_string(json, "ip"), Some("1.2.3.4".to_string()));
    }

    #[test]
    fn test_extract_json_string_org() {
        let json = r#"{"ip": "1.2.3.4", "org": "AS12345 Test Company"}"#;
        assert_eq!(
            extract_json_string(json, "org"),
            Some("AS12345 Test Company".to_string())
        );
    }

    #[test]
    fn test_extract_json_string_missing_key() {
        let json = r#"{"ip": "1.2.3.4"}"#;
        assert_eq!(extract_json_string(json, "org"), None);
    }

    #[test]
    fn test_extract_json_string_with_whitespace() {
        let json = r#"{"ip":   "1.2.3.4"}"#;
        assert_eq!(extract_json_string(json, "ip"), Some("1.2.3.4".to_string()));
    }

    #[test]
    fn test_extract_json_string_empty() {
        let json = r"{}";
        assert_eq!(extract_json_string(json, "ip"), None);
    }

    #[test]
    fn test_is_valid_ipv4_valid() {
        assert!(is_valid_ipv4("1.2.3.4"));
        assert!(is_valid_ipv4("192.168.1.1"));
        assert!(is_valid_ipv4("0.0.0.0"));
        assert!(is_valid_ipv4("255.255.255.255"));
    }

    #[test]
    fn test_is_valid_ipv4_invalid() {
        assert!(!is_valid_ipv4("999.999.999.999"));
        assert!(!is_valid_ipv4("256.1.1.1"));
        assert!(!is_valid_ipv4("1.2.3"));
        assert!(!is_valid_ipv4("1.2.3.4.5"));
        assert!(!is_valid_ipv4("not.an.ip.address"));
        assert!(!is_valid_ipv4(""));
    }

    // === Ping output parsing tests ===

    #[test]
    fn test_parse_ping_output_macos() {
        let output = "\
PING 1.1.1.1 (1.1.1.1): 56 data bytes
64 bytes from 1.1.1.1: icmp_seq=0 ttl=57 time=1.234 ms
64 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=5.678 ms

--- 1.1.1.1 ping statistics ---
10 packets transmitted, 10 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 1.234/5.678/9.012/3.456 ms";

        let stats = parse_ping_output(output);
        assert_eq!(stats.latency_ms, 5); // avg 5.678 truncated to u64
        assert!((stats.packet_loss - 0.0).abs() < f32::EPSILON);
        assert_eq!(stats.jitter_ms, 3); // stddev 3.456 truncated to u64
    }

    #[test]
    fn test_parse_ping_output_linux() {
        let output = "\
PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
64 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=1.23 ms
64 bytes from 1.1.1.1: icmp_seq=2 ttl=57 time=5.67 ms

--- 1.1.1.1 ping statistics ---
10 packets transmitted, 8 received, 20% packet loss, time 9001ms
rtt min/avg/max/mdev = 1.234/5.678/9.012/3.456 ms";

        let stats = parse_ping_output(output);
        assert_eq!(stats.latency_ms, 5);
        assert!((stats.packet_loss - 20.0).abs() < f32::EPSILON);
        assert_eq!(stats.jitter_ms, 3);
    }

    #[test]
    fn test_parse_ping_output_100_percent_loss() {
        let output = "\
--- 1.1.1.1 ping statistics ---
10 packets transmitted, 0 packets received, 100.0% packet loss";

        let stats = parse_ping_output(output);
        assert_eq!(stats.latency_ms, 0);
        assert!((stats.packet_loss - 100.0).abs() < f32::EPSILON);
    }

    #[test]
    fn test_parse_ping_output_empty() {
        let stats = parse_ping_output("");
        assert_eq!(stats, PingStats::default());
    }

    // /proc/net/dev and `ip addr` parsing tests moved to
    // `vortix-platform-linux::{network_stats, interface}` along with the
    // parsers themselves (plan 003 U2).

    // === DNS parsing tests ===

    #[test]
    fn test_parse_ip_api_response_full() {
        let json =
            r#"{"ip": "1.2.3.4", "org": "AS12345 Test ISP", "city": "Berlin", "country": "DE"}"#;
        let result = parse_ip_api_response(json);
        assert!(result.is_some());
        let (ip, isp, location) = result.unwrap();
        assert_eq!(ip, "1.2.3.4");
        assert_eq!(isp, Some("AS12345 Test ISP".to_string()));
        assert_eq!(location, Some("Berlin, DE".to_string()));
    }

    #[test]
    fn test_parse_ip_api_response_ip_only() {
        let json = r#"{"ip": "8.8.8.8"}"#;
        let result = parse_ip_api_response(json);
        assert!(result.is_some());
        let (ip, isp, location) = result.unwrap();
        assert_eq!(ip, "8.8.8.8");
        assert!(isp.is_none());
        assert!(location.is_none());
    }

    #[test]
    fn test_parse_ip_api_response_invalid() {
        assert!(parse_ip_api_response("not json").is_none());
        assert!(parse_ip_api_response("{}").is_none());
        assert!(parse_ip_api_response(r#"{"ip": "not_an_ip"}"#).is_none());
    }

    // === TelemetryConfig conversion ===

    #[test]
    fn test_telemetry_config_from_app_config() {
        let app_cfg = crate::config::AppConfig {
            tick_rate: 500, // not used by TelemetryConfig
            telemetry_poll_rate: 45,
            api_timeout: 8,
            ping_timeout: 3,
            connect_timeout: 30, // not used by TelemetryConfig
            ping_targets: vec!["4.4.4.4".to_string()],
            ipv6_check_apis: vec!["https://v6.example.com".to_string()],
            ip_api_primary: "https://custom.api/json".to_string(),
            ip_api_fallbacks: vec![
                "https://fb1.example.com".to_string(),
                "https://fb2.example.com".to_string(),
            ],
            max_log_entries: 1000,
            log_level: "info".to_string(),
            log_rotation_size: 5 * 1024 * 1024,
            log_retention_days: 7,
            disconnect_timeout: 30,
            openvpn_verbosity: "3".to_string(),
            connect_max_retries: 3,
            connect_retry_base_delay_secs: 2,
            connect_retry_max_delay_secs: 300,
            auto_reconnect: true,
            auto_reconnect_delay_secs: 3,
        };

        let tel_cfg = TelemetryConfig::from(&app_cfg);

        assert_eq!(tel_cfg.poll_rate, Duration::from_secs(45));
        assert_eq!(tel_cfg.api_timeout, 8);
        assert_eq!(tel_cfg.ping_timeout, 3);
        assert_eq!(tel_cfg.ping_targets, vec!["4.4.4.4"]);
        assert_eq!(tel_cfg.ipv6_check_apis, vec!["https://v6.example.com"]);
        assert_eq!(tel_cfg.ip_api_primary, "https://custom.api/json");
        assert_eq!(tel_cfg.ip_api_fallbacks.len(), 2);
        assert_eq!(tel_cfg.ip_api_fallbacks[0], "https://fb1.example.com");
        assert_eq!(tel_cfg.ip_api_fallbacks[1], "https://fb2.example.com");
    }

    #[test]
    fn test_telemetry_config_from_defaults() {
        let defaults = crate::config::AppConfig::default();
        let tel_cfg = TelemetryConfig::from(&defaults);

        assert_eq!(tel_cfg.poll_rate, Duration::from_secs(30));
        assert_eq!(tel_cfg.api_timeout, 5);
        assert_eq!(tel_cfg.ping_timeout, 2);
        assert_eq!(tel_cfg.ping_targets.len(), 4);
        assert_eq!(tel_cfg.ipv6_check_apis.len(), 3);
        assert_eq!(tel_cfg.ip_api_fallbacks.len(), 3);
    }
}
