use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use anyhow::Context;
use regex::Regex;

const POLL_INTERVAL_MS: u64 = 100;

#[derive(Debug, Clone, serde::Serialize)]
pub struct Connection {
    pub remote_host: String,
    /// Reverse DNS resolved hostname (empty if lookup failed)
    #[serde(skip_serializing_if = "String::is_empty")]
    pub resolved_host: String,
    pub remote_port: u16,
    pub pid: u32,
    pub process_name: String,
    pub cmdline: String,
}

#[derive(Debug, serde::Serialize)]
pub struct MonitorResult {
    pub command: String,
    pub exit_code: i32,
    pub duration_secs: f64,
    pub connections: Vec<Connection>,
    pub expected: Vec<Connection>,
    pub unexpected: Vec<Connection>,
    pub critical: Vec<Connection>,
    pub file_alerts: Vec<crate::watchdog::FileAlert>,
    pub write_violations: Vec<crate::watchdog::WriteViolation>,
}

/// Observations collected by MonitorHandle during concurrent monitoring
#[derive(Debug, Clone, serde::Serialize)]
pub struct MonitorObservations {
    pub connections: Vec<Connection>,
    pub expected: Vec<Connection>,
    pub unexpected: Vec<Connection>,
    pub critical: Vec<Connection>,
    pub file_alerts: Vec<crate::watchdog::FileAlert>,
    pub write_violations: Vec<crate::watchdog::WriteViolation>,
    pub duration_secs: f64,
}

impl MonitorObservations {
    /// Create empty observations (no monitoring performed, e.g. Docker)
    pub fn empty() -> Self {
        Self {
            connections: vec![],
            expected: vec![],
            unexpected: vec![],
            critical: vec![],
            file_alerts: vec![],
            write_violations: vec![],
            duration_secs: 0.0,
        }
    }
}

/// Handle for monitoring an externally-managed process.
/// Start monitoring with `MonitorHandle::start(pid)`, then call `stop()` to collect results.
pub struct MonitorHandle {
    running: Arc<std::sync::atomic::AtomicBool>,
    poll_handle: Option<std::thread::JoinHandle<()>>,
    watchdog_handle: Option<std::thread::JoinHandle<()>>,
    connections: Arc<Mutex<Vec<Connection>>>,
    file_alerts: Arc<Mutex<Vec<crate::watchdog::FileAlert>>>,
    write_violations: Arc<Mutex<Vec<crate::watchdog::WriteViolation>>>,
    start: std::time::Instant,
}

impl MonitorHandle {
    /// Start monitoring a process by PID
    pub fn start(pid: u32) -> Self {
        let connections: Arc<Mutex<Vec<Connection>>> = Arc::new(Mutex::new(Vec::new()));
        let seen_addrs: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
        let running = Arc::new(std::sync::atomic::AtomicBool::new(true));
        let file_alerts: Arc<Mutex<Vec<crate::watchdog::FileAlert>>> =
            Arc::new(Mutex::new(Vec::new()));
        let write_violations: Arc<Mutex<Vec<crate::watchdog::WriteViolation>>> =
            Arc::new(Mutex::new(Vec::new()));

        let conn_clone = connections.clone();
        let seen_clone = seen_addrs.clone();
        let running_clone = running.clone();

        let poll_handle = std::thread::spawn(move || {
            poll_connections(pid, &conn_clone, &seen_clone, &running_clone);
        });

        let project_root = std::env::current_dir().unwrap_or_default();
        let config = crate::config::load_config(&project_root);
        let sensitive_paths = crate::watchdog::build_sensitive_paths(&config.install.watch_paths);
        let allowed_write_dirs: Vec<std::path::PathBuf> = vec![
            project_root.join("node_modules"),
            project_root.join("vendor"),
            project_root.join(".venv"),
            std::env::temp_dir(),
            PathBuf::from("/tmp"),
            PathBuf::from("/private/tmp"),
        ];

        let alerts_clone = file_alerts.clone();
        let violations_clone = write_violations.clone();
        let running_watchdog = running.clone();

        let watchdog_handle = std::thread::spawn(move || {
            let mut seen_alerts = std::collections::HashSet::new();
            let mut seen_violations = std::collections::HashSet::new();
            while running_watchdog.load(std::sync::atomic::Ordering::Relaxed) {
                let new_alerts =
                    crate::watchdog::check_process_files(pid, &sensitive_paths, &mut seen_alerts);
                if !new_alerts.is_empty() {
                    alerts_clone.lock().unwrap().extend(new_alerts);
                }
                let new_violations = crate::watchdog::check_write_boundaries(
                    pid,
                    &allowed_write_dirs,
                    &mut seen_violations,
                );
                if !new_violations.is_empty() {
                    violations_clone.lock().unwrap().extend(new_violations);
                }
                std::thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
            }
        });

        Self {
            running,
            poll_handle: Some(poll_handle),
            watchdog_handle: Some(watchdog_handle),
            connections,
            file_alerts,
            write_violations,
            start: std::time::Instant::now(),
        }
    }

