//! NAT port mapping via UPnP-IGD, NAT-PMP, and PCP.
//!
//! Wraps the `portmapper` crate (also used internally by iroh) which runs
//! all three protocols in parallel and auto-renews the lease in a background
//! task. This module is named `upnp` for historical reasons — by v0.7.2 it
//! covers more than UPnP.
//!
//! ## Protocols
//! - **UPnP-IGD** — long-standing consumer-router default. Discovery uses
//!   SSDP multicast on 239.255.255.250:1900. Behavior on Hold harbor routers
//!   varies; many ship with UPnP disabled by default.
//! - **NAT-PMP** (RFC 6886) — Apple lineage; widespread on routers that
//!   ever shipped Bonjour. Unicast to the gateway on UDP/5351.
//! - **PCP** (RFC 6887) — modern IETF-track successor to NAT-PMP. Unicast
//!   on UDP/5351. Supports both IPv4 NAT mapping and IPv6 firewall pinholes
//!   (the latter via `AddPinhole`-shaped requests). Increasingly common in
//!   modern routers.
//!
//! All three are attempted in parallel by portmapper; the first one to
//! respond wins. PCP responses arrive sub-second when present; SSDP wakes
//! up in 1–3s.
//!
//! ## Per-platform contract
//! | Platform | UPnP-IGD | NAT-PMP | PCP | TCP for HTTP |
//! |---|---|---|---|---|
//! | Linux/macOS/Windows | ✓ | ✓ | ✓ | ✓ |
//! | Android (WiFi/Ethernet) | ✓ (with MulticastLock) | ✓ | ✓ | ✓ |
//! | Android (cellular) | ✗ (skipped early) | ✗ | ✗ | ✗ |
//! | iOS | ✗ (without `com.apple.developer.networking.multicast` entitlement) | ✓ | ✓ | ✓ |
//!
//! ## Android specifics
//! `try_udp` / `try_tcp` first check `crate::android_wifi::is_on_wifi()`.
//! If not on WiFi/Ethernet (cellular), returns `None` immediately — cellular
//! networks almost never expose any of these protocols, and a discovery
//! timeout would waste ~3s every startup.
//!
//! When on WiFi, a `WifiManager.MulticastLock` is acquired and held for the
//! lifetime of the resulting `PortMapping`. Without the lock Android filters
//! the SSDP multicast responses; PCP/NAT-PMP work without it but UPnP-IGD
//! would never complete. The lock is released on Drop.
//!
//! ## iOS specifics
//! Until Apple grants the multicast entitlement, UPnP-IGD will silently fail
//! (no SSDP responses delivered to the socket). The unicast protocols PCP
//! and NAT-PMP succeed without any entitlement and cover most modern home
//! routers, so iOS gets reasonable coverage today.
//!
//! ## Renewal model
//! Auto-renewal happens inside `portmapper::Client`'s internal task — there
//! is **no** external renewal cycle to schedule. The `PortMapping` value
//! owns the client; while it's held, the mapping stays alive. Dropping it
//! aborts the renewal task (via `AbortOnDropHandle`) and stops keeping the
//! mapping alive at the gateway. `release(&self)` triggers an explicit
//! deactivation message before drop for cleaner shutdown.
//!
//! ## Anchor reachability watcher (bidirectional)
//! `Network::start` spawns a task that observes the UDP mapping's
//! `watch_external()` channel and adjusts anchor mode at runtime:
//!   - **Mapping lost** for >5 min → clear `is_anchor`. The node stops
//!     advertising itself as an anchor at the now-stale external address.
//!   - **Mapping restored** (None → Some) → re-evaluate auto-anchor. On
//!     non-mobile devices the anchor flag is set back on so the node
//!     re-joins the anchor set without a restart.
//!
//! Network roams (e.g., leaving a UPnP-capable WiFi and joining a new one)
//! self-heal. Mobile devices never auto-anchor regardless — cellular IPs
//! look public but sit behind CGNAT.

