//! # rustnet-host
//!
//! Per-connection **process attribution** for
//! [RustNet](https://github.com/domcyrus/rustnet): given a [`Connection`], find
//! the owning process (pid + name). Each platform uses its best available
//! strategy behind one [`ProcessLookup`] trait, selected by
//! [`create_process_lookup`]:
//!
//! - **Linux** — eBPF socket tracking (with the `ebpf` feature) and a procfs
//!   fallback.
//! - **macOS** — PKTAP packet metadata when available (capture provides it
//!   directly, so lookup is a no-op), otherwise `lsof`.
//! - **Windows** — kernel ETW events with an IP Helper API
//!   (`GetExtendedTcpTable`/`...UdpTable`) fallback.
//! - **FreeBSD** — `sockstat`.
//!
//! When a platform can't use its optimal method, [`ProcessLookup::get_degradation_reason`]
//! reports why via [`DegradationReason`] (e.g. missing `CAP_BPF`, no root for
//! PKTAP), which front-ends can surface to the user.
//!
//! This crate depends only on `rustnet-core` (for [`Connection`]/[`Protocol`]).
//! It does not depend on `rustnet-capture`; on macOS the application injects
//! whether PKTAP is active (via `report_pktap_degradation`) rather than this
//! crate querying capture. It has no UI or capture-loop dependency, so headless
//! tools can attribute processes the same way the `rustnet` TUI does.

use anyhow::Result;
use rustnet_core::network::types::{Connection, Protocol};
use std::borrow::Cow;
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};

/// Reasons why process detection may be degraded from optimal
#[derive(Debug, Clone, PartialEq, Default)]
pub enum DegradationReason {
    /// No degradation - optimal method available
    #[default]
    None,
    // Linux eBPF reasons
    /// Missing CAP_BPF capability (Linux 5.8+)
    #[cfg(target_os = "linux")]
    MissingCapBpf,
    /// Missing CAP_PERFMON capability (Linux 5.8+)
    #[cfg(target_os = "linux")]
    MissingCapPerfmon,
    /// Missing both CAP_BPF and CAP_PERFMON
    #[cfg(target_os = "linux")]
    MissingBpfCapabilities,
    /// eBPF feature not compiled in
    #[cfg(all(target_os = "linux", not(feature = "ebpf")))]
    EbpfFeatureDisabled,
    /// Kernel doesn't support required eBPF features (e.g. ENOSYS from bpf(2))
    #[cfg(target_os = "linux")]
    KernelUnsupported,
    /// BPF syscall denied despite caps - typically AppArmor, kernel lockdown,
    /// or unprivileged_bpf_disabled interactions
    #[cfg(target_os = "linux")]
    BpfPermissionDenied,
    /// Failed to attach a kprobe (e.g. symbol missing from kernel). The
    /// String carries the symbol name where known.
    #[cfg(target_os = "linux")]
    KprobeAttachFailed(String),
    /// Kernel BTF unavailable / CO-RE relocation failed (no /sys/kernel/btf/vmlinux)
    #[cfg(target_os = "linux")]
    BtfUnavailable,
    /// Generic eBPF load failure carrying the truncated libbpf error text
    #[cfg(target_os = "linux")]
    EbpfLoadFailed(String),
    /// Binary lives on a filesystem mounted with `nosuid`, which makes the
    /// kernel silently ignore file capabilities set via `setcap`. Common when
    /// the binary is under `/home`, `/tmp`, or a removable mount.
    #[cfg(target_os = "linux")]
    BinaryOnNosuidMount,
    // macOS PKTAP reasons
    /// No root privileges for PKTAP
    #[cfg(target_os = "macos")]
    MissingRootPrivileges,
    /// Cannot access BPF devices (/dev/bpf*)
    #[cfg(target_os = "macos")]
    NoBpfDeviceAccess,
    /// BPF filter specified (incompatible with PKTAP)
    #[cfg(target_os = "macos")]
    BpfFilterIncompatible,
    /// Specific interface requested (PKTAP only works with pktap pseudo-device)
    #[cfg(target_os = "macos")]
    InterfaceSpecified,
    // Windows ETW reason
    /// Kernel ETW could not be started; IP Helper polling remains available.
    #[cfg(target_os = "windows")]
    EtwUnavailable,
}

