mod port_mapping;
mod probes;

use std::fs;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::{Context, Result};
use netdev::get_default_interface;
use nostr_vpn_core::config::AppConfig;
use nostr_vpn_core::diagnostics::{
    HealthIssue, HealthSeverity, NetcheckReport, NetworkSummary, PortMappingStatus,
};

pub(crate) use self::port_mapping::PortMappingRuntime;
use self::port_mapping::probe_port_mapping_services;
use self::probes::{
    CAPTIVE_PORTAL_ENDPOINTS, check_captive_portal_endpoint, mapping_varies_by_dest_ip,
};
#[cfg(test)]
use self::probes::{CaptivePortalEndpoint, parse_http_response};
#[cfg(target_os = "macos")]
use crate::macos_network::{
    macos_default_routes, macos_ipconfig_ipv4_for_interface, macos_ipconfig_router_for_interface,
    macos_underlay_default_route_from_routes, macos_underlay_default_route_from_system,
};
use crate::{DaemonPeerState, DaemonStatus, unix_timestamp};

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct NetworkSnapshot {
    pub default_interface: Option<String>,
    pub primary_ipv4: Option<Ipv4Addr>,
    pub primary_ipv6: Option<Ipv6Addr>,
    pub gateway_ipv4: Option<Ipv4Addr>,
    pub gateway_ipv6: Option<Ipv6Addr>,
}

impl NetworkSnapshot {
    #[must_use]
    pub(crate) fn fingerprint(&self) -> String {
        [
            self.default_interface.as_deref().unwrap_or(""),
            &self
                .primary_ipv4
                .map_or_else(String::new, |value| value.to_string()),
            &self
                .primary_ipv6
                .map_or_else(String::new, |value| value.to_string()),
            &self
                .gateway_ipv4
                .map_or_else(String::new, |value| value.to_string()),
            &self
                .gateway_ipv6
                .map_or_else(String::new, |value| value.to_string()),
        ]
        .join("|")
    }

    #[must_use]
    pub(crate) fn changed_since(&self, previous: &Self) -> bool {
        self.fingerprint() != previous.fingerprint()
    }

    #[must_use]
    pub(crate) fn summary(
        &self,
        changed_at: Option<u64>,
        captive_portal: Option<bool>,
    ) -> NetworkSummary {
        NetworkSummary {
            default_interface: self.default_interface.clone(),
            primary_ipv4: self.primary_ipv4.map(|value| value.to_string()),
            primary_ipv6: self.primary_ipv6.map(|value| value.to_string()),
            gateway_ipv4: self.gateway_ipv4.map(|value| value.to_string()),
            gateway_ipv6: self.gateway_ipv6.map(|value| value.to_string()),
            changed_at,
            captive_portal,
        }
    }
}

#[must_use]
pub(crate) fn prefer_nonempty_network_snapshot(
    previous: &NetworkSnapshot,
    latest: NetworkSnapshot,
) -> NetworkSnapshot {
    let latest_is_empty = latest.default_interface.is_none()
        && latest.primary_ipv4.is_none()
        && latest.primary_ipv6.is_none()
        && latest.gateway_ipv4.is_none()
        && latest.gateway_ipv6.is_none();
    let previous_has_underlay = previous.default_interface.is_some()
        || previous.primary_ipv4.is_some()
        || previous.primary_ipv6.is_some()
        || previous.gateway_ipv4.is_some()
        || previous.gateway_ipv6.is_some();

    if latest_is_empty && previous_has_underlay {
        previous.clone()
    } else {
        latest
    }
}

pub(crate) fn capture_network_snapshot() -> NetworkSnapshot {
    #[cfg(target_os = "macos")]
    {
        let snapshot = capture_macos_network_snapshot();
        if snapshot.default_interface.is_some()
            || snapshot.primary_ipv4.is_some()
            || snapshot.gateway_ipv4.is_some()
        {
            return snapshot;
        }
    }

    let mut snapshot = NetworkSnapshot::default();
    let Ok(interface) = get_default_interface() else {
        return snapshot;
    };

    snapshot.default_interface = Some(interface.name.clone());
    snapshot.primary_ipv4 = interface
        .ipv4_addrs()
        .into_iter()
        .find(|ip| !ip.is_loopback() && !ip.is_link_local());
    snapshot.primary_ipv6 = interface.ipv6_addrs().into_iter().find(|ip| {
        !ip.is_loopback()
            && !ip.is_unspecified()
            && !ip.is_unicast_link_local()
            && !ip.is_multicast()
    });
    if let Some(gateway) = interface.gateway {
        snapshot.gateway_ipv4 = gateway.ipv4.first().copied();
        snapshot.gateway_ipv6 = gateway.ipv6.first().copied();
    }

    snapshot
}

