//! Cross-distribution identity extraction for Linux systems.
//!
//! Provides machine-id, FQDN, IP address, and OS-detail collection
//! compatible with Debian/Ubuntu, RHEL/CentOS/Fedora, Alpine, and Arch Linux.

use anyhow::{anyhow, Context, Result};
use std::fs;
use std::net::{IpAddr, Ipv4Addr};
use std::process::Command;

/// Read the D-Bus machine identifier from `/etc/machine-id`.
/// Falls back to `/var/lib/dbus/machine-id` on older systems.
pub fn get_machine_id() -> Result<String> {
    let primary = "/etc/machine-id";
    let fallback = "/var/lib/dbus/machine-id";

    if let Ok(id) = fs::read_to_string(primary) {
        let trimmed = id.trim().to_string();
        if !trimmed.is_empty() {
            return Ok(trimmed);
        }
    }

    let id = fs::read_to_string(fallback)
        .with_context(|| format!("Failed to read machine-id from {} or {}", primary, fallback))?;
    let trimmed = id.trim().to_string();
    if trimmed.is_empty() {
        return Err(anyhow!("machine-id file is empty"));
    }
    Ok(trimmed)
}

/// Resolve the fully-qualified domain name.
///
/// Strategy (in priority order):
/// 1. `hostname -f` → if result contains `.`, it's a real FQDN
/// 2. `hostname` + `hostname -d` → combine short hostname + domain
/// 3. `/etc/hostname` → short hostname fallback
/// 4. `hostname` command → last resort
/// 5. `"localhost"` → final fallback
pub fn get_fqdn() -> Result<String> {
    // 1. Try `hostname -f` — returns FQDN on properly configured systems
    if let Ok(output) = Command::new("hostname").arg("-f").output() {
        if output.status.success() {
            let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
            if !name.is_empty() && name.contains('.') && name != "(none)" {
                tracing::debug!(fqdn = %name, "Resolved FQDN via hostname -f");
                return Ok(name);
            }
        }
    }

    // 2. Try combining short hostname + domain from `hostname -d`
    if let Ok(short_output) = Command::new("hostname").output() {
        if short_output.status.success() {
            let short = String::from_utf8_lossy(&short_output.stdout)
                .trim()
                .to_string();
            if !short.is_empty() && short != "(none)" {
                if let Ok(domain_output) = Command::new("hostname").arg("-d").output() {
                    if domain_output.status.success() {
                        let domain = String::from_utf8_lossy(&domain_output.stdout)
                            .trim()
                            .to_string();
                        if !domain.is_empty() {
                            let fqdn = format!("{}.{}", short, domain);
                            tracing::debug!(fqdn = %fqdn, "Resolved FQDN via hostname + hostname -d");
                            return Ok(fqdn);
                        }
                    }
                }
                // Domain not available — fall through to try other methods
            }
        }
    }

    // 3. Try reading from /etc/hostname (common on systemd systems, usually short hostname)
    if let Ok(name) = fs::read_to_string("/etc/hostname") {
        let trimmed = name.trim().to_string();
        if !trimmed.is_empty() && trimmed != "(none)" {
            tracing::debug!(hostname = %trimmed, "Resolved hostname via /etc/hostname (may be short)");
            return Ok(trimmed);
        }
    }

    // 4. Fallback to plain hostname command
    if let Ok(output) = Command::new("hostname").output() {
        if output.status.success() {
            let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
            if !name.is_empty() {
                tracing::debug!(hostname = %name, "Resolved hostname via hostname command");
                return Ok(name);
            }
        }
    }

    // 5. Final fallback
    tracing::warn!("Could not determine hostname — falling back to localhost");
    Ok("localhost".into())
}