impl DegradationReason {
    /// Get human-readable description of what's needed
    pub fn description(&self) -> Cow<'_, str> {
        match self {
            Self::None => Cow::Borrowed(""),
            #[cfg(target_os = "linux")]
            Self::MissingCapBpf => Cow::Borrowed("needs CAP_BPF"),
            #[cfg(target_os = "linux")]
            Self::MissingCapPerfmon => Cow::Borrowed("needs CAP_PERFMON"),
            #[cfg(target_os = "linux")]
            Self::MissingBpfCapabilities => Cow::Borrowed("needs CAP_BPF+CAP_PERFMON"),
            #[cfg(all(target_os = "linux", not(feature = "ebpf")))]
            Self::EbpfFeatureDisabled => Cow::Borrowed("eBPF feature disabled"),
            #[cfg(target_os = "linux")]
            Self::KernelUnsupported => Cow::Borrowed("kernel unsupported"),
            #[cfg(target_os = "linux")]
            Self::BpfPermissionDenied => Cow::Borrowed(
                "BPF denied (check perf_event_paranoid / AppArmor / unprivileged_bpf_disabled)",
            ),
            #[cfg(target_os = "linux")]
            Self::KprobeAttachFailed(sym) => {
                if sym.is_empty() {
                    Cow::Borrowed("kprobe attach failed")
                } else {
                    Cow::Owned(format!("kprobe attach failed: {sym}"))
                }
            }
            #[cfg(target_os = "linux")]
            Self::BtfUnavailable => Cow::Borrowed("kernel BTF unavailable"),
            #[cfg(target_os = "linux")]
            Self::EbpfLoadFailed(s) => Cow::Owned(format!("eBPF load failed: {s}")),
            #[cfg(target_os = "linux")]
            Self::BinaryOnNosuidMount => {
                Cow::Borrowed("file caps ignored: binary on a nosuid mount")
            }
            #[cfg(target_os = "macos")]
            Self::MissingRootPrivileges => Cow::Borrowed("needs root"),
            #[cfg(target_os = "macos")]
            Self::NoBpfDeviceAccess => Cow::Borrowed("no BPF device access"),
            #[cfg(target_os = "macos")]
            Self::BpfFilterIncompatible => Cow::Borrowed("BPF filter incompatible"),
            #[cfg(target_os = "macos")]
            Self::InterfaceSpecified => Cow::Borrowed("interface specified"),
            #[cfg(target_os = "windows")]
            Self::EtwUnavailable => Cow::Borrowed("May miss short-lived processes"),
        }
    }

    /// Get the name of the unavailable feature
    pub fn unavailable_feature(&self) -> Option<&str> {
        match self {
            Self::None => None,
            #[cfg(target_os = "linux")]
            Self::MissingCapBpf
            | Self::MissingCapPerfmon
            | Self::MissingBpfCapabilities
            | Self::KernelUnsupported
            | Self::BpfPermissionDenied
            | Self::KprobeAttachFailed(_)
            | Self::BtfUnavailable
            | Self::EbpfLoadFailed(_)
            | Self::BinaryOnNosuidMount => Some("eBPF"),
            #[cfg(all(target_os = "linux", not(feature = "ebpf")))]
            Self::EbpfFeatureDisabled => Some("eBPF"),
            #[cfg(target_os = "macos")]
            Self::MissingRootPrivileges
            | Self::NoBpfDeviceAccess
            | Self::BpfFilterIncompatible
            | Self::InterfaceSpecified => Some("PKTAP"),
            #[cfg(target_os = "windows")]
            Self::EtwUnavailable => Some("ETW"),
        }
    }
}

// Platform-specific modules (one cfg per platform instead of many)
#[cfg(target_os = "freebsd")]
mod freebsd;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;

