use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;

use async_trait::async_trait;
use igd_next::aio::Gateway as IgdGatewayHandle;
use igd_next::aio::tokio::{Tokio as IgdTokio, search_gateway};
use igd_next::{PortMappingProtocol, SearchOptions};
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};

use crate::unified_config::PortMappingConfig;

const DISCOVERY_RETRY_DELAY: Duration = Duration::from_secs(2);
const ZERO_LEASE_REFRESH_INTERVAL: Duration = Duration::from_secs(300);
const PORT_MAPPING_DESCRIPTION: &str = "ant-quic";

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct PortMappingSnapshot {
    pub active: bool,
    pub external_addr: Option<SocketAddr>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PortMappingEvent {
    Established { snapshot: PortMappingSnapshot },
    Renewed { snapshot: PortMappingSnapshot },
    Failed { error: String },
    Removed { external_addr: Option<SocketAddr> },
}

#[derive(Debug, thiserror::Error)]
enum PortMappingError {
    #[error("gateway discovery failed: {0}")]
    Discovery(String),
    #[error("gateway external IP lookup failed: {0}")]
    ExternalIp(String),
    #[error("router port-mapping request failed: {0}")]
    AddPort(String),
    #[error("router random-port-mapping request failed: {0}")]
    AddAnyPort(String),
    #[error("router mapping cleanup failed: {0}")]
    RemovePort(String),
    #[error("gateway address {0} is not IPv4")]
    UnsupportedGatewayAddress(SocketAddr),
    #[error("failed to determine LAN IPv4 via gateway {gateway}: {reason}")]
    DetermineLanIpv4 { gateway: SocketAddr, reason: String },
}

struct ActivePortMapping {
    gateway: Box<dyn GatewayControl>,
    local_addr: SocketAddr,
    external_addr: SocketAddr,
}

#[async_trait]
trait GatewayControl: Send + Sync {
    fn gateway_addr(&self) -> SocketAddr;

    async fn get_external_ip(&self) -> Result<IpAddr, PortMappingError>;

    async fn add_port(
        &self,
        external_port: u16,
        local_addr: SocketAddr,
        lease_duration_secs: u32,
        description: &str,
    ) -> Result<(), PortMappingError>;

    async fn add_any_port(
        &self,
        local_addr: SocketAddr,
        lease_duration_secs: u32,
        description: &str,
    ) -> Result<u16, PortMappingError>;

    async fn remove_port(&self, external_port: u16) -> Result<(), PortMappingError>;
}

#[async_trait]
trait GatewayDiscoverer: Send + Sync {
    async fn discover(&self) -> Result<Box<dyn GatewayControl>, PortMappingError>;
}

#[derive(Debug, Default)]
struct IgdGatewayDiscoverer;

#[derive(Clone)]
struct IgdGatewayClient {
    gateway: IgdGatewayHandle<IgdTokio>,
}

#[async_trait]
impl GatewayDiscoverer for IgdGatewayDiscoverer {
    async fn discover(&self) -> Result<Box<dyn GatewayControl>, PortMappingError> {
        let gateway = search_gateway(SearchOptions {
            bind_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
            timeout: Some(Duration::from_secs(3)),
            single_search_timeout: Some(Duration::from_secs(1)),
            ..SearchOptions::default()
        })
        .await
        .map_err(|error| PortMappingError::Discovery(error.to_string()))?;

        Ok(Box::new(IgdGatewayClient { gateway }))
    }
}

#[async_trait]
impl GatewayControl for IgdGatewayClient {
    fn gateway_addr(&self) -> SocketAddr {
        self.gateway.addr
    }

    async fn get_external_ip(&self) -> Result<IpAddr, PortMappingError> {
        self.gateway
            .get_external_ip()
            .await
            .map_err(|error| PortMappingError::ExternalIp(error.to_string()))
    }

    async fn add_port(
        &self,
        external_port: u16,
        local_addr: SocketAddr,
        lease_duration_secs: u32,
        description: &str,
    ) -> Result<(), PortMappingError> {
        self.gateway
            .add_port(
                PortMappingProtocol::UDP,
                external_port,
                local_addr,
                lease_duration_secs,
                description,
            )
            .await
            .map_err(|error| PortMappingError::AddPort(error.to_string()))
    }

    async fn add_any_port(
        &self,
        local_addr: SocketAddr,
        lease_duration_secs: u32,
        description: &str,
    ) -> Result<u16, PortMappingError> {
        self.gateway
            .add_any_port(
                PortMappingProtocol::UDP,
                local_addr,
                lease_duration_secs,
                description,
            )
            .await
            .map_err(|error| PortMappingError::AddAnyPort(error.to_string()))
    }

    async fn remove_port(&self, external_port: u16) -> Result<(), PortMappingError> {
        self.gateway
            .remove_port(PortMappingProtocol::UDP, external_port)
            .await
            .map_err(|error| PortMappingError::RemovePort(error.to_string()))
    }
}

pub(crate) fn spawn_best_effort_port_mapping<F>(
    config: PortMappingConfig,
    internal_port: u16,
    shutdown: CancellationToken,
    on_update: F,
) where
    F: FnMut(PortMappingEvent) + Send + 'static,
{
    tokio::spawn(async move {
        run_port_mapping_lifecycle(
            IgdGatewayDiscoverer,
            config,
            internal_port,
            shutdown,
            on_update,
        )
        .await;
    });
}

async fn run_port_mapping_lifecycle<D, F>(
    discoverer: D,
    config: PortMappingConfig,
    internal_port: u16,
    shutdown: CancellationToken,
    mut on_update: F,
) where
    D: GatewayDiscoverer,
    F: FnMut(PortMappingEvent) + Send,
{
    let mut published_snapshot = PortMappingSnapshot::default();
    let mut active_mapping: Option<ActivePortMapping> = None;
    let mut last_failure: Option<String> = None;

    loop {
        if shutdown.is_cancelled() {
            break;
        }

        if active_mapping.is_none() {
            match establish_mapping(&discoverer, config, internal_port).await {
                Ok(mapping) => {
                    last_failure = None;
                    let snapshot = PortMappingSnapshot {
                        active: true,
                        external_addr: Some(mapping.external_addr),
                    };
                    publish_snapshot(&mut published_snapshot, snapshot, &mut on_update);
                    on_update(PortMappingEvent::Established { snapshot });
                    info!(
                        internal_addr = %mapping.local_addr,
                        external_addr = %mapping.external_addr,
                        "Best-effort router port mapping is active"
                    );
                    active_mapping = Some(mapping);
                }
                Err(error) => {
                    let error_message = error.to_string();
                    if last_failure.as_deref() != Some(error_message.as_str()) {
                        on_update(PortMappingEvent::Failed {
                            error: error_message.clone(),
                        });
                        last_failure = Some(error_message);
                    }
                    debug!(error = %error, "Best-effort router port mapping unavailable");
                    publish_snapshot(
                        &mut published_snapshot,
                        PortMappingSnapshot::default(),
                        &mut on_update,
                    );
                    tokio::select! {
                        _ = shutdown.cancelled() => break,
                        _ = tokio::time::sleep(DISCOVERY_RETRY_DELAY) => {}
                    }
                }
            }
            continue;
        }

        let renewal_delay = renewal_interval(config.lease_duration_secs);

        tokio::select! {
            _ = shutdown.cancelled() => break,
            _ = tokio::time::sleep(renewal_delay) => {}
        }

        let Some(mapping) = active_mapping.as_mut() else {
            continue;
        };

        match renew_mapping(mapping, config).await {
            Ok(changed) => {
                last_failure = None;
                let snapshot = PortMappingSnapshot {
                    active: true,
                    external_addr: Some(mapping.external_addr),
                };
                if changed {
                    publish_snapshot(&mut published_snapshot, snapshot, &mut on_update);
                    info!(
                        internal_addr = %mapping.local_addr,
                        external_addr = %mapping.external_addr,
                        "Best-effort router port mapping external address refreshed"
                    );
                }
                on_update(PortMappingEvent::Renewed { snapshot });
            }
            Err(error) => {
                on_update(PortMappingEvent::Failed {
                    error: error.to_string(),
                });
                warn!(
                    error = %error,
                    external_addr = %mapping.external_addr,
                    "Router port-mapping renewal failed; dropping mapping state and retrying"
                );
                let failed_mapping = active_mapping.take();
                publish_snapshot(
                    &mut published_snapshot,
                    PortMappingSnapshot::default(),
                    &mut on_update,
                );
                if let Some(mapping) = failed_mapping.as_ref() {
                    let _ = cleanup_mapping(mapping).await;
                    on_update(PortMappingEvent::Removed {
                        external_addr: Some(mapping.external_addr),
                    });
                }
            }
        }
    }

    if let Some(mapping) = active_mapping.as_ref() {
        if let Err(error) = cleanup_mapping(mapping).await {
            debug!(
                error = %error,
                external_addr = %mapping.external_addr,
                "Best-effort router port-mapping cleanup failed during shutdown"
            );
        }
        on_update(PortMappingEvent::Removed {
            external_addr: Some(mapping.external_addr),
        });
    }

    publish_snapshot(
        &mut published_snapshot,
        PortMappingSnapshot::default(),
        &mut on_update,
    );
}

async fn establish_mapping<D>(
    discoverer: &D,
    config: PortMappingConfig,
    internal_port: u16,
) -> Result<ActivePortMapping, PortMappingError>
where
    D: GatewayDiscoverer,
{
    let gateway = discoverer.discover().await?;
    let local_addr = determine_lan_ipv4(gateway.gateway_addr(), internal_port).await?;
    let external_ip = gateway.get_external_ip().await?;

    let external_port = match gateway
        .add_port(
            internal_port,
            local_addr,
            config.lease_duration_secs,
            PORT_MAPPING_DESCRIPTION,
        )
        .await
    {
        Ok(()) => internal_port,
        Err(error) if config.allow_random_external_port => {
            warn!(
                error = %error,
                internal_addr = %local_addr,
                "Same-port mapping failed; falling back to a random external port"
            );
            gateway
                .add_any_port(
                    local_addr,
                    config.lease_duration_secs,
                    PORT_MAPPING_DESCRIPTION,
                )
                .await?
        }
        Err(error) => return Err(error),
    };

    Ok(ActivePortMapping {
        gateway,
        local_addr,
        external_addr: SocketAddr::new(external_ip, external_port),
    })
}

async fn renew_mapping(
    mapping: &mut ActivePortMapping,
    config: PortMappingConfig,
) -> Result<bool, PortMappingError> {
    mapping
        .gateway
        .add_port(
            mapping.external_addr.port(),
            mapping.local_addr,
            config.lease_duration_secs,
            PORT_MAPPING_DESCRIPTION,
        )
        .await?;

    let refreshed_addr = SocketAddr::new(
        mapping.gateway.get_external_ip().await?,
        mapping.external_addr.port(),
    );
    let changed = mapping.external_addr != refreshed_addr;
    mapping.external_addr = refreshed_addr;

    Ok(changed)
}

async fn cleanup_mapping(mapping: &ActivePortMapping) -> Result<(), PortMappingError> {
    mapping
        .gateway
        .remove_port(mapping.external_addr.port())
        .await
}

async fn determine_lan_ipv4(
    gateway_addr: SocketAddr,
    internal_port: u16,
) -> Result<SocketAddr, PortMappingError> {
    let gateway_ip = match gateway_addr.ip() {
        IpAddr::V4(ipv4) => ipv4,
        _ => return Err(PortMappingError::UnsupportedGatewayAddress(gateway_addr)),
    };

    let probe_socket =
        tokio::net::UdpSocket::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0))
            .await
            .map_err(|error| PortMappingError::DetermineLanIpv4 {
                gateway: gateway_addr,
                reason: error.to_string(),
            })?;

    probe_socket
        .connect(SocketAddr::new(IpAddr::V4(gateway_ip), gateway_addr.port()))
        .await
        .map_err(|error| PortMappingError::DetermineLanIpv4 {
            gateway: gateway_addr,
            reason: error.to_string(),
        })?;

    let local_addr =
        probe_socket
            .local_addr()
            .map_err(|error| PortMappingError::DetermineLanIpv4 {
                gateway: gateway_addr,
                reason: error.to_string(),
            })?;

    match local_addr.ip() {
        IpAddr::V4(ipv4) => Ok(SocketAddr::new(IpAddr::V4(ipv4), internal_port)),
        _ => Err(PortMappingError::DetermineLanIpv4 {
            gateway: gateway_addr,
            reason: format!("temporary local address {} was not IPv4", local_addr),
        }),
    }
}