    /// Stop monitoring and collect results
    pub fn stop(mut self) -> MonitorObservations {
        self.running
            .store(false, std::sync::atomic::Ordering::Relaxed);
        if let Some(h) = self.poll_handle.take() {
            let _ = h.join();
        }
        if let Some(h) = self.watchdog_handle.take() {
            let _ = h.join();
        }

        let defaults = default_expected_hosts();

        // Load learned baseline (same as run_monitor does)
        let project_root = std::env::current_dir().unwrap_or_default();
        let baseline_path = project_root.join(".depsec/monitor-baseline.json");
        let user_baseline = std::fs::read_to_string(&baseline_path)
            .ok()
            .and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok());
        let user_allowed: HashSet<String> = user_baseline
            .as_ref()
            .and_then(|b| b.get("allowed_hosts"))
            .and_then(|h| h.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str().map(String::from))
                    .collect()
            })
            .unwrap_or_default();

        let all_connections = self.connections.lock().unwrap().clone();
        let mut expected = Vec::new();
        let mut unexpected = Vec::new();
        let mut critical = Vec::new();

        for conn in &all_connections {
            if ALWAYS_BLOCK.iter().any(|b| conn.remote_host == *b) {
                critical.push(conn.clone());
            } else if is_expected_connection(conn, &defaults, &user_allowed) {
                expected.push(conn.clone());
            } else {
                unexpected.push(conn.clone());
            }
        }

        MonitorObservations {
            connections: all_connections,
            expected,
            unexpected,
            critical,
            file_alerts: self.file_alerts.lock().unwrap().clone(),
            write_violations: self.write_violations.lock().unwrap().clone(),
            duration_secs: self.start.elapsed().as_secs_f64(),
        }
    }
}

/// Known-malicious IPs that are always flagged
const ALWAYS_BLOCK: &[&str] = &["169.254.169.254", "169.254.170.2"];

/// Expected registry hosts per package manager process
fn default_expected_hosts() -> HashMap<&'static str, Vec<&'static str>> {
    let mut m = HashMap::new();
    m.insert("npm", vec!["registry.npmjs.org"]);
    m.insert("node", vec!["registry.npmjs.org"]);
    m.insert("yarn", vec!["registry.yarnpkg.com", "registry.npmjs.org"]);
    m.insert("pnpm", vec!["registry.npmjs.org"]);
    m.insert("pip", vec!["pypi.org", "files.pythonhosted.org"]);
    m.insert("pip3", vec!["pypi.org", "files.pythonhosted.org"]);
    m.insert("python", vec!["pypi.org", "files.pythonhosted.org"]);
    m.insert("python3", vec!["pypi.org", "files.pythonhosted.org"]);
    m.insert("uv", vec!["pypi.org", "files.pythonhosted.org"]);
    m.insert(
        "cargo",
        vec!["crates.io", "static.crates.io", "index.crates.io"],
    );
    m.insert("go", vec!["proxy.golang.org", "sum.golang.org"]);
    m.insert("bundle", vec!["rubygems.org", "index.rubygems.org"]);
    m.insert("gem", vec!["rubygems.org", "index.rubygems.org"]);
    m
}

/// Hosts always expected (GitHub, DNS, etc.)
const UNIVERSAL_EXPECTED: &[&str] = &[
    "github.com",
    "api.github.com",
    "objects.githubusercontent.com",
];