#[cfg(target_os = "macos")]
fn capture_macos_network_snapshot() -> NetworkSnapshot {
    let mut snapshot = NetworkSnapshot::default();

    let underlay = macos_default_routes()
        .ok()
        .and_then(|routes| {
            macos_underlay_default_route_from_routes(&routes)
                .or_else(|| macos_underlay_default_route_from_system().ok().flatten())
        })
        .or_else(|| macos_underlay_default_route_from_system().ok().flatten());

    let Some(underlay) = underlay else {
        return snapshot;
    };

    snapshot.default_interface = Some(underlay.interface.clone());
    snapshot.primary_ipv4 = macos_ipconfig_ipv4_for_interface(&underlay.interface)
        .ok()
        .flatten();
    snapshot.gateway_ipv4 = underlay
        .gateway
        .as_deref()
        .and_then(|value| value.parse::<Ipv4Addr>().ok())
        .or_else(|| {
            macos_ipconfig_router_for_interface(&underlay.interface)
                .ok()
                .flatten()
        });

    snapshot
}

pub(crate) async fn run_netcheck_report(app: &AppConfig, timeout_secs: u64) -> NetcheckReport {
    let timeout = Duration::from_secs(timeout_secs.max(1));

    // Public endpoint discovery moved to fips-core's overlay-advert path; the
    // netcheck report no longer pre-runs STUN itself.
    let public_v4_endpoints: Vec<String> = Vec::new();
    let _ = (app, timeout);

    let snapshot = capture_network_snapshot();
    let port_mapping = probe_port_mapping_services(&snapshot, timeout).await;
    let captive_portal = detect_captive_portal(timeout).await;

    NetcheckReport {
        checked_at: unix_timestamp(),
        udp: !public_v4_endpoints.is_empty(),
        ipv4: !public_v4_endpoints.is_empty(),
        ipv6: snapshot.primary_ipv6.is_some(),
        public_ipv4: public_v4_endpoints.first().cloned(),
        public_ipv6: None,
        mapping_varies_by_dest_ip: mapping_varies_by_dest_ip(&public_v4_endpoints),
        captive_portal,
        port_mapping,
    }
}

pub(crate) fn build_health_issues(
    app: &AppConfig,
    vpn_active: bool,
    _mesh_ready: bool,
    network: &NetworkSummary,
    port_mapping: &PortMappingStatus,
    peers: &[DaemonPeerState],
) -> Vec<HealthIssue> {
    let mut issues = Vec::new();

    if vpn_active && network.captive_portal == Some(true) {
        issues.push(HealthIssue::new(
            "network.captive_portal",
            HealthSeverity::Critical,
            "Captive portal detected",
            "This network appears to intercept HTTP connectivity checks. VPN bootstrap may fail until the portal is cleared.",
        ));
    }

    if vpn_active
        && port_mapping.active_protocol.is_none()
        && network.primary_ipv4.is_none()
        && network.primary_ipv6.is_none()
    {
        issues.push(HealthIssue::new(
            "network.no_primary_address",
            HealthSeverity::Critical,
            "No primary network address detected",
            "No usable default interface address was detected for announcing this node.",
        ));
    }

    if vpn_active
        && port_mapping.active_protocol.is_none()
        && app.nat.enabled
        && network.primary_ipv4.is_some()
    {
        issues.push(HealthIssue::new(
            "nat.no_public_mapping",
            HealthSeverity::Info,
            "No active port mapping",
            "Direct connectivity may still succeed via STUN or LAN discovery, but no PCP/NAT-PMP/UPnP mapping is currently active.",
        ));
    }

    if vpn_active && !app.exit_node.is_empty() {
        let selected_peer = peers
            .iter()
            .find(|peer| peer.participant_pubkey == app.exit_node);
        match selected_peer {
            Some(peer) if !peer.reachable => issues.push(HealthIssue::new(
                "exit_node.offline",
                HealthSeverity::Critical,
                "Selected exit node is offline",
                "Default-route traffic is pinned to a peer that does not currently have a recent handshake.",
            )),
            Some(peer)
                if !peer
                    .advertised_routes
                    .iter()
                    .any(|route| route == "0.0.0.0/0" || route == "::/0") =>
            {
                issues.push(HealthIssue::new(
                    "exit_node.unavailable",
                    HealthSeverity::Warning,
                    "Selected exit node is not advertising default routes",
                    "Choose a peer that offers exit-node routes or clear the exit-node setting.",
                ));
            }
            None => issues.push(HealthIssue::new(
                "exit_node.unknown",
                HealthSeverity::Warning,
                "Selected exit node is not present",
                "The configured exit-node peer is not part of the currently known runtime peer set.",
            )),
            Some(_) => {}
        }
    }

    if vpn_active
        && peers
            .iter()
            .any(|peer| peer.error.as_deref() == Some("signal stale"))
    {
        issues.push(HealthIssue::new(
            "peer.signal_stale",
            HealthSeverity::Warning,
            "One or more peers have stale signaling",
            "The tunnel can keep running from cached paths, but one or more peer announcements have expired.",
        ));
    }

    issues
}