fn renewal_interval(lease_duration_secs: u32) -> Duration {
    if lease_duration_secs == 0 {
        ZERO_LEASE_REFRESH_INTERVAL
    } else {
        Duration::from_secs(u64::from((lease_duration_secs / 2).max(1)))
    }
}

fn publish_snapshot<F>(
    published_snapshot: &mut PortMappingSnapshot,
    next_snapshot: PortMappingSnapshot,
    _on_update: &mut F,
) where
    F: FnMut(PortMappingEvent),
{
    if *published_snapshot != next_snapshot {
        *published_snapshot = next_snapshot;
    }
}

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

    use std::collections::{HashMap, VecDeque};
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::{Arc, Mutex};

    use mock_igd::{Action, MockIgdServer, Protocol, Responder};

    struct TestGateway {
        gateway_addr: SocketAddr,
        external_ip: Mutex<IpAddr>,
        add_port_results: Mutex<VecDeque<Result<(), String>>>,
        add_any_port_results: Mutex<VecDeque<Result<u16, String>>>,
        add_port_calls: Mutex<Vec<u16>>,
        add_any_port_calls: Mutex<usize>,
        remove_port_calls: Mutex<Vec<u16>>,
    }

    impl TestGateway {
        fn new(gateway_addr: SocketAddr, external_ip: IpAddr) -> Self {
            Self {
                gateway_addr,
                external_ip: Mutex::new(external_ip),
                add_port_results: Mutex::new(VecDeque::new()),
                add_any_port_results: Mutex::new(VecDeque::new()),
                add_port_calls: Mutex::new(Vec::new()),
                add_any_port_calls: Mutex::new(0),
                remove_port_calls: Mutex::new(Vec::new()),
            }
        }
    }

    #[derive(Clone)]
    struct TestGatewayHandle {
        inner: Arc<TestGateway>,
    }

    #[async_trait]
    impl GatewayControl for TestGatewayHandle {
        fn gateway_addr(&self) -> SocketAddr {
            self.inner.gateway_addr
        }

        async fn get_external_ip(&self) -> Result<IpAddr, PortMappingError> {
            Ok(*self.inner.external_ip.lock().expect("lock external_ip"))
        }

        async fn add_port(
            &self,
            external_port: u16,
            _local_addr: SocketAddr,
            _lease_duration_secs: u32,
            _description: &str,
        ) -> Result<(), PortMappingError> {
            self.inner
                .add_port_calls
                .lock()
                .expect("lock add_port_calls")
                .push(external_port);
            self.inner
                .add_port_results
                .lock()
                .expect("lock add_port_results")
                .pop_front()
                .unwrap_or(Ok(()))
                .map_err(PortMappingError::AddPort)
        }

        async fn add_any_port(
            &self,
            _local_addr: SocketAddr,
            _lease_duration_secs: u32,
            _description: &str,
        ) -> Result<u16, PortMappingError> {
            *self
                .inner
                .add_any_port_calls
                .lock()
                .expect("lock add_any_port_calls") += 1;
            self.inner
                .add_any_port_results
                .lock()
                .expect("lock add_any_port_results")
                .pop_front()
                .unwrap_or(Err("missing add_any_port result".to_string()))
                .map_err(PortMappingError::AddAnyPort)
        }

        async fn remove_port(&self, external_port: u16) -> Result<(), PortMappingError> {
            self.inner
                .remove_port_calls
                .lock()
                .expect("lock remove_port_calls")
                .push(external_port);
            Ok(())
        }
    }

    struct TestDiscoverer {
        gateway: Arc<TestGateway>,
        failures_before_success: AtomicUsize,
    }

    #[async_trait]
    impl GatewayDiscoverer for TestDiscoverer {
        async fn discover(&self) -> Result<Box<dyn GatewayControl>, PortMappingError> {
            let remaining = self.failures_before_success.load(Ordering::SeqCst);
            if remaining > 0 {
                self.failures_before_success.fetch_sub(1, Ordering::SeqCst);
                return Err(PortMappingError::Discovery(
                    "scripted discovery failure".to_string(),
                ));
            }

            Ok(Box::new(TestGatewayHandle {
                inner: Arc::clone(&self.gateway),
            }))
        }
    }

    fn collect_events() -> (
        Arc<Mutex<Vec<PortMappingEvent>>>,
        impl FnMut(PortMappingEvent) + Send + 'static,
    ) {
        let events = Arc::new(Mutex::new(Vec::new()));
        let events_clone = Arc::clone(&events);
        let on_update = move |event: PortMappingEvent| {
            events_clone
                .lock()
                .expect("lock port-mapping events")
                .push(event);
        };
        (events, on_update)
    }

    async fn wait_for_events(
        events: &Arc<Mutex<Vec<PortMappingEvent>>>,
        min_len: usize,
    ) -> Vec<PortMappingEvent> {
        let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
        loop {
            let current = events.lock().expect("lock events").clone();
            if current.len() >= min_len {
                return current;
            }

            assert!(
                tokio::time::Instant::now() < deadline,
                "timed out waiting for {} port-mapping events; got {:?}",
                min_len,
                current
            );
            tokio::time::sleep(Duration::from_millis(25)).await;
        }
    }

    fn mock_gateway(server: &MockIgdServer) -> IgdGatewayClient {
        let mut control_schema = HashMap::new();
        control_schema.insert("GetExternalIPAddress".to_string(), Vec::new());
        control_schema.insert(
            "AddPortMapping".to_string(),
            vec![
                "NewRemoteHost".to_string(),
                "NewExternalPort".to_string(),
                "NewProtocol".to_string(),
                "NewInternalPort".to_string(),
                "NewInternalClient".to_string(),
                "NewEnabled".to_string(),
                "NewPortMappingDescription".to_string(),
                "NewLeaseDuration".to_string(),
            ],
        );
        control_schema.insert(
            "DeletePortMapping".to_string(),
            vec![
                "NewRemoteHost".to_string(),
                "NewExternalPort".to_string(),
                "NewProtocol".to_string(),
            ],
        );

        IgdGatewayClient {
            gateway: IgdGatewayHandle {
                addr: server.http_addr(),
                root_url: "/rootDesc.xml".to_string(),
                control_url: "/ctl/IPConn".to_string(),
                control_schema_url: "/WANIPCn.xml".to_string(),
                control_schema,
                provider: IgdTokio,
            },
        }
    }

    #[tokio::test]
    async fn test_mapping_success_updates_snapshot_and_cleans_up() {
        let gateway = Arc::new(TestGateway::new(
            "127.0.0.1:1900".parse().expect("valid gateway"),
            "203.0.113.10".parse().expect("valid external IP"),
        ));
        let discoverer = TestDiscoverer {
            gateway: Arc::clone(&gateway),
            failures_before_success: AtomicUsize::new(0),
        };
        let shutdown = CancellationToken::new();
        let (events, on_update) = collect_events();

        let task = tokio::spawn(run_port_mapping_lifecycle(
            discoverer,
            PortMappingConfig::default(),
            31000,
            shutdown.clone(),
            on_update,
        ));

        let events = wait_for_events(&events, 1).await;
        assert_eq!(
            events[0],
            PortMappingEvent::Established {
                snapshot: PortMappingSnapshot {
                    active: true,
                    external_addr: Some("203.0.113.10:31000".parse().expect("valid mapped addr")),
                },
            }
        );

        shutdown.cancel();
        task.await.expect("port-mapping task should exit cleanly");

        let removed_ports = gateway
            .remove_port_calls
            .lock()
            .expect("lock remove_port_calls")
            .clone();
        assert_eq!(removed_ports, vec![31000]);
    }

    #[tokio::test]
    async fn test_same_port_conflict_falls_back_to_random_port() {
        let gateway = Arc::new(TestGateway::new(
            "127.0.0.1:1900".parse().expect("valid gateway"),
            "203.0.113.20".parse().expect("valid external IP"),
        ));
        gateway
            .add_port_results
            .lock()
            .expect("lock add_port_results")
            .push_back(Err("conflict".to_string()));
        gateway
            .add_any_port_results
            .lock()
            .expect("lock add_any_port_results")
            .push_back(Ok(41000));

        let discoverer = TestDiscoverer {
            gateway: Arc::clone(&gateway),
            failures_before_success: AtomicUsize::new(0),
        };
        let shutdown = CancellationToken::new();
        let (events, on_update) = collect_events();

        let task = tokio::spawn(run_port_mapping_lifecycle(
            discoverer,
            PortMappingConfig::default(),
            31001,
            shutdown.clone(),
            on_update,
        ));

        let events = wait_for_events(&events, 1).await;
        assert!(events.iter().any(|event| matches!(
            event,
            PortMappingEvent::Established { snapshot }
                if snapshot.external_addr == Some("203.0.113.20:41000".parse().expect("valid mapped addr"))
        )));
        assert_eq!(
            *gateway
                .add_any_port_calls
                .lock()
                .expect("lock add_any_port_calls"),
            1
        );

        shutdown.cancel();
        task.await.expect("port-mapping task should exit cleanly");
    }

    #[tokio::test]
    async fn test_renewal_reuses_existing_external_port() {
        let gateway = Arc::new(TestGateway::new(
            "127.0.0.1:1900".parse().expect("valid gateway"),
            "203.0.113.30".parse().expect("valid external IP"),
        ));
        gateway
            .add_port_results
            .lock()
            .expect("lock add_port_results")
            .extend([Ok(()), Ok(())]);

        let discoverer = TestDiscoverer {
            gateway: Arc::clone(&gateway),
            failures_before_success: AtomicUsize::new(0),
        };
        let shutdown = CancellationToken::new();
        let (_events, on_update) = collect_events();

        let task = tokio::spawn(run_port_mapping_lifecycle(
            discoverer,
            PortMappingConfig {
                lease_duration_secs: 2,
                ..PortMappingConfig::default()
            },
            31002,
            shutdown.clone(),
            on_update,
        ));

        tokio::time::sleep(Duration::from_millis(1200)).await;
        shutdown.cancel();
        task.await.expect("port-mapping task should exit cleanly");

        let add_port_calls = gateway
            .add_port_calls
            .lock()
            .expect("lock add_port_calls")
            .clone();
        assert!(
            add_port_calls.len() >= 2,
            "expected at least initial map and one renewal, got {:?}",
            add_port_calls
        );
        assert_eq!(add_port_calls[0], 31002);
        assert_eq!(add_port_calls[1], 31002);
    }

    #[tokio::test]
    async fn test_renewal_refreshes_external_ip() {
        let gateway = Arc::new(TestGateway::new(
            "127.0.0.1:1900".parse().expect("valid gateway"),
            "203.0.113.31".parse().expect("valid external IP"),
        ));
        gateway
            .add_port_results
            .lock()
            .expect("lock add_port_results")
            .extend([Ok(()), Ok(())]);

        let discoverer = TestDiscoverer {
            gateway: Arc::clone(&gateway),
            failures_before_success: AtomicUsize::new(0),
        };
        let shutdown = CancellationToken::new();
        let (events, on_update) = collect_events();

        let task = tokio::spawn(run_port_mapping_lifecycle(
            discoverer,
            PortMappingConfig {
                lease_duration_secs: 2,
                ..PortMappingConfig::default()
            },
            31012,
            shutdown.clone(),
            on_update,
        ));

        let initial = wait_for_events(&events, 1).await;
        assert!(initial.iter().any(|event| matches!(
            event,
            PortMappingEvent::Established { snapshot }
                if snapshot.external_addr == Some("203.0.113.31:31012".parse().expect("valid mapped addr"))
        )));

        *gateway.external_ip.lock().expect("lock external_ip") =
            "203.0.113.99".parse().expect("valid external IP");

        let refreshed = wait_for_events(&events, 2).await;
        assert!(refreshed.iter().any(|event| matches!(
            event,
            PortMappingEvent::Renewed { snapshot }
                if snapshot.external_addr == Some("203.0.113.99:31012".parse().expect("valid refreshed addr"))
        )));

        shutdown.cancel();
        task.await.expect("port-mapping task should exit cleanly");
    }

    #[tokio::test]
    async fn test_discovery_failures_are_non_fatal_until_shutdown() {
        let gateway = Arc::new(TestGateway::new(
            "127.0.0.1:1900".parse().expect("valid gateway"),
            "203.0.113.40".parse().expect("valid external IP"),
        ));
        let discoverer = TestDiscoverer {
            gateway,
            failures_before_success: AtomicUsize::new(10),
        };
        let shutdown = CancellationToken::new();
        let (events, on_update) = collect_events();

        let task = tokio::spawn(run_port_mapping_lifecycle(
            discoverer,
            PortMappingConfig::default(),
            31003,
            shutdown.clone(),
            on_update,
        ));

        tokio::time::sleep(Duration::from_millis(150)).await;
        shutdown.cancel();
        task.await.expect("port-mapping task should exit cleanly");

        let snapshots = events.lock().expect("lock events").clone();
        assert!(
            snapshots
                .iter()
                .all(|event| matches!(event, PortMappingEvent::Failed { .. })),
            "discovery failure should remain non-fatal and only publish failure events"
        );
    }

    #[test]
    fn test_zero_lease_still_refreshes_periodically() {
        assert_eq!(renewal_interval(0), ZERO_LEASE_REFRESH_INTERVAL);
    }

    #[tokio::test]
    async fn test_igd_gateway_client_uses_mock_igd_server() {
        let server = MockIgdServer::start().await.expect("mock IGD server");
        server
            .mock(
                Action::GetExternalIPAddress,
                Responder::success()
                    .with_external_ip("198.51.100.5".parse::<IpAddr>().expect("valid external IP")),
            )
            .await;
        server
            .mock(
                Action::add_port_mapping()
                    .with_external_port(31004)
                    .with_protocol(Protocol::UDP),
                Responder::success(),
            )
            .await;
        server
            .mock(
                Action::delete_port_mapping()
                    .with_external_port(31004)
                    .with_protocol(Protocol::UDP),
                Responder::success(),
            )
            .await;

        let gateway = mock_gateway(&server);
        let external_ip = gateway
            .get_external_ip()
            .await
            .expect("external IP request should succeed");
        assert_eq!(
            external_ip,
            "198.51.100.5".parse::<IpAddr>().expect("valid external IP")
        );

        let local_addr: SocketAddr = "127.0.0.1:31004".parse().expect("valid local addr");
        gateway
            .add_port(31004, local_addr, 60, "ant-quic test")
            .await
            .expect("add_port should succeed");
        gateway
            .remove_port(31004)
            .await
            .expect("remove_port should succeed");

        let requests = server.received_requests().await;
        assert_eq!(requests.len(), 3);
    }
}