/// Run a command and monitor its network activity.
pub fn run_monitor(
    args: &[String],
    baseline_path: Option<&Path>,
    learn_mode: bool,
    json_output: bool,
) -> anyhow::Result<MonitorResult> {
    if args.is_empty() {
        anyhow::bail!("No command specified. Usage: depsec monitor <command> [args...]");
    }

    let command_str = args.join(" ");
    let start = std::time::Instant::now();

    // Shared state for connections collected by the polling thread
    let connections: Arc<Mutex<Vec<Connection>>> = Arc::new(Mutex::new(Vec::new()));
    let seen_addrs: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
    let running = Arc::new(std::sync::atomic::AtomicBool::new(true));

    // Spawn the monitored command
    let mut child = Command::new(&args[0])
        .args(&args[1..])
        .stdin(Stdio::inherit())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .spawn()
        .with_context(|| format!("Failed to start: {}", args[0]))?;

    let child_pid = child.id();

    // Start network polling in a background thread
    let conn_clone = connections.clone();
    let seen_clone = seen_addrs.clone();
    let running_clone = running.clone();

    let poll_handle = std::thread::spawn(move || {
        poll_connections(child_pid, &conn_clone, &seen_clone, &running_clone);
    });

    // Start file access watchdog in a parallel thread
    let file_alerts: Arc<Mutex<Vec<crate::watchdog::FileAlert>>> = Arc::new(Mutex::new(Vec::new()));
    let write_violations: Arc<Mutex<Vec<crate::watchdog::WriteViolation>>> =
        Arc::new(Mutex::new(Vec::new()));
    let running_watchdog = running.clone();
    let alerts_clone = file_alerts.clone();
    let violations_clone = write_violations.clone();

    // Load config for extra watch paths
    let project_root_for_config = std::env::current_dir().unwrap_or_default();
    let config = crate::config::load_config(&project_root_for_config);
    let sensitive_paths = crate::watchdog::build_sensitive_paths(&config.install.watch_paths);
    let project_root = std::env::current_dir().unwrap_or_default();
    let allowed_write_dirs: Vec<std::path::PathBuf> = vec![
        project_root.join("node_modules"),
        project_root.join("vendor"),
        project_root.join(".venv"),
        std::env::temp_dir(),
    ];

    let watchdog_handle = std::thread::spawn(move || {
        let mut seen_alerts = std::collections::HashSet::new();
        let mut seen_violations = std::collections::HashSet::new();

        while running_watchdog.load(std::sync::atomic::Ordering::Relaxed) {
            let new_alerts =
                crate::watchdog::check_process_files(child_pid, &sensitive_paths, &mut seen_alerts);
            if !new_alerts.is_empty() {
                alerts_clone.lock().unwrap().extend(new_alerts);
            }

            let new_violations = crate::watchdog::check_write_boundaries(
                child_pid,
                &allowed_write_dirs,
                &mut seen_violations,
            );
            if !new_violations.is_empty() {
                violations_clone.lock().unwrap().extend(new_violations);
            }

            std::thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
        }
    });

    // Wait for the child to finish
    let status = child.wait().context("Failed to wait for child process")?;
    let duration = start.elapsed();

    // Stop both polling threads
    running.store(false, std::sync::atomic::Ordering::Relaxed);
    let _ = poll_handle.join();
    let _ = watchdog_handle.join();

    let all_connections = connections.lock().unwrap().clone();
    let all_file_alerts = file_alerts.lock().unwrap().clone();
    let all_write_violations = write_violations.lock().unwrap().clone();

    // Load baseline for comparison
    let user_baseline = baseline_path
        .and_then(|p| std::fs::read_to_string(p).ok())
        .and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok());

    let user_allowed: HashSet<String> = user_baseline
        .as_ref()
        .and_then(|b| b.get("allowed_hosts"))
        .and_then(|h| h.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();

    let defaults = default_expected_hosts();

    // Classify connections
    let mut expected = Vec::new();
    let mut unexpected = Vec::new();
    let mut critical = Vec::new();

    for conn in &all_connections {
        if ALWAYS_BLOCK.iter().any(|b| conn.remote_host == *b) {
            critical.push(conn.clone());
        } else if is_expected_connection(conn, &defaults, &user_allowed) {
            expected.push(conn.clone());
        } else {
            unexpected.push(conn.clone());
        }
    }

    // In learn mode, save all connections as a baseline
    if learn_mode {
        save_learned_baseline(&all_connections, baseline_path)?;
    }

    let result = MonitorResult {
        command: command_str,
        exit_code: status.code().unwrap_or(-1),
        duration_secs: duration.as_secs_f64(),
        connections: all_connections,
        expected,
        unexpected,
        critical,
        file_alerts: all_file_alerts,
        write_violations: all_write_violations,
    };

    if json_output {
        println!("{}", serde_json::to_string_pretty(&result)?);
    } else {
        print_monitor_result(&result);
    }

    Ok(result)
}