/// Resolve the short hostname (without domain).
///
/// Strategy: `/etc/hostname` → `hostname` command → split FQDN on `.` → `"localhost"`.
pub fn get_hostname() -> Result<String> {
    // Try reading from /etc/hostname (usually contains the short hostname)
    if let Ok(name) = fs::read_to_string("/etc/hostname") {
        let trimmed = name.trim().to_string();
        if !trimmed.is_empty() && trimmed != "(none)" {
            // If it contains a dot, take just the first component
            let short = trimmed.split('.').next().unwrap_or(&trimmed).to_string();
            tracing::debug!(hostname = %short, "Resolved short hostname via /etc/hostname");
            return Ok(short);
        }
    }

    // Try hostname command
    if let Ok(output) = Command::new("hostname").output() {
        if output.status.success() {
            let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
            if !name.is_empty() {
                // If it contains a dot, take just the first component
                let short = name.split('.').next().unwrap_or(&name).to_string();
                tracing::debug!(hostname = %short, "Resolved short hostname via hostname command");
                return Ok(short);
            }
        }
    }

    // Try splitting FQDN from get_fqdn()
    if let Ok(fqdn) = get_fqdn() {
        if fqdn != "localhost" && fqdn.contains('.') {
            let short = fqdn.split('.').next().unwrap_or(&fqdn).to_string();
            tracing::debug!(hostname = %short, "Resolved short hostname by splitting FQDN");
            return Ok(short);
        }
    }

    // Final fallback
    tracing::warn!("Could not determine short hostname — falling back to localhost");
    Ok("localhost".into())
}

/// Collect all non-loopback IPv4 addresses from network interfaces.
///
/// Filters out container bridge subnets (Docker 172.16.0.0/12) and
/// link-local addresses (169.254.0.0/16) that are not routable from the manager.
pub fn get_ip_addresses() -> Result<Vec<String>> {
    let ifaces = if_addrs::get_if_addrs().context("Failed to enumerate network interfaces")?;

    let mut addrs: Vec<String> = ifaces
        .iter()
        .filter_map(|iface| {
            if iface.is_loopback() {
                return None;
            }
            match &iface.ip() {
                IpAddr::V4(addr) => {
                    // Filter container bridge and link-local subnets
                    if is_container_bridge(addr) || is_link_local(addr) {
                        tracing::debug!(
                            ip = %addr,
                            "Excluding container bridge or link-local IP from enrollment report"
                        );
                        return None;
                    }
                    Some(addr.to_string())
                }
                IpAddr::V6(_) => None,
            }
        })
        .collect();

    addrs.sort();
    addrs.dedup();
    Ok(addrs)
}

/// Check if an IPv4 address is in a container bridge subnet.
///
/// Filters the `172.16.0.0/12` range (172.16.0.0 – 172.31.255.255), which is
/// Docker's default bridge network allocation.
///
/// Note: `10.0.0.0/8` is NOT filtered because it is widely used for legitimate
/// LAN addressing. If a deployment uses a custom Docker bridge subnet outside
/// `172.16.0.0/12`, use `report_interface` or `report_ip` config to override.
pub fn is_container_bridge(addr: &Ipv4Addr) -> bool {
    // 172.16.0.0/12 = 172.16.0.0 – 172.31.255.255
    // Binary: 10101100.0001xxxx.xxxxxxxx.xxxxxxxx
    let octets = addr.octets();
    octets[0] == 172 && (octets[1] & 0xF0) == 0x10
}

/// Check if an IPv4 address is link-local (`169.254.0.0/16`).
///
/// Link-local addresses are auto-assigned when no DHCP is available and
/// are never routable across networks.
pub fn is_link_local(addr: &Ipv4Addr) -> bool {
    let octets = addr.octets();
    octets[0] == 169 && octets[1] == 254
}