// Re-export the per-platform process-lookup factory.
#[cfg(target_os = "freebsd")]
pub use freebsd::create_process_lookup;
#[cfg(target_os = "linux")]
pub use linux::create_process_lookup;
#[cfg(target_os = "macos")]
pub use macos::{create_process_lookup, report_pktap_degradation};
#[cfg(target_os = "windows")]
pub use windows::create_process_lookup;

/// Trait for platform-specific process lookup
pub trait ProcessLookup: Send + Sync {
    /// Look up process information for a connection
    /// Returns (pid, process_name) if found
    fn get_process_for_connection(&self, conn: &Connection) -> Option<(u32, String)>;

    /// Refresh internal caches if any (best-effort)
    fn refresh(&self) -> Result<()> {
        Ok(()) // Default no-op
    }

    /// Get the detection method name for display purposes
    fn get_detection_method(&self) -> &str;

    /// Get the reason why process detection is degraded (if any)
    /// Returns DegradationReason::None if using optimal detection method
    fn get_degradation_reason(&self) -> DegradationReason {
        DegradationReason::None // Default: no degradation
    }

    /// Fallback lookup that relaxes the connection key to handle sockets stored with
    /// wildcard addresses in OS-level tables.
    ///
    /// Three shapes that actually appear in OS socket tables:
    ///   1. (lip:lport, 0:0)      — listening on a specific local IP
    ///   2. (0:lport,  rip:rport) — INADDR_ANY socket connected to a known remote
    ///   3. (0:lport,  0:0)       — listening on INADDR_ANY
    ///
    /// If two candidates resolve to different processes the result is ambiguous and
    /// `None` is returned to avoid mis-attribution.
    fn fallback_lookup(
        map: &HashMap<ConnectionKey, (u32, String)>,
        key: &ConnectionKey,
    ) -> Option<(u32, String)>
    where
        Self: Sized,
    {
        // Only TCP and UDP sockets appear in OS network tables with wildcard
        // addresses. Other protocols (ICMP, IGMP, ARP) have no entries to fall back to.
        if !matches!(key.protocol, Protocol::Tcp | Protocol::Udp) {
            return None;
        }

        let zero = |addr: SocketAddr| -> IpAddr {
            match addr {
                SocketAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
                SocketAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
            }
        };

        let lip = key.local_addr.ip();
        let lport = key.local_addr.port();
        let rip = key.remote_addr.ip();
        let rport = key.remote_addr.port();
        let zlip = zero(key.local_addr);
        let zrip = zero(key.remote_addr);

        let candidates: [(IpAddr, u16, IpAddr, u16); 3] = [
            (lip, lport, zrip, 0),     // 1. listening on specific local IP
            (zlip, lport, rip, rport), // 2. INADDR_ANY with known remote
            (zlip, lport, zrip, 0),    // 3. INADDR_ANY listener
        ];

        // Collect all matches across every candidate. If two candidates resolve to
        // different processes the result is ambiguous — return nothing to avoid
        // attributing traffic to the wrong process.
        let mut found: Option<(u32, String)> = None;
        for (l_ip, l_port, r_ip, r_port) in candidates {
            let candidate = ConnectionKey {
                protocol: key.protocol,
                local_addr: SocketAddr::new(l_ip, l_port),
                remote_addr: SocketAddr::new(r_ip, r_port),
            };
            if let Some(entry) = map.get(&candidate) {
                match &found {
                    None => found = Some(entry.clone()),
                    Some(existing) if existing == entry => {} // same result, no conflict
                    Some(_) => return None,                   // two different processes → ambiguous
                }
            }
        }
        found
    }
}

/// Connection identifier for lookups
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct ConnectionKey {
    pub protocol: Protocol,
    pub local_addr: SocketAddr,
    pub remote_addr: SocketAddr,
}

impl ConnectionKey {
    pub fn from_connection(conn: &Connection) -> Self {
        Self {
            protocol: conn.protocol,
            local_addr: conn.local_addr,
            remote_addr: conn.remote_addr,
        }
    }
}