fn is_expected_connection(
    conn: &Connection,
    defaults: &HashMap<&str, Vec<&str>>,
    user_allowed: &HashSet<String>,
) -> bool {
    // Check both raw IP and reverse-DNS resolved hostname
    let hosts = [&conn.remote_host, &conn.resolved_host];

    // Check universal hosts (strict match — exact or subdomain, NOT substring)
    for host in &hosts {
        if UNIVERSAL_EXPECTED.iter().any(|h| host_matches(host, h)) {
            return true;
        }
    }

    // Check user baseline (exact match via HashSet)
    for host in &hosts {
        if user_allowed.contains(*host) {
            return true;
        }
    }

    // Check process-specific defaults
    let process_base = conn
        .process_name
        .rsplit('/')
        .next()
        .unwrap_or(&conn.process_name);

    if let Some(expected_hosts) = defaults.get(process_base) {
        for host in &hosts {
            if expected_hosts.iter().any(|h| host_matches(host, h)) {
                return true;
            }
        }
    }

    false
}

/// Poll network connections using ss (Linux) or lsof (macOS)
fn poll_connections(
    child_pid: u32,
    connections: &Arc<Mutex<Vec<Connection>>>,
    seen: &Arc<Mutex<HashSet<String>>>,
    running: &Arc<std::sync::atomic::AtomicBool>,
) {
    while running.load(std::sync::atomic::Ordering::Relaxed) {
        // Get the full process tree for the child (includes grandchildren)
        let child_pids: HashSet<u32> = crate::watchdog::get_process_tree_pub(child_pid)
            .into_iter()
            .collect();

        if let Ok(conns) = get_current_connections() {
            // Collect new connections under the lock (fast — no I/O)
            let mut new_conns = Vec::new();
            {
                let mut seen_lock = seen.lock().unwrap();
                for conn in conns {
                    if !child_pids.contains(&conn.pid) {
                        continue;
                    }
                    let key = format!("{}:{}:{}", conn.remote_host, conn.remote_port, conn.pid);
                    if seen_lock.insert(key) {
                        new_conns.push(conn);
                    }
                }
            } // locks dropped before DNS

            // Resolve DNS outside the lock (can block up to 1s per IP)
            for conn in &mut new_conns {
                conn.resolved_host = reverse_dns(&conn.remote_host);
            }

            // Push results
            if !new_conns.is_empty() {
                connections.lock().unwrap().extend(new_conns);
            }
        }
        std::thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
    }
}

/// Get current TCP connections with process info
fn get_current_connections() -> anyhow::Result<Vec<Connection>> {
    if cfg!(target_os = "linux") {
        get_connections_ss()
    } else {
        get_connections_lsof()
    }
}

/// Linux: parse `ss -tnp` output
fn get_connections_ss() -> anyhow::Result<Vec<Connection>> {
    let output = Command::new("ss")
        .args(["-tnp"])
        .output()
        .context("Failed to run ss")?;

    if !output.status.success() {
        return Ok(vec![]);
    }

    let re = Regex::new(
        r#"ESTAB\s+\d+\s+\d+\s+\S+\s+(\d+\.\d+\.\d+\.\d+):(\d+)\s+.*users:\(\("([^"]+)",pid=(\d+)"#,
    )?;

    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut connections = Vec::new();

    for line in stdout.lines() {
        if let Some(caps) = re.captures(line) {
            let remote_host = caps[1].to_string();
            let remote_port: u16 = caps[2].parse().unwrap_or(0);
            let process_name = caps[3].to_string();
            let pid: u32 = caps[4].parse().unwrap_or(0);

            let cmdline = read_proc_cmdline(pid);

            connections.push(Connection {
                remote_host,
                resolved_host: String::new(),
                remote_port,
                pid,
                process_name,
                cmdline,
            });
        }
    }

    Ok(connections)
}