#[allow(clippy::too_many_arguments)]
pub(crate) async fn write_doctor_bundle(
    path: &Path,
    app: &AppConfig,
    network_id: &str,
    daemon_status: &DaemonStatus,
    network: &NetworkSummary,
    port_mapping: &PortMappingStatus,
    issues: &[HealthIssue],
    netcheck: &NetcheckReport,
    log_tail: &str,
) -> Result<PathBuf> {
    let output_path = if path.extension().is_some() {
        path.to_path_buf()
    } else {
        path.join(format!("nvpn-doctor-{}.json", unix_timestamp()))
    };
    if let Some(parent) = output_path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }

    let daemon_state_raw = if daemon_status.state_file.exists() {
        fs::read_to_string(&daemon_status.state_file).unwrap_or_default()
    } else {
        String::new()
    };

    let bundle = serde_json::json!({
        "generatedAt": unix_timestamp(),
        "networkId": network_id,
        "config": sanitized_config_json(app),
        "daemon": {
            "running": daemon_status.running,
            "pid": daemon_status.pid,
            "stateFile": daemon_status.state_file,
            "logFile": daemon_status.log_file,
            "state": daemon_status.state,
            "rawState": daemon_state_raw,
        },
        "network": network,
        "portMapping": port_mapping,
        "health": issues,
        "netcheck": netcheck,
        "logTail": log_tail,
    });
    fs::write(&output_path, serde_json::to_vec_pretty(&bundle)?)
        .with_context(|| format!("failed to write {}", output_path.display()))?;

    Ok(output_path)
}

fn sanitized_config_json(app: &AppConfig) -> serde_json::Value {
    serde_json::json!({
        "networkId": app.effective_network_id(),
        "nodeName": app.node_name,
        "autoconnect": app.autoconnect,
        "magicDnsSuffix": app.magic_dns_suffix,
        "exitNode": app.exit_node,
        "nostr": {
            "publicKey": app.nostr.public_key,
            "relays": app.nostr.relays,
        },
        "node": {
            "id": app.node.id,
            "endpoint": app.node.endpoint,
            "tunnelIp": app.node.tunnel_ip,
            "listenPort": app.node.listen_port,
            "advertisedRoutes": app.node.advertised_routes,
            "advertiseExitNode": app.node.advertise_exit_node,
        },
        "wireguardExit": {
            "enabled": app.wireguard_exit.enabled,
            "configured": app.wireguard_exit.configured(),
            "interface": &app.wireguard_exit.interface,
            "address": &app.wireguard_exit.address,
            "endpoint": &app.wireguard_exit.endpoint,
            "allowedIps": &app.wireguard_exit.allowed_ips,
            "dns": &app.wireguard_exit.dns,
            "mtu": app.wireguard_exit.mtu,
            "persistentKeepaliveSecs": app.wireguard_exit.persistent_keepalive_secs,
            "privateKeySet": !app.wireguard_exit.private_key.trim().is_empty(),
            "peerPresharedKeySet": !app.wireguard_exit.peer_preshared_key.trim().is_empty(),
            "peerPublicKeySet": !app.wireguard_exit.peer_public_key.trim().is_empty(),
        },
        "networks": app.networks,
    })
}

