//! Per-peer connection-transparency derivation.
//!
//! The transport hands us cumulative counters for each peer's selected QUIC
//! path ([`PathSnapshot`]); this module turns two consecutive snapshots into
//! the human-facing [`PeerConnInfo`] the UI renders (badge + tooltip): path
//! type, RTT, and loss/bitrate over the poll window. Pure functions only —
//! the polling task in `core::mod` owns the clock and the previous-snapshot
//! map.

use crate::network::PathSnapshot;
use std::time::Duration;

/// How often the core polls the transport for path snapshots.
pub const POLL_INTERVAL: Duration = Duration::from_secs(1);

/// Derived, display-ready connection info for one peer, sent to the UI via
/// `UiEvent::ConnectionStats`. Window-relative fields are `None` when they
/// can't be derived yet (first poll, path switch, or an idle window).
#[derive(Debug, Clone, PartialEq)]
pub struct PeerConnInfo {
    /// True = relayed path, false = direct IP path.
    pub relay: bool,
    /// `ip:port` for a direct path, the relay URL for a relayed one.
    pub remote_addr: String,
    /// Path round-trip time, rounded to whole milliseconds.
    pub rtt_ms: u32,
    /// Percentage of packets sent in the window that were detected lost.
    pub loss_pct: Option<f32>,
    /// Outbound bitrate over the window, kilobits per second.
    pub up_kbps: Option<f32>,
    /// Inbound bitrate over the window, kilobits per second.
    pub down_kbps: Option<f32>,
}

/// Derive display info from the current snapshot and (when comparable) the
/// previous one. `prev` is comparable only if it's the same path — a relay→
/// direct migration or a reconnect resets the counters, so those windows
/// yield `None` rates rather than garbage (negative deltas show up as
/// `cur < prev` and are treated the same way).
pub fn derive(prev: Option<&PathSnapshot>, cur: &PathSnapshot, elapsed: Duration) -> PeerConnInfo {
    let rates = prev
        .filter(|p| comparable(p, cur))
        .and_then(|p| window_rates(p, cur, elapsed));
    PeerConnInfo {
        relay: cur.is_relay,
        remote_addr: cur.remote_addr.clone(),
        rtt_ms: cur.rtt.as_millis().min(u128::from(u32::MAX)) as u32,
        loss_pct: rates.and_then(|r| r.loss_pct),
        up_kbps: rates.map(|r| r.up_kbps),
        down_kbps: rates.map(|r| r.down_kbps),
    }
}

/// True when `cur`'s counters continue `prev`'s: same path (address) and
/// monotonically non-decreasing counters (a reconnect on the same address
/// restarts them from zero).
fn comparable(prev: &PathSnapshot, cur: &PathSnapshot) -> bool {
    prev.remote_addr == cur.remote_addr
        && cur.tx_bytes >= prev.tx_bytes
        && cur.rx_bytes >= prev.rx_bytes
        && cur.tx_datagrams >= prev.tx_datagrams
        && cur.lost_packets >= prev.lost_packets
}

#[derive(Debug, Clone, Copy)]
struct WindowRates {
    loss_pct: Option<f32>,
    up_kbps: f32,
    down_kbps: f32,
}