/// macOS: parse `lsof -i -n -P` output
fn get_connections_lsof() -> anyhow::Result<Vec<Connection>> {
    let output = Command::new("lsof")
        .args(["-i", "-n", "-P", "+c0"])
        .output()
        .context("Failed to run lsof")?;

    if !output.status.success() {
        return Ok(vec![]);
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut connections = Vec::new();

    // lsof output: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
    // NAME format: host:port->remote_host:remote_port (ESTABLISHED)
    let re = Regex::new(r"->(\d+\.\d+\.\d+\.\d+):(\d+)\s+\(ESTABLISHED\)")?;

    for line in stdout.lines().skip(1) {
        // Skip header
        let parts: Vec<&str> = line.split_whitespace().collect();
        if parts.len() < 9 {
            continue;
        }

        let process_name = parts[0].to_string();
        let pid: u32 = parts[1].parse().unwrap_or(0);
        let name_field = parts.last().unwrap_or(&"");

        // Join remaining fields to handle names with spaces
        let name_str = parts[8..].join(" ");

        if let Some(caps) = re.captures(&name_str) {
            connections.push(Connection {
                remote_host: caps[1].to_string(),
                resolved_host: String::new(),
                remote_port: caps[2].parse().unwrap_or(0),
                pid,
                process_name,
                cmdline: format!("{} (pid {})", name_field, pid),
            });
        }
    }

    Ok(connections)
}

/// Strict host matching: exact match or subdomain match.
/// Prevents PTR spoofing (e.g., "registry.npmjs.org.evil.com" must NOT match "registry.npmjs.org").
fn host_matches(actual: &str, expected: &str) -> bool {
    if actual.is_empty() || expected.is_empty() {
        return false;
    }
    actual == expected || actual.ends_with(&format!(".{expected}"))
}

/// Reverse DNS lookup — resolve an IP to a hostname.
/// Uses the `host` command (available on macOS/Linux) with a short timeout.
/// Returns empty string if lookup fails or times out.
fn reverse_dns(ip: &str) -> String {
    Command::new("host")
        .args(["-W", "1", ip]) // 1 second timeout
        .output()
        .ok()
        .filter(|o| o.status.success())
        .and_then(|o| {
            let stdout = String::from_utf8_lossy(&o.stdout);
            // Parse: "34.6.16.104.in-addr.arpa domain name pointer hostname.example.com."
            stdout
                .lines()
                .find(|l| l.contains("domain name pointer"))
                .and_then(|l| l.split("domain name pointer ").nth(1))
                .map(|s| s.trim().trim_end_matches('.').to_string())
        })
        .unwrap_or_default()
}

/// Read /proc/<pid>/cmdline on Linux
fn read_proc_cmdline(pid: u32) -> String {
    let path = format!("/proc/{pid}/cmdline");
    std::fs::read_to_string(&path)
        .map(|s| s.replace('\0', " ").trim().to_string())
        .unwrap_or_else(|_| format!("(pid {pid})"))
}

fn save_learned_baseline(connections: &[Connection], path: Option<&Path>) -> anyhow::Result<()> {
    let output_path = path
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from(".depsec/monitor-baseline.json"));

    if let Some(parent) = output_path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    let mut hosts: Vec<String> = connections
        .iter()
        .map(|c| c.remote_host.clone())
        .collect::<HashSet<_>>()
        .into_iter()
        .collect();
    hosts.sort();

    let baseline = serde_json::json!({
        "version": 1,
        "learned": true,
        "allowed_hosts": hosts,
    });

    std::fs::write(&output_path, serde_json::to_string_pretty(&baseline)?)?;
    eprintln!(
        "Baseline saved to {} ({} hosts learned)",
        output_path.display(),
        hosts.len()
    );

    Ok(())
}