use std::net::SocketAddr;
use std::num::NonZeroU16;
use std::time::Duration;
use tracing::{debug, info, warn};

/// An active port mapping. While this value is held, the underlying
/// `portmapper::Client` keeps the lease alive in a background task.
/// Dropping it releases the mapping.
pub struct PortMapping {
    pub external_addr: SocketAddr,
    pub local_port: u16,
    #[allow(dead_code)] // service kept alive by holding the client
    client: portmapper::Client,
    #[cfg(target_os = "android")]
    #[allow(dead_code)] // lock kept alive for the lifetime of the mapping
    mcast_lock: Option<crate::android_wifi::MulticastLockGuard>,
}

impl PortMapping {
    /// Best-effort UDP port mapping for the given local QUIC port.
    /// On Android, requires an active WiFi/Ethernet connection — returns
    /// `None` immediately on cellular. Waits up to 3 seconds for any of the
    /// three protocols (PCP / NAT-PMP / UPnP-IGD) to return a mapping.
    pub async fn try_udp(local_port: u16) -> Option<Self> {
        Self::try_for_protocol(local_port, portmapper::Protocol::Udp).await
    }

    /// Best-effort TCP port mapping for HTTP serving on the given local port.
    /// Same platform gating as UDP. Used to make this node HTTP-reachable
    /// for browser-shaped fetches of public posts.
    pub async fn try_tcp(local_port: u16) -> Option<Self> {
        Self::try_for_protocol(local_port, portmapper::Protocol::Tcp).await
    }

    async fn try_for_protocol(local_port: u16, protocol: portmapper::Protocol) -> Option<Self> {
        let port = NonZeroU16::new(local_port)?;

        #[cfg(target_os = "android")]
        {
            if !crate::android_wifi::is_on_wifi() {
                debug!("Port mapping: skipping (not on WiFi/Ethernet)");
                return None;
            }
        }

        #[cfg(target_os = "android")]
        let mcast_lock = crate::android_wifi::MulticastLockGuard::acquire("itsgoin-ssdp");

        let config = portmapper::Config {
            enable_upnp: true,
            enable_pcp: true,
            enable_nat_pmp: true,
            protocol,
        };
        let client = portmapper::Client::new(config);
        client.update_local_port(port);

        // Wait up to 3 seconds for any protocol to produce a mapping.
        let mut watch = client.watch_external_address();
        let external = match tokio::time::timeout(Duration::from_secs(3), async {
            loop {
                if let Some(addr) = *watch.borrow_and_update() {
                    return Some(addr);
                }
                if watch.changed().await.is_err() {
                    return None;
                }
            }
        })
        .await
        {
            Ok(Some(addr)) => addr,
            _ => {
                debug!(
                    protocol = ?protocol,
                    local_port,
                    "Port mapping: no protocol responded within 3s (no UPnP/PCP/NAT-PMP gateway?)"
                );
                return None;
            }
        };

        info!(
            external = %external,
            local_port,
            protocol = ?protocol,
            "Port mapping established"
        );

        Some(PortMapping {
            external_addr: SocketAddr::V4(external),
            local_port,
            client,
            #[cfg(target_os = "android")]
            mcast_lock,
        })
    }

    /// Watch for changes to the external address. Useful when the underlying
    /// network changes (e.g., mobile WiFi roam) — the mapping may move to a
    /// new public IP/port.
    pub fn watch_external(&self) -> tokio::sync::watch::Receiver<Option<std::net::SocketAddrV4>> {
        self.client.watch_external_address()
    }

    /// Explicitly request the portmapper service to release the gateway
    /// mapping. The actual cleanup completes when the `PortMapping` is
    /// dropped (the internal service handle is abort-on-drop). Allows
    /// callers to signal "release now" before the value goes out of scope.
    pub fn release(&self) {
        self.client.deactivate();
    }
}