pub(crate) async fn detect_captive_portal(timeout: Duration) -> Option<bool> {
    for endpoint in CAPTIVE_PORTAL_ENDPOINTS {
        match tokio::task::spawn_blocking({
            let endpoint = *endpoint;
            move || check_captive_portal_endpoint(endpoint, timeout)
        })
        .await
        .ok()
        .flatten()
        {
            Some(found) => return Some(found),
            None => continue,
        }
    }

    None
}

#[cfg(test)]
mod tests {
    use super::probes::{probe_nat_pmp_server, probe_pcp_server, probe_upnp_ssdp_server};
    use super::{
        CaptivePortalEndpoint, NetworkSnapshot, build_health_issues, check_captive_portal_endpoint,
        mapping_varies_by_dest_ip, parse_http_response, prefer_nonempty_network_snapshot,
    };
    use nostr_vpn_core::config::AppConfig;
    use nostr_vpn_core::diagnostics::ProbeState;

    use crate::DaemonPeerState;

    use std::io::{Read, Write};
    use std::net::{IpAddr, Ipv4Addr, TcpListener, UdpSocket};
    use std::thread;
    use std::time::Duration;

    #[test]
    fn network_snapshot_change_detection_uses_fingerprint() {
        let left = NetworkSnapshot {
            default_interface: Some("en0".to_string()),
            primary_ipv4: Some(Ipv4Addr::new(192, 168, 1, 5)),
            ..NetworkSnapshot::default()
        };
        let right = NetworkSnapshot {
            default_interface: Some("en1".to_string()),
            primary_ipv4: Some(Ipv4Addr::new(192, 168, 1, 5)),
            ..NetworkSnapshot::default()
        };

        assert!(right.changed_since(&left));
    }

    #[test]
    fn empty_network_snapshot_does_not_replace_known_underlay() {
        let previous = NetworkSnapshot {
            default_interface: Some("en0".to_string()),
            primary_ipv4: Some(Ipv4Addr::new(192, 168, 64, 2)),
            gateway_ipv4: Some(Ipv4Addr::new(192, 168, 64, 1)),
            ..NetworkSnapshot::default()
        };

        let preferred = prefer_nonempty_network_snapshot(&previous, NetworkSnapshot::default());

        assert_eq!(preferred, previous);
    }

    #[test]
    fn mapping_varies_by_dest_ip_requires_multiple_distinct_addresses() {
        assert_eq!(
            mapping_varies_by_dest_ip(&[
                "203.0.113.10:51820".to_string(),
                "203.0.113.10:40000".to_string(),
            ]),
            Some(false)
        );
        assert_eq!(
            mapping_varies_by_dest_ip(&[
                "203.0.113.10:51820".to_string(),
                "203.0.113.20:40000".to_string(),
            ]),
            Some(true)
        );
    }

    #[test]
    fn nat_pmp_probe_detects_gateway_response() {
        let server = UdpSocket::bind("127.0.0.1:0").expect("bind natpmp server");
        let addr = server.local_addr().expect("natpmp addr");
        thread::spawn(move || {
            let mut buf = [0_u8; 64];
            let (read, peer) = server.recv_from(&mut buf).expect("recv natpmp");
            assert_eq!(&buf[..read], &[0, 0]);
            let response = [0_u8, 128, 0, 0, 0, 0, 0, 1, 203, 0, 113, 20];
            server.send_to(&response, peer).expect("send natpmp");
        });

        let status = probe_nat_pmp_server(addr, Duration::from_secs(1));
        assert_eq!(status.state, ProbeState::Available);
    }