fn print_monitor_result(result: &MonitorResult) {
    println!(
        "\ndepsec monitor — {} completed in {:.1}s (exit code {})\n",
        result.command, result.duration_secs, result.exit_code
    );

    if result.connections.is_empty() {
        println!("[Network Activity]");
        println!("  No outbound connections detected.");
        return;
    }

    println!(
        "[Network Activity] {} connection{} monitored\n",
        result.connections.len(),
        if result.connections.len() == 1 {
            ""
        } else {
            "s"
        }
    );

    for conn in &result.critical {
        println!(
            "  \x1b[31m✗\x1b[0m {}:{} — \x1b[31mCRITICAL: cloud credential endpoint!\x1b[0m",
            conn.remote_host, conn.remote_port
        );
        println!("    → Process: {} (pid {})", conn.process_name, conn.pid);
        if !conn.cmdline.is_empty() && conn.cmdline != format!("(pid {})", conn.pid) {
            println!("    → Command: {}", conn.cmdline);
        }
    }

    for conn in &result.unexpected {
        println!(
            "  \x1b[31m✗\x1b[0m {}:{} — UNEXPECTED",
            conn.remote_host, conn.remote_port
        );
        println!("    → Process: {} (pid {})", conn.process_name, conn.pid);
        if !conn.cmdline.is_empty() && conn.cmdline != format!("(pid {})", conn.pid) {
            println!("    → Command: {}", conn.cmdline);
        }
    }

    for conn in &result.expected {
        println!(
            "  \x1b[32m✓\x1b[0m {}:{} — expected ({})",
            conn.remote_host, conn.remote_port, conn.process_name
        );
    }

    // File access alerts
    println!("\n[File Access]");
    if result.file_alerts.is_empty() {
        println!("  \x1b[32m✓\x1b[0m No sensitive files accessed");
    } else {
        for alert in &result.file_alerts {
            println!(
                "  \x1b[31m🔴 READ {}\x1b[0m — by {} (pid {})",
                alert.path, alert.process_name, alert.pid
            );
        }
    }

    // Write boundary violations
    if !result.write_violations.is_empty() {
        println!("\n[Write Boundary]");
        for violation in &result.write_violations {
            println!(
                "  \x1b[31m⚠ WRITE outside expected dirs: {}\x1b[0m — by {} (pid {})",
                violation.path, violation.process_name, violation.pid
            );
        }
    }

    println!();
    let total = result.connections.len();
    let bad = result.unexpected.len() + result.critical.len();
    let file_issues = result.file_alerts.len() + result.write_violations.len();

    if bad > 0 || file_issues > 0 {
        print!("{total} connections monitored");
        if bad > 0 {
            print!(", \x1b[31m{bad} unexpected\x1b[0m");
        }
        if !result.file_alerts.is_empty() {
            print!(
                ", \x1b[31m{} sensitive file reads\x1b[0m",
                result.file_alerts.len()
            );
        }
        if !result.write_violations.is_empty() {
            print!(
                ", \x1b[31m{} write violations\x1b[0m",
                result.write_violations.len()
            );
        }
        println!(".");
    } else {
        println!("{total} connections monitored, all expected. No file access issues.");
    }
}

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

    fn make_conn(host: &str, process: &str) -> Connection {
        Connection {
            remote_host: host.into(),
            resolved_host: String::new(),
            remote_port: 443,
            pid: 1,
            process_name: process.into(),
            cmdline: String::new(),
        }
    }

    #[test]
    fn test_default_expected_hosts() {
        let hosts = default_expected_hosts();
        assert!(hosts.contains_key("npm"));
        assert!(hosts.contains_key("pip"));
        assert!(hosts.contains_key("cargo"));
        assert!(hosts["npm"].contains(&"registry.npmjs.org"));
    }

    #[test]
    fn test_is_expected_universal() {
        let defaults = default_expected_hosts();
        let allowed = HashSet::new();
        let conn = make_conn("github.com", "git");
        assert!(is_expected_connection(&conn, &defaults, &allowed));
    }

    #[test]
    fn test_is_expected_process_specific() {
        let defaults = default_expected_hosts();
        let allowed = HashSet::new();
        let conn = make_conn("registry.npmjs.org", "npm");
        assert!(is_expected_connection(&conn, &defaults, &allowed));
    }

    #[test]
    fn test_is_expected_user_allowed() {
        let defaults = default_expected_hosts();
        let mut allowed = HashSet::new();
        allowed.insert("custom.registry.com".into());
        let conn = make_conn("custom.registry.com", "unknown");
        assert!(is_expected_connection(&conn, &defaults, &allowed));
    }

    #[test]
    fn test_is_unexpected_connection() {
        let defaults = default_expected_hosts();
        let allowed = HashSet::new();
        let conn = make_conn("evil-server.com", "node");
        assert!(!is_expected_connection(&conn, &defaults, &allowed));
    }

    #[test]
    fn test_always_block_contains_imds() {
        assert!(ALWAYS_BLOCK.contains(&"169.254.169.254"));
        assert!(ALWAYS_BLOCK.contains(&"169.254.170.2"));
    }

    #[test]
    fn test_is_expected_process_path_strip() {
        // Process name with full path should still match
        let defaults = default_expected_hosts();
        let allowed = HashSet::new();
        let conn = make_conn("registry.npmjs.org", "/usr/local/bin/npm");
        assert!(is_expected_connection(&conn, &defaults, &allowed));
    }

    #[test]
    fn test_is_expected_via_resolved_host() {
        // Raw IP doesn't match, but resolved_host does → expected
        let defaults = default_expected_hosts();
        let allowed = HashSet::new();
        let conn = Connection {
            remote_host: "104.16.6.34".into(),
            resolved_host: "registry.npmjs.org".into(),
            remote_port: 443,
            pid: 1,
            process_name: "node".into(),
            cmdline: String::new(),
        };
        assert!(
            is_expected_connection(&conn, &defaults, &allowed),
            "Connection with resolved_host matching expected registry should be expected"
        );
    }

    #[test]
    fn test_is_unexpected_even_with_resolved_host() {
        let defaults = default_expected_hosts();
        let allowed = HashSet::new();
        let conn = Connection {
            remote_host: "1.2.3.4".into(),
            resolved_host: "evil-c2-server.com".into(),
            remote_port: 443,
            pid: 1,
            process_name: "node".into(),
            cmdline: String::new(),
        };
        assert!(!is_expected_connection(&conn, &defaults, &allowed));
    }

    #[test]
    fn test_ptr_spoofing_rejected() {
        // Attacker sets PTR record to "registry.npmjs.org.evil.com" — must NOT match
        let defaults = default_expected_hosts();
        let allowed = HashSet::new();
        let conn = Connection {
            remote_host: "1.2.3.4".into(),
            resolved_host: "registry.npmjs.org.evil.com".into(),
            remote_port: 443,
            pid: 1,
            process_name: "node".into(),
            cmdline: String::new(),
        };
        assert!(
            !is_expected_connection(&conn, &defaults, &allowed),
            "PTR spoofing with subdomain suffix must be rejected"
        );
    }

    #[test]
    fn test_host_matches_strict() {
        assert!(host_matches("registry.npmjs.org", "registry.npmjs.org"));
        assert!(host_matches("cdn.registry.npmjs.org", "registry.npmjs.org"));
        assert!(!host_matches(
            "registry.npmjs.org.evil.com",
            "registry.npmjs.org"
        ));
        assert!(!host_matches("", "registry.npmjs.org"));
        assert!(!host_matches("registry.npmjs.org", ""));
    }

    #[test]
    fn test_reverse_dns_parses_host_output() {
        // Test the parsing logic — reverse_dns calls `host` which may not work in CI,
        // but we can test that a well-known IP resolves (best-effort)
        let result = reverse_dns("1.1.1.1");
        // Cloudflare rDNS: typically "one.one.one.one" — don't hard-fail if DNS unavailable
        if !result.is_empty() {
            assert!(
                result.contains("one.one.one.one") || result.contains("cloudflare"),
                "Expected Cloudflare rDNS for 1.1.1.1, got: {result}"
            );
        }
    }

    #[test]
    fn test_monitor_observations_empty() {
        let obs = MonitorObservations::empty();
        assert!(obs.connections.is_empty());
        assert!(obs.unexpected.is_empty());
        assert!(obs.critical.is_empty());
        assert!(obs.file_alerts.is_empty());
        assert!(obs.write_violations.is_empty());
        assert_eq!(obs.duration_secs, 0.0);
    }
}