fn window_rates(prev: &PathSnapshot, cur: &PathSnapshot, elapsed: Duration) -> Option<WindowRates> {
    let secs = elapsed.as_secs_f64();
    if secs <= 0.0 {
        return None;
    }
    let sent = cur.tx_datagrams - prev.tx_datagrams;
    let lost = cur.lost_packets - prev.lost_packets;
    // Loss detection lags sending (it needs ACK timeouts), so a window can see
    // more losses than sends; clamp to 100% rather than exceeding it. An idle
    // window (nothing sent or lost) has no loss story to tell.
    let loss_pct = if sent == 0 && lost == 0 {
        None
    } else {
        Some(((lost as f64 / (sent.max(lost)) as f64) * 100.0) as f32)
    };
    let kbps = |bytes: u64| ((bytes as f64 * 8.0 / 1000.0) / secs) as f32;
    Some(WindowRates {
        loss_pct,
        up_kbps: kbps(cur.tx_bytes - prev.tx_bytes),
        down_kbps: kbps(cur.rx_bytes - prev.rx_bytes),
    })
}

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

    fn snap(addr: &str, tx_b: u64, rx_b: u64, tx_d: u64, lost: u64) -> PathSnapshot {
        PathSnapshot {
            is_relay: false,
            remote_addr: addr.to_string(),
            rtt: Duration::from_millis(12),
            tx_bytes: tx_b,
            rx_bytes: rx_b,
            tx_datagrams: tx_d,
            lost_packets: lost,
        }
    }

    #[test]
    fn first_poll_has_type_and_rtt_but_no_rates() {
        let cur = snap("1.2.3.4:5", 1000, 2000, 50, 0);
        let info = derive(None, &cur, POLL_INTERVAL);
        assert_eq!(info.rtt_ms, 12);
        assert!(!info.relay);
        assert_eq!(info.remote_addr, "1.2.3.4:5");
        assert_eq!(info.loss_pct, None);
        assert_eq!(info.up_kbps, None);
        assert_eq!(info.down_kbps, None);
    }

    #[test]
    fn steady_window_yields_rates_and_loss() {
        let prev = snap("1.2.3.4:5", 0, 0, 0, 0);
        // 1s window: 4000 bytes up (32 kbps), 2000 down (16 kbps), 2 of 100 lost.
        let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2);
        let info = derive(Some(&prev), &cur, Duration::from_secs(1));
        assert_eq!(info.up_kbps, Some(32.0));
        assert_eq!(info.down_kbps, Some(16.0));
        assert_eq!(info.loss_pct, Some(2.0));
    }

    #[test]
    fn idle_window_has_no_loss_story() {
        let prev = snap("1.2.3.4:5", 4000, 2000, 100, 2);
        let cur = prev.clone();
        let info = derive(Some(&prev), &cur, Duration::from_secs(1));
        assert_eq!(info.loss_pct, None);
        assert_eq!(info.up_kbps, Some(0.0));
    }

    #[test]
    fn loss_detected_in_an_idle_window_clamps_to_full() {
        // Losses can be *detected* after sending stops (ACK timeouts fire late).
        let prev = snap("1.2.3.4:5", 4000, 2000, 100, 0);
        let cur = snap("1.2.3.4:5", 4000, 2000, 100, 3);
        let info = derive(Some(&prev), &cur, Duration::from_secs(1));
        assert_eq!(info.loss_pct, Some(100.0));
    }

    #[test]
    fn path_switch_resets_the_window() {
        let prev = snap("relay.example:443", 9000, 9000, 900, 5);
        let cur = snap("1.2.3.4:5", 100, 100, 10, 0);
        let info = derive(Some(&prev), &cur, Duration::from_secs(1));
        assert_eq!(info.up_kbps, None);
        assert_eq!(info.loss_pct, None);
    }

    #[test]
    fn counter_reset_on_same_address_resets_the_window() {
        // Same address but the connection was rebuilt → counters restarted.
        let prev = snap("1.2.3.4:5", 9000, 9000, 900, 5);
        let cur = snap("1.2.3.4:5", 100, 100, 10, 0);
        let info = derive(Some(&prev), &cur, Duration::from_secs(1));
        assert_eq!(info.up_kbps, None);
        assert_eq!(info.loss_pct, None);
    }

    #[test]
    fn zero_elapsed_yields_no_rates() {
        let prev = snap("1.2.3.4:5", 0, 0, 0, 0);
        let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2);
        let info = derive(Some(&prev), &cur, Duration::ZERO);
        assert_eq!(info.up_kbps, None);
        assert_eq!(info.loss_pct, None);
    }

    #[test]
    fn oversized_rtt_saturates_instead_of_wrapping() {
        let mut cur = snap("1.2.3.4:5", 0, 0, 0, 0);
        cur.rtt = Duration::from_secs(u64::MAX);
        let info = derive(None, &cur, POLL_INTERVAL);
        assert_eq!(info.rtt_ms, u32::MAX);
    }
}