/// Determine the local source IP that would be used to reach a target IP.
/// Uses the kernel routing table via `ip route get <target>`.
///
/// This is the most accurate way to select the correct local IP because it
/// queries the kernel routing table directly, which accounts for all routing
/// rules, interface priorities, and source address selection.
pub fn get_route_source_ip(target_ip: &str) -> Result<String> {
    let output = Command::new("ip")
        .args(["route", "get", target_ip])
        .output()
        .context("Failed to execute 'ip route get' — is iproute2 installed?")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(anyhow!(
            "'ip route get {}' failed: {}",
            target_ip,
            stderr.trim()
        ));
    }

    let stdout = String::from_utf8_lossy(&output.stdout);

    // Parse output like: "192.168.3.36 via 192.168.1.1 dev eth0 src 192.168.3.36 uid ..."
    // We want the 'src' field value
    let mut found_src = false;
    for part in stdout.split_whitespace() {
        if found_src {
            // Validate it's a valid IPv4 address
            if part.parse::<Ipv4Addr>().is_ok() {
                let addr = part.parse::<Ipv4Addr>().unwrap();
                if !addr.is_loopback() && !is_container_bridge(&addr) && !is_link_local(&addr) {
                    tracing::info!(
                        target_ip = target_ip,
                        source_ip = part,
                        "Route-based IP selection: local source IP for reaching target"
                    );
                    return Ok(part.to_string());
                }
            }
            break;
        }
        if part == "src" {
            found_src = true;
        }
    }

    Err(anyhow!(
        "Could not determine source IP for route to '{}' — 'ip route get' output: {}",
        target_ip,
        stdout.trim()
    ))
}

/// Get the IPv4 address of a specific network interface by name.
///
/// Returns the first non-loopback IPv4 address on the named interface.
/// Useful when the admin knows which interface faces the manager network.
pub fn get_ip_for_interface(interface_name: &str) -> Result<String> {
    let ifaces = if_addrs::get_if_addrs()
        .with_context(|| "Failed to enumerate network interfaces for interface lookup")?;

    for iface in &ifaces {
        if iface.name != interface_name {
            continue;
        }
        if let IpAddr::V4(addr) = iface.ip() {
            if !iface.is_loopback() {
                tracing::info!(
                    interface = interface_name,
                    ip = %addr,
                    "Resolved IP from configured interface"
                );
                return Ok(addr.to_string());
            }
        }
    }

    Err(anyhow!(
        "No non-loopback IPv4 address found on interface '{}'",
        interface_name
    ))
}

/// Determine the primary IP address to report to the manager.
///
/// Resolution priority:
/// 1. `report_ip` — explicit IP from config (highest priority)
/// 2. `report_interface` — IP from a named interface
/// 3. `route_target` — route-based selection using kernel routing table
/// 4. Auto-detect — first IP from `get_ip_addresses()` (bridge subnets already filtered)
pub fn get_primary_ip(
    report_interface: Option<&str>,
    report_ip: Option<&str>,
    route_target: Option<&str>,
) -> Result<String> {
    // Priority 1: Explicit IP override
    if let Some(ip) = report_ip {
        // Validate it parses as IPv4
        if let Ok(addr) = ip.parse::<Ipv4Addr>() {
            if !addr.is_loopback() {
                tracing::info!(ip = ip, "Using explicitly configured report_ip");
                return Ok(ip.to_string());
            }
            tracing::warn!(
                ip = ip,
                "Configured report_ip is a loopback address — ignoring"
            );
        } else {
            tracing::warn!(
                ip = ip,
                "Configured report_ip is not a valid IPv4 address — falling back to auto-detect"
            );
        }
    }

    // Priority 2: Interface name override
    if let Some(iface) = report_interface {
        match get_ip_for_interface(iface) {
            Ok(ip) => return Ok(ip),
            Err(e) => {
                tracing::warn!(
                    interface = iface,
                    error = %e,
                    "Configured report_interface lookup failed — falling back to route-based or auto-detect"
                );
            }
        }
    }

    // Priority 3: Route-based selection using kernel routing table
    if let Some(target) = route_target {
        match get_route_source_ip(target) {
            Ok(ip) => {
                tracing::info!(
                    target = target,
                    ip = %ip,
                    "Using route-based IP selection for target"
                );
                return Ok(ip);
            }
            Err(e) => {
                tracing::warn!(
                    target = target,
                    error = %e,
                    "Route-based IP selection failed — falling back to auto-detect"
                );
            }
        }
    }

    // Priority 4: Auto-detect (bridge subnets already filtered by get_ip_addresses)
    let addrs = get_ip_addresses()?;
    addrs
        .first()
        .cloned()
        .ok_or_else(|| anyhow!("No suitable IPv4 address found on any interface"))
}