    #[test]
    fn pcp_probe_detects_gateway_response() {
        let server = UdpSocket::bind("127.0.0.1:0").expect("bind pcp server");
        let addr = server.local_addr().expect("pcp addr");
        thread::spawn(move || {
            let mut buf = [0_u8; 128];
            let (_read, peer) = server.recv_from(&mut buf).expect("recv pcp");
            let mut response = [0_u8; 24];
            response[0] = 2;
            response[1] = 0x80;
            response[3] = 0;
            response[11] = 1;
            server.send_to(&response, peer).expect("send pcp");
        });

        let status = probe_pcp_server(
            addr,
            Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 9))),
            Duration::from_secs(1),
        );
        assert_eq!(status.state, ProbeState::Available);
    }

    #[test]
    fn upnp_probe_detects_ssdp_response() {
        let server = UdpSocket::bind("127.0.0.1:0").expect("bind ssdp server");
        let addr = server.local_addr().expect("ssdp addr");
        thread::spawn(move || {
            let mut buf = [0_u8; 2048];
            let (_read, peer) = server.recv_from(&mut buf).expect("recv ssdp");
            let response = concat!(
                "HTTP/1.1 200 OK\r\n",
                "LOCATION: http://127.0.0.1/rootDesc.xml\r\n",
                "ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n",
                "\r\n"
            );
            server
                .send_to(response.as_bytes(), peer)
                .expect("send ssdp");
        });

        let status = probe_upnp_ssdp_server(addr, Duration::from_secs(1));
        assert_eq!(status.state, ProbeState::Available);
    }

    #[test]
    fn captive_portal_check_flags_redirects() {
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind tcp");
        let addr = listener.local_addr().expect("listener addr");
        thread::spawn(move || {
            let (mut stream, _) = listener.accept().expect("accept");
            let mut request = [0_u8; 1024];
            let _ = stream.read(&mut request);
            stream
                .write_all(
                    b"HTTP/1.1 302 Found\r\nLocation: http://login/\r\nContent-Length: 0\r\n\r\n",
                )
                .expect("write");
        });

        let endpoint = CaptivePortalEndpoint {
            url: Box::leak(format!("http://{addr}/generate_204").into_boxed_str()),
            expected_status: 204,
            expected_prefix: "",
        };

        assert_eq!(
            check_captive_portal_endpoint(endpoint, Duration::from_secs(1)),
            Some(true)
        );
    }

    #[test]
    fn parse_http_response_extracts_status_and_body() {
        let (status, body) = parse_http_response("HTTP/1.1 204 No Content\r\nX-Test: ok\r\n\r\n")
            .expect("parse response");
        assert_eq!(status, 204);
        assert_eq!(body, "");
    }

    #[test]
    fn health_issues_flag_selected_exit_node_when_offline() {
        let app = AppConfig {
            exit_node: "peer-a".to_string(),
            ..AppConfig::default()
        };
        let network = NetworkSnapshot {
            default_interface: Some("en0".to_string()),
            primary_ipv4: Some(Ipv4Addr::new(192, 168, 1, 4)),
            ..NetworkSnapshot::default()
        }
        .summary(Some(10), Some(false));
        let issues = build_health_issues(
            &app,
            true,
            false,
            &network,
            &Default::default(),
            &[DaemonPeerState {
                participant_pubkey: "peer-a".to_string(),
                node_id: "node-a".to_string(),
                tunnel_ip: "10.44.0.2/32".to_string(),
                endpoint: "203.0.113.20:51820".to_string(),
                runtime_endpoint: None,
                fips_endpoint_npub: String::new(),
                fips_transport_addr: String::new(),
                fips_transport_type: String::new(),
                fips_srtt_ms: None,
                fips_packets_sent: 0,
                fips_packets_recv: 0,
                fips_bytes_sent: 0,
                fips_bytes_recv: 0,
                tx_bytes: 0,
                rx_bytes: 0,
                public_key: "pk".to_string(),
                advertised_routes: vec!["0.0.0.0/0".to_string()],
                last_mesh_seen_at: 1,
                last_fips_seen_at: Some(1),
                reachable: false,
                last_handshake_at: None,
                error: Some("awaiting handshake".to_string()),
            }],
        );

        assert!(issues.iter().any(|issue| issue.code == "exit_node.offline"));
    }

    #[test]
    fn health_issues_skip_exit_node_warning_when_vpn_is_inactive() {
        let app = AppConfig {
            exit_node: "peer-a".to_string(),
            ..AppConfig::default()
        };
        let network = NetworkSnapshot {
            default_interface: Some("en0".to_string()),
            primary_ipv4: Some(Ipv4Addr::new(192, 168, 1, 4)),
            ..NetworkSnapshot::default()
        }
        .summary(Some(10), Some(false));

        let issues = build_health_issues(&app, false, false, &network, &Default::default(), &[]);
        assert!(issues.iter().all(|issue| issue.code != "exit_node.unknown"));
    }
}