/// Extract OS distribution details from `/etc/os-release` and kernel version.
/// Returns a JSON object with: distro, version, id_like, kernel.
pub fn get_os_details() -> Result<serde_json::Value> {
    let mut details = serde_json::Map::new();

    // Parse /etc/os-release (exists on all target distros)
    if let Ok(content) = fs::read_to_string("/etc/os-release") {
        for line in content.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }

            if let Some((key, value)) = line.split_once('=') {
                // Strip surrounding quotes from value
                let unquoted = value.trim().trim_matches('"').trim_matches('\'');
                match key {
                    "NAME" => {
                        details.insert(
                            "distro".into(),
                            serde_json::Value::String(unquoted.to_string()),
                        );
                    }
                    "VERSION_ID" => {
                        details.insert(
                            "version".into(),
                            serde_json::Value::String(unquoted.to_string()),
                        );
                    }
                    "ID_LIKE" => {
                        details.insert(
                            "id_like".into(),
                            serde_json::Value::String(unquoted.to_string()),
                        );
                    }
                    "VERSION_CODENAME" => {
                        details.insert(
                            "codename".into(),
                            serde_json::Value::String(unquoted.to_string()),
                        );
                    }
                    _ => {}
                }
            }
        }
    } else {
        // Fallback for systems without os-release (very rare)
        details.insert("distro".into(), serde_json::Value::String("unknown".into()));
        details.insert(
            "version".into(),
            serde_json::Value::String("unknown".into()),
        );
    }

    // Kernel version via uname -r
    if let Ok(output) = Command::new("uname").arg("-r").output() {
        if output.status.success() {
            let kernel = String::from_utf8_lossy(&output.stdout).trim().to_string();
            details.insert("kernel".into(), serde_json::Value::String(kernel));
        }
    } else {
        details.insert("kernel".into(), serde_json::Value::String("unknown".into()));
    }

    Ok(serde_json::Value::Object(details))
}

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

    #[test]
    fn machine_id_is_not_empty() {
        let id = get_machine_id().expect("Failed to get machine-id");
        assert!(!id.is_empty(), "machine-id should not be empty");
        assert_eq!(id.len(), 32, "machine-id should be 32 hex chars");
    }

    #[test]
    fn fqdn_is_not_empty() {
        let fqdn = get_fqdn().expect("Failed to get FQDN");
        assert!(!fqdn.is_empty(), "FQDN should not be empty");
    }

    #[test]
    fn fqdn_prefers_full_domain() {
        // If hostname -f returns a value with a dot, get_fqdn should return it
        // (not the short hostname from /etc/hostname)
        let fqdn = get_fqdn().expect("Failed to get FQDN");
        // On properly configured systems, FQDN should contain at least one dot
        // If it doesn't, it's likely a short hostname from /etc/hostname
        if fqdn.contains('.') {
            // FQDN contains domain — good
            assert!(
                fqdn.split('.').count() >= 2,
                "FQDN should have at least host.domain format, got: {}",
                fqdn
            );
        }
        // If no dot, it's a short hostname — acceptable fallback but not ideal
    }

    #[test]
    fn hostname_is_not_empty() {
        let hostname = get_hostname().expect("Failed to get hostname");
        assert!(!hostname.is_empty(), "Hostname should not be empty");
    }

    #[test]
    fn hostname_is_short_form() {
        let hostname = get_hostname().expect("Failed to get hostname");
        // Short hostname should NOT contain dots
        assert!(
            !hostname.contains('.'),
            "Short hostname should not contain dots, got: {}",
            hostname
        );
    }

    #[test]
    fn hostname_is_prefix_of_fqdn() {
        let hostname = get_hostname().expect("Failed to get hostname");
        let fqdn = get_fqdn().expect("Failed to get FQDN");
        // If FQDN contains a dot, hostname should be the first component
        if fqdn.contains('.') {
            let fqdn_prefix = fqdn.split('.').next().unwrap_or(&fqdn);
            assert_eq!(
                hostname, fqdn_prefix,
                "Short hostname '{}' should match FQDN prefix '{}'",
                hostname, fqdn_prefix
            );
        }
    }

    #[test]
    fn os_details_contains_kernel() {
        let details = get_os_details().expect("Failed to get OS details");
        assert!(
            details.get("kernel").is_some(),
            "OS details must contain kernel version"
        );
    }

    // =============================================================================
    // Container Bridge & Link-Local Filtering Tests
    // =============================================================================

    #[test]
    fn test_is_container_bridge_docker_default() {
        // Docker default bridge network: 172.17.0.0/16
        assert!(is_container_bridge(&"172.17.0.1".parse().unwrap()));
        assert!(is_container_bridge(&"172.17.255.255".parse().unwrap()));
    }

    #[test]
    fn test_is_container_bridge_full_range() {
        // 172.16.0.0/12 = 172.16.0.0 – 172.31.255.255
        assert!(is_container_bridge(&"172.16.0.1".parse().unwrap()));
        assert!(is_container_bridge(&"172.31.255.255".parse().unwrap()));
        assert!(is_container_bridge(&"172.20.0.1".parse().unwrap()));
    }

    #[test]
    fn test_is_not_container_bridge() {
        // Outside 172.16.0.0/12
        assert!(!is_container_bridge(&"192.168.3.36".parse().unwrap()));
        assert!(!is_container_bridge(&"10.0.0.1".parse().unwrap()));
        assert!(!is_container_bridge(&"172.32.0.1".parse().unwrap()));
        assert!(!is_container_bridge(&"172.15.0.1".parse().unwrap()));
        assert!(!is_container_bridge(&"8.8.8.8".parse().unwrap()));
    }

    #[test]
    fn test_is_link_local() {
        assert!(is_link_local(&"169.254.0.1".parse().unwrap()));
        assert!(is_link_local(&"169.254.255.255".parse().unwrap()));
    }

    #[test]
    fn test_is_not_link_local() {
        assert!(!is_link_local(&"192.168.1.1".parse().unwrap()));
        assert!(!is_link_local(&"169.253.0.1".parse().unwrap()));
        assert!(!is_link_local(&"169.255.0.1".parse().unwrap()));
    }

    #[test]
    fn test_get_ip_addresses_excludes_docker_bridge() {
        // On a system with Docker, the returned IPs should not include 172.16.0.0/12
        let addrs = get_ip_addresses().expect("Failed to get IP addresses");
        for addr in &addrs {
            let parsed: Ipv4Addr = addr.parse().expect("Should parse as IPv4");
            assert!(
                !is_container_bridge(&parsed),
                "IP '{}' is in Docker bridge range 172.16.0.0/12 — should be excluded",
                addr
            );
        }
    }

    #[test]
    fn test_get_ip_addresses_excludes_link_local() {
        let addrs = get_ip_addresses().expect("Failed to get IP addresses");
        for addr in &addrs {
            let parsed: Ipv4Addr = addr.parse().expect("Should parse as IPv4");
            assert!(
                !is_link_local(&parsed),
                "IP '{}' is link-local 169.254.0.0/16 — should be excluded",
                addr
            );
        }
    }

    #[test]
    fn test_get_primary_ip_auto_detect() {
        // Without overrides, should return a valid non-bridge IP
        // In Docker containers, auto-detect may find no routable IPs — that's valid
        match get_primary_ip(None, None, None) {
            Ok(ip) => {
                assert!(!ip.is_empty(), "Primary IP should not be empty");
                let parsed: Ipv4Addr = ip.parse().expect("Primary IP should be valid IPv4");
                assert!(
                    !is_container_bridge(&parsed),
                    "Auto-detected IP should not be Docker bridge"
                );
            }
            Err(_) => {
                eprintln!("NOTE: No routable IPs found — likely running inside a Docker container");
            }
        }
    }

    #[test]
    fn test_get_primary_ip_explicit_override() {
        // Explicit IP should be returned as-is
        let ip = get_primary_ip(None, Some("10.99.99.1"), None).expect("Failed with explicit IP");
        assert_eq!(ip, "10.99.99.1");
    }

    #[test]
    fn test_get_primary_ip_rejects_loopback_override() {
        // Loopback in report_ip should fall back to auto-detect
        // In Docker containers, auto-detect may also fail — that's valid
        match get_primary_ip(None, Some("127.0.0.1"), None) {
            Ok(ip) => assert_ne!(ip, "127.0.0.1"),
            Err(_) => {
                eprintln!(
                    "NOTE: Loopback rejected but no routable IPs for fallback — Docker container"
                );
            }
        }
    }

    #[test]
    fn test_get_primary_ip_invalid_override_falls_back() {
        // Invalid IP in report_ip should fall back to auto-detect
        // In Docker containers, auto-detect may also fail — that's valid
        match get_primary_ip(None, Some("not-an-ip"), None) {
            Ok(ip) => assert!(!ip.is_empty()),
            Err(_) => {
                eprintln!(
                    "NOTE: Invalid IP rejected but no routable IPs for fallback — Docker container"
                );
            }
        }
    }

    #[test]
    fn test_get_primary_ip_route_target_priority() {
        // Route-based selection should be tried before auto-detect
        // We test with a well-known IP; if iproute2 is available this may succeed,
        // otherwise it falls back gracefully
        match get_primary_ip(None, None, Some("8.8.8.8")) {
            Ok(ip) => {
                assert!(!ip.is_empty(), "Route-based IP should not be empty");
                let parsed: Ipv4Addr = ip.parse().expect("Route-based IP should be valid IPv4");
                assert!(
                    !is_container_bridge(&parsed),
                    "Route-based IP should not be Docker bridge"
                );
                assert!(
                    !parsed.is_loopback(),
                    "Route-based IP should not be loopback"
                );
            }
            Err(_) => {
                eprintln!(
                    "NOTE: Route-based selection failed — iproute2 may not be available in this environment"
                );
            }
        }
    }

    #[test]
    fn test_get_primary_ip_explicit_overrides_route_target() {
        // Explicit report_ip should take priority over route_target
        let ip = get_primary_ip(None, Some("10.99.99.1"), Some("8.8.8.8"))
            .expect("Explicit IP should override route_target");
        assert_eq!(ip, "10.99.99.1");
    }

    #[test]
    fn test_get_route_source_ip_known_target() {
        // Test route-based IP detection with a well-known target
        // This test requires iproute2 to be installed
        match get_route_source_ip("8.8.8.8") {
            Ok(ip) => {
                let parsed: Ipv4Addr = ip.parse().expect("Route source IP should be valid IPv4");
                assert!(
                    !parsed.is_loopback(),
                    "Route source IP should not be loopback"
                );
                assert!(
                    !is_container_bridge(&parsed),
                    "Route source IP should not be Docker bridge"
                );
                assert!(
                    !is_link_local(&parsed),
                    "Route source IP should not be link-local"
                );
            }
            Err(e) => {
                // Acceptable in containers without iproute2 or routing
                eprintln!(
                    "NOTE: Route-based IP detection failed: {} — may be unavailable in this environment",
                    e
                );
            }
        }
    }
}
