//! IPv4 TCP/IP OS detection probes (Nmap 2nd-gen): SEQ/OPS/WIN, ECN, T1–T7, U1, IE.
//!
//! Requires a raw IPv4 socket (privileged).

use std::collections::HashMap;
use std::io;
use std::mem;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use std::thread;
use std::time::{Duration, Instant};

use anyhow::{Context, Result};
use pnet::packet::ip::IpNextHeaderProtocols;
use pnet::packet::ipv4::{checksum as ipv4_hdr_cksum, Ipv4Flags, Ipv4Packet, MutableIpv4Packet};
use pnet::packet::tcp::{ipv4_checksum, TcpFlags, TcpPacket};
use pnet::packet::Packet;
use pnet::transport::{
    transport_channel, TransportChannelType, TransportReceiver, TransportSender,
};
use pnet_sys;
use rand::Rng;
use std::fmt::Write as _;

use crate::os_fp_db::SubjectFingerprint;

const NUM_SEQ_SAMPLES: usize = 6;
const OS_SEQ_PROBE_DELAY_MS: u64 = 100;
const PRIME_32K: u32 = 32261;

/// `prbOpts` / `prbWindowSz` from Nmap `osscan2.cc`.
const PRB_OPTS: [&[u8]; 13] = [
    b"\x03\x03\x0A\x01\x02\x04\x05\xb4\x08\x0A\xff\xff\xff\xff\x00\x00\x00\x00\x04\x02",
    b"\x02\x04\x05\x78\x03\x03\x00\x04\x02\x08\x0A\xff\xff\xff\xff\x00\x00\x00\x00\x00",
    b"\x08\x0A\xff\xff\xff\xff\x00\x00\x00\x00\x01\x01\x03\x03\x05\x01\x02\x04\x02\x80",
    b"\x04\x02\x08\x0A\xff\xff\xff\xff\x00\x00\x00\x00\x03\x03\x0A\x00",
    b"\x02\x04\x02\x18\x04\x02\x08\x0A\xff\xff\xff\xff\x00\x00\x00\x00\x03\x03\x0A\x00",
    b"\x02\x04\x01\x09\x04\x02\x08\x0A\xff\xff\xff\xff\x00\x00\x00\x00",
    b"\x03\x03\x0A\x01\x02\x04\x05\xb4\x04\x02\x01\x01",
    b"\x03\x03\x0A\x01\x02\x04\x01\x09\x08\x0A\xff\xff\xff\xff\x00\x00\x00\x00\x04\x02",
    b"\x03\x03\x0A\x01\x02\x04\x01\x09\x08\x0A\xff\xff\xff\xff\x00\x00\x00\x00\x04\x02",
    b"\x03\x03\x0A\x01\x02\x04\x01\x09\x08\x0A\xff\xff\xff\xff\x00\x00\x00\x00\x04\x02",
    b"\x03\x03\x0A\x01\x02\x04\x01\x09\x08\x0A\xff\xff\xff\xff\x00\x00\x00\x00\x04\x02",
    b"\x03\x03\x0A\x01\x02\x04\x01\x09\x08\x0A\xff\xff\xff\xff\x00\x00\x00\x00\x04\x02",
    b"\x03\x03\x0f\x01\x02\x04\x01\x09\x08\x0A\xff\xff\xff\xff\x00\x00\x00\x00\x04\x02",
];

const PRB_WIN: [u16; 13] = [1, 63, 4, 4, 16, 512, 3, 128, 256, 1024, 31337, 32768, 65535];

// ───── reply structs ────────────────────────────────────────────

struct TcpReply {
    ipid: u16,
    ttl: u8,
    df: bool,
    window: u16,
    seq: u32,
    ack_num: u32,
    flags: u8,
    reserved: u8,
    urg_ptr: u16,
    options_fp: String,
    tsval: u32,
    data: Vec<u8>,
    recv_time: Instant,
}

struct IcmpEchoReply {
    ipid: u16,
    ttl: u8,
    df: bool,
    code: u8,
}

struct IcmpUnreachReply {
    ttl: u8,
    df: bool,
    ip_total_len: u16,
    unused: u32,
    embedded_ip_total_len: u16,
    embedded_ip_id: u16,
    embedded_ip_cksum: u16,
    embedded_ip_cksum_computed: u16,
    embedded_udp_cksum: u16,
    embedded_udp_data: Vec<u8>,
}

// ───── utilities ────────────────────────────────────────────────

fn local_ipv4() -> io::Result<Ipv4Addr> {
    let s = UdpSocket::bind("0.0.0.0:0")?;
    s.connect("8.8.8.8:80")?;
    match s.local_addr()? {
        SocketAddr::V4(v) => Ok(*v.ip()),
        _ => Err(io::Error::other("no IPv4 source")),
    }
}

fn hex_val(x: u32) -> String {
    format!("{:X}", x)
}

fn next_ipid(counter: &mut u16) -> u16 {
    let v = *counter;
    *counter = counter.wrapping_add(1);
    v
}

fn internet_checksum(data: &[u8]) -> u16 {
    let mut sum: u32 = 0;
    let mut i = 0;
    while i + 1 < data.len() {
        sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
        i += 2;
    }
    if i < data.len() {
        sum += (data[i] as u32) << 8;
    }
    while sum >> 16 != 0 {
        sum = (sum >> 16) + (sum & 0xffff);
    }
    !(sum as u16)
}

fn ttl_guess(ttl: u8) -> u8 {
    if ttl <= 32 {
        32
    } else if ttl <= 64 {
        64
    } else if ttl <= 128 {
        128
    } else {
        255
    }
}

fn flags_str(f: u8) -> String {
    let mut s = String::new();
    if f & TcpFlags::ECE != 0 {
        s.push('E');
    }
    if f & TcpFlags::URG != 0 {
        s.push('U');
    }
    if f & TcpFlags::ACK != 0 {
        s.push('A');
    }
    if f & TcpFlags::PSH != 0 {
        s.push('P');
    }
    if f & TcpFlags::RST != 0 {
        s.push('R');
    }
    if f & TcpFlags::SYN != 0 {
        s.push('S');
    }
    if f & TcpFlags::FIN != 0 {
        s.push('F');
    }
    s
}

fn quirks_str(reserved: u8, flags: u8, urg: u16) -> String {
    let mut s = String::new();
    if reserved != 0 {
        s.push('R');
    }
    if urg != 0 && flags & TcpFlags::URG == 0 {
        s.push('U');
    }
    s
}

fn seq_relation(resp_seq: u32, probe_ack: u32) -> String {
    if resp_seq == 0 {
        "Z".into()
    } else if resp_seq == probe_ack {
        "A".into()
    } else if resp_seq == probe_ack.wrapping_add(1) {
        "A+".into()
    } else {
        "O".into()
    }
}

fn ack_relation(resp_ack: u32, probe_seq: u32) -> String {
    if resp_ack == 0 {
        "Z".into()
    } else if resp_ack == probe_seq {
        "S".into()
    } else if resp_ack == probe_seq.wrapping_add(1) {
        "S+".into()
    } else {
        "O".into()
    }
}

fn cc_str(flags: u8) -> String {
    let ece = flags & TcpFlags::ECE != 0;
    let cwr = flags & TcpFlags::CWR != 0;
    match (ece, cwr) {
        (true, false) => "Y",
        (false, false) => "N",
        (true, true) => "S",
        _ => "O",
    }
    .into()
}

fn rd_value(data: &[u8]) -> String {
    if data.is_empty() {
        "0".into()
    } else {
        format!("{:X}", crc32fast::hash(data))
    }
}

// ───── TCP option parsing ───────────────────────────────────────

/// Nmap `get_tcpopt_string` (`osscan2.cc`).
#[allow(clippy::result_unit_err)] // nmap-style sentinel: only success vs "no fingerprint"
pub fn tcp_options_fingerprint(tcp: &TcpPacket<'_>, _mss_hint: u16) -> Result<String, ()> {
    let mut out = String::new();
    let hdr = tcp.packet();
    let data_off = tcp.get_data_offset() as usize * 4;
    if data_off < 20 || hdr.len() < data_off {
        return Err(());
    }
    let mut q = &hdr[20..data_off];
    while !q.is_empty() && out.len() < 240 {
        let opcode = q[0];
        if opcode == 0 {
            out.push('L');
            break;
        }
        if opcode == 1 {
            out.push('N');
            q = &q[1..];
            continue;
        }
        if q.len() < 2 {
            return Err(());
        }
        let len = q[1] as usize;
        if len < 2 || q.len() < len {
            return Err(());
        }
        match opcode {
            2 => {
                if len < 4 {
                    return Err(());
                }
                let mss = u16::from_be_bytes([q[2], q[3]]);
                let _ = write!(&mut out, "M{:X}", mss);
            }
            3 => {
                if len < 3 {
                    return Err(());
                }
                let _ = write!(&mut out, "W{:X}", q[2]);
            }
            4 => {
                out.push('S');
            }
            8 => {
                if len < 10 {
                    return Err(());
                }
                out.push('T');
                let t1 = u32::from_be_bytes([q[2], q[3], q[4], q[5]]);
                let t2 = u32::from_be_bytes([q[6], q[7], q[8], q[9]]);
                out.push(if t1 != 0 { '1' } else { '0' });
                out.push(if t2 != 0 { '1' } else { '0' });
            }
            _ => {
                return Err(());
            }
        }
        q = &q[len..];
    }
    Ok(out)
}

fn read_timestamp(tcp: &TcpPacket<'_>) -> u32 {
    let hdr = tcp.packet();
    let data_off = tcp.get_data_offset() as usize * 4;
    if data_off <= 20 || hdr.len() < data_off {
        return 0;
    }
    let mut q = &hdr[20..data_off];
    while !q.is_empty() {
        let op = q[0];
        if op == 0 {
            break;
        }
        if op == 1 {
            q = &q[1..];
            continue;
        }
        if q.len() < 2 {
            break;
        }
        let len = q[1] as usize;
        if len < 2 || q.len() < len {
            break;
        }
        if op == 8 && len >= 10 {
            return u32::from_be_bytes([q[2], q[3], q[4], q[5]]);
        }
        q = &q[len..];
    }
    0
}

// ───── raw socket recv ──────────────────────────────────────────

fn recv_ipv4_with_timeout(
    tr: &mut TransportReceiver,
    t: Duration,
) -> io::Result<Option<Ipv4Packet<'_>>> {
    let fd = tr.socket.fd;
    let old = pnet_sys::get_socket_receive_timeout(fd)?;
    pnet_sys::set_socket_receive_timeout(fd, t)?;
    let mut caddr: pnet_sys::SockAddrStorage = unsafe { mem::zeroed() };
    let r = pnet_sys::recv_from(fd, &mut tr.buffer[..], &mut caddr);
    let _ = pnet_sys::set_socket_receive_timeout(fd, old);
    match r {
        Ok(len) => Ok(Ipv4Packet::new(&tr.buffer[..len])),
        Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(None),
        Err(e) => Err(e),
    }
}

fn recv_tcp_reply(
    rx: &mut TransportReceiver,
    target: Ipv4Addr,
    our_port: u16,
    timeout: Duration,
) -> io::Result<Option<TcpReply>> {
    let deadline = Instant::now() + timeout;
    loop {
        let remaining = deadline.saturating_duration_since(Instant::now());
        if remaining.is_zero() {
            return Ok(None);
        }
        let wait = remaining.min(Duration::from_millis(50));
        let Some(ip) = recv_ipv4_with_timeout(rx, wait)? else {
            continue;
        };
        if ip.get_source() != target {
            continue;
        }
        if ip.get_next_level_protocol() != IpNextHeaderProtocols::Tcp {
            continue;
        }
        let Some(tcp) = TcpPacket::new(ip.payload()) else {
            continue;
        };
        if tcp.get_destination() != our_port {
            continue;
        }
        let ipid = ip.get_identification();
        let ttl = ip.get_ttl();
        let df = ip.get_flags() & 0b010 != 0;
        let window = tcp.get_window();
        let seq = tcp.get_sequence();
        let ack_num = tcp.get_acknowledgement();
        let flags = tcp.get_flags();
        let reserved = tcp.packet()[12] & 0x0F;
        let urg_ptr = tcp.get_urgent_ptr();
        let options_fp = tcp_options_fingerprint(&tcp, 265).unwrap_or_default();
        let tsval = read_timestamp(&tcp);
        let data = tcp.payload().to_vec();
        return Ok(Some(TcpReply {
            ipid,
            ttl,
            df,
            window,
            seq,
            ack_num,
            flags,
            reserved,
            urg_ptr,
            options_fp,
            tsval,
            data,
            recv_time: Instant::now(),
        }));
    }
}

fn recv_icmp_echo_reply(
    rx: &mut TransportReceiver,
    target: Ipv4Addr,
    expected_id: u16,
    timeout: Duration,
) -> io::Result<Option<IcmpEchoReply>> {
    let deadline = Instant::now() + timeout;
    loop {
        let remaining = deadline.saturating_duration_since(Instant::now());
        if remaining.is_zero() {
            return Ok(None);
        }
        let wait = remaining.min(Duration::from_millis(50));
        let Some(ip) = recv_ipv4_with_timeout(rx, wait)? else {
            continue;
        };
        if ip.get_source() != target {
            continue;
        }
        if ip.get_next_level_protocol() != IpNextHeaderProtocols::Icmp {
            continue;
        }
        let p = ip.payload();
        if p.len() < 8 || p[0] != 0 {
            continue;
        }
        let id = u16::from_be_bytes([p[4], p[5]]);
        if id != expected_id {
            continue;
        }
        return Ok(Some(IcmpEchoReply {
            ipid: ip.get_identification(),
            ttl: ip.get_ttl(),
            df: ip.get_flags() & 0b010 != 0,
            code: p[1],
        }));
    }
}

fn recv_icmp_unreachable(
    rx: &mut TransportReceiver,
    target: Ipv4Addr,
    expected_udp_dport: u16,
    timeout: Duration,
) -> io::Result<Option<IcmpUnreachReply>> {
    let deadline = Instant::now() + timeout;
    loop {
        let remaining = deadline.saturating_duration_since(Instant::now());
        if remaining.is_zero() {
            return Ok(None);
        }
        let wait = remaining.min(Duration::from_millis(50));
        let Some(ip) = recv_ipv4_with_timeout(rx, wait)? else {
            continue;
        };
        if ip.get_source() != target {
            continue;
        }
        if ip.get_next_level_protocol() != IpNextHeaderProtocols::Icmp {
            continue;
        }
        let p = ip.payload();
        if p.len() < 36 {
            continue;
        } // 8 ICMP hdr + 20 embedded IP + 8 embedded UDP
        if p[0] != 3 || p[1] != 3 {
            continue;
        }
        let embedded = &p[8..];
        let ihl = (embedded[0] & 0x0f) as usize * 4;
        if embedded.len() < ihl + 4 {
            continue;
        }
        let udp_dport = u16::from_be_bytes([embedded[ihl + 2], embedded[ihl + 3]]);
        if udp_dport != expected_udp_dport {
            continue;
        }

        let unused = u32::from_be_bytes([p[4], p[5], p[6], p[7]]);
        let eip_total = u16::from_be_bytes([embedded[2], embedded[3]]);
        let eip_id = u16::from_be_bytes([embedded[4], embedded[5]]);
        let eip_ck = u16::from_be_bytes([embedded[10], embedded[11]]);
        let mut hdr_copy = embedded[..ihl].to_vec();
        hdr_copy[10] = 0;
        hdr_copy[11] = 0;
        let eip_ck_computed = internet_checksum(&hdr_copy);
        let eudp_ck = if embedded.len() >= ihl + 8 {
            u16::from_be_bytes([embedded[ihl + 6], embedded[ihl + 7]])
        } else {
            0
        };
        let eudp_data = if embedded.len() > ihl + 8 {
            embedded[ihl + 8..].to_vec()
        } else {
            Vec::new()
        };
        return Ok(Some(IcmpUnreachReply {
            ttl: ip.get_ttl(),
            df: ip.get_flags() & 0b010 != 0,
            ip_total_len: ip.get_total_length(),
            unused,
            embedded_ip_total_len: eip_total,
            embedded_ip_id: eip_id,
            embedded_ip_cksum: eip_ck,
            embedded_ip_cksum_computed: eip_ck_computed,
            embedded_udp_cksum: eudp_ck,
            embedded_udp_data: eudp_data,
        }));
    }
}

// ───── send functions ───────────────────────────────────────────

#[allow(clippy::too_many_arguments)] // raw IPv4+TCP probe layout
fn send_tcp_probe(
    tx: &mut TransportSender,
    src: Ipv4Addr,
    dst: Ipv4Addr,
    sport: u16,
    dport: u16,
    seq: u32,
    ack: u32,
    reserved: u8,
    flags: u8,
    window: u16,
    urg: u16,
    df: bool,
    opts: &[u8],
    ttl: u8,
    ip_id: u16,
) -> io::Result<()> {
    let tcp_len = 20 + opts.len();
    let total = 20 + tcp_len;
    let mut buf = vec![0u8; total];
    {
        let t = &mut buf[20..20 + tcp_len];
        t[0..2].copy_from_slice(&sport.to_be_bytes());
        t[2..4].copy_from_slice(&dport.to_be_bytes());
        t[4..8].copy_from_slice(&seq.to_be_bytes());
        t[8..12].copy_from_slice(&ack.to_be_bytes());
        t[12] = (((tcp_len / 4) as u8) << 4) | (reserved & 0x0f);
        t[13] = flags;
        t[14..16].copy_from_slice(&window.to_be_bytes());
        t[18..20].copy_from_slice(&urg.to_be_bytes());
        t[20..].copy_from_slice(opts);
        let pkt = TcpPacket::new(t).expect("tcp len");
        let ck = ipv4_checksum(&pkt, &src, &dst);
        t[16..18].copy_from_slice(&ck.to_be_bytes());
    }
    build_ip_header(&mut buf[..20], total as u16, ip_id, df, ttl, 6, src, dst);
    tx.send_to(Ipv4Packet::new(&buf).expect("pkt"), IpAddr::V4(dst))?;
    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn send_udp_probe(
    tx: &mut TransportSender,
    src: Ipv4Addr,
    dst: Ipv4Addr,
    sport: u16,
    dport: u16,
    data: &[u8],
    ttl: u8,
    df: bool,
    ip_id: u16,
) -> io::Result<u16> {
    let udp_len = 8 + data.len();
    let total = 20 + udp_len;
    let mut buf = vec![0u8; total];
    {
        let u = &mut buf[20..20 + udp_len];
        u[0..2].copy_from_slice(&sport.to_be_bytes());
        u[2..4].copy_from_slice(&dport.to_be_bytes());
        u[4..6].copy_from_slice(&(udp_len as u16).to_be_bytes());
        u[8..8 + data.len()].copy_from_slice(data);
        let mut pseudo = Vec::with_capacity(12 + udp_len);
        pseudo.extend_from_slice(&src.octets());
        pseudo.extend_from_slice(&dst.octets());
        pseudo.extend_from_slice(&[0, 17]);
        pseudo.extend_from_slice(&(udp_len as u16).to_be_bytes());
        pseudo.extend_from_slice(&u[..udp_len]);
        let ck = internet_checksum(&pseudo);
        u[6..8].copy_from_slice(&ck.to_be_bytes());
    }
    build_ip_header(&mut buf[..20], total as u16, ip_id, df, ttl, 17, src, dst);
    let sent_ck = u16::from_be_bytes([buf[26], buf[27]]);
    tx.send_to(Ipv4Packet::new(&buf).expect("pkt"), IpAddr::V4(dst))?;
    Ok(sent_ck)
}

#[allow(clippy::too_many_arguments)]
fn send_icmp_echo(
    tx: &mut TransportSender,
    src: Ipv4Addr,
    dst: Ipv4Addr,
    code: u8,
    id: u16,
    seq: u16,
    data_len: usize,
    ttl: u8,
    df: bool,
    tos: u8,
    ip_id: u16,
) -> io::Result<()> {
    let icmp_len = 8 + data_len;
    let total = 20 + icmp_len;
    let mut buf = vec![0u8; total];
    {
        let ic = &mut buf[20..20 + icmp_len];
        ic[0] = 8; // echo request
        ic[1] = code;
        ic[4..6].copy_from_slice(&id.to_be_bytes());
        ic[6..8].copy_from_slice(&seq.to_be_bytes());
        let ck = internet_checksum(&ic[..icmp_len]);
        ic[2..4].copy_from_slice(&ck.to_be_bytes());
    }
    {
        let mut ip = MutableIpv4Packet::new(&mut buf[..20]).expect("ip");
        ip.set_version(4);
        ip.set_header_length(5);
        ip.set_dscp(tos >> 2);
        ip.set_ecn(tos & 0x03);
        ip.set_total_length(total as u16);
        ip.set_identification(ip_id);
        if df {
            ip.set_flags(Ipv4Flags::DontFragment);
        }
        ip.set_fragment_offset(0);
        ip.set_ttl(ttl);
        ip.set_next_level_protocol(IpNextHeaderProtocols::Icmp);
        ip.set_checksum(0);
        ip.set_source(src);
        ip.set_destination(dst);
        ip.set_checksum(ipv4_hdr_cksum(&ip.to_immutable()));
    }
    tx.send_to(Ipv4Packet::new(&buf).expect("pkt"), IpAddr::V4(dst))?;
    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn build_ip_header(
    buf: &mut [u8],
    total_len: u16,
    id: u16,
    df: bool,
    ttl: u8,
    proto: u8,
    src: Ipv4Addr,
    dst: Ipv4Addr,
) {
    let mut ip = MutableIpv4Packet::new(buf).expect("ip hdr");
    ip.set_version(4);
    ip.set_header_length(5);
    ip.set_dscp(0);
    ip.set_ecn(0);
    ip.set_total_length(total_len);
    ip.set_identification(id);
    if df {
        ip.set_flags(Ipv4Flags::DontFragment);
    }
    ip.set_fragment_offset(0);
    ip.set_ttl(ttl);
    ip.set_next_level_protocol(pnet::packet::ip::IpNextHeaderProtocol::new(proto));
    ip.set_checksum(0);
    ip.set_source(src);
    ip.set_destination(dst);
    ip.set_checksum(ipv4_hdr_cksum(&ip.to_immutable()));
}

// ───── classification ───────────────────────────────────────────

fn classify_ipid(ipids: &[u16]) -> String {
    if ipids.len() < 2 {
        return "O".into();
    }
    if ipids.iter().all(|&v| v == 0) {
        return "Z".into();
    }

    let mut diffs = Vec::with_capacity(ipids.len() - 1);
    for i in 1..ipids.len() {
        let cur = ipids[i] as u32;
        let prev = ipids[i - 1] as u32;
        let d = if cur >= prev {
            cur - prev
        } else {
            0x10000 + cur - prev
        };
        diffs.push(d);
    }

    if diffs.iter().all(|&d| d == 0) {
        return "Z".into();
    }
    if diffs.iter().any(|&d| d > 20000) {
        return "RD".into();
    }

    // check byte-swapped incremental (BI)
    let mut bi = true;
    for i in 1..ipids.len() {
        let prev = ipids[i - 1].swap_bytes() as u32;
        let cur = ipids[i].swap_bytes() as u32;
        let d = if cur >= prev {
            cur - prev
        } else {
            0x10000 + cur - prev
        };
        if d > 20000 {
            bi = false;
            break;
        }
    }
    if bi {
        let mut swap_diffs = Vec::new();
        for i in 1..ipids.len() {
            let prev = ipids[i - 1].swap_bytes() as u32;
            let cur = ipids[i].swap_bytes() as u32;
            let d = if cur >= prev {
                cur - prev
            } else {
                0x10000 + cur - prev
            };
            swap_diffs.push(d);
        }
        if swap_diffs.iter().all(|&d| d <= 20000) {
            return "BI".into();
        }
    }

    let avg: f64 = diffs.iter().map(|&d| d as f64).sum::<f64>() / diffs.len() as f64;
    if avg < 1.0 {
        return "Z".into();
    }
    let var: f64 = diffs
        .iter()
        .map(|&d| {
            let x = d as f64 - avg;
            x * x
        })
        .sum::<f64>()
        / diffs.len() as f64;
    if var.sqrt() / avg < 0.1 && avg < 5000.0 {
        return "I".into();
    }
    "RI".into()
}

fn classify_ts(timestamps: &[u32], times: &[Instant]) -> String {
    if timestamps.len() < 2 {
        return "U".into();
    }
    if timestamps.iter().all(|&t| t == 0) {
        return "0".into();
    }

    for i in 1..timestamps.len() {
        if timestamps[i] < timestamps[i - 1] && timestamps[i - 1] - timestamps[i] > 1000 {
            return "U".into();
        }
    }

    let ts_diff = timestamps.last().unwrap().wrapping_sub(timestamps[0]) as f64;
    let time_diff = times.last().unwrap().duration_since(times[0]).as_secs_f64();
    if time_diff < 0.001 {
        return "U".into();
    }
    let freq = ts_diff / time_diff;

    if freq < 0.5 {
        return "0".into();
    }
    if freq < 5.66 {
        return "1".into();
    }
    if (85.0..350.0).contains(&freq) {
        return "7".into();
    }
    if (700.0..1500.0).contains(&freq) {
        return "8".into();
    }
    format!("{:X}", freq.round() as u32)
}

fn shared_ipid_seq(tcp_ipids: &[u16], icmp_ipids: &[u16]) -> String {
    if tcp_ipids.len() < 2 || icmp_ipids.is_empty() {
        return "O".into();
    }
    let ti = classify_ipid(tcp_ipids);
    let ii = classify_ipid(icmp_ipids);
    if ti != "I" || ii != "I" {
        return "O".into();
    }
    let last_tcp = *tcp_ipids.last().unwrap() as i32;
    let first_icmp = icmp_ipids[0] as i32;
    if (first_icmp - last_tcp).unsigned_abs() < 256 {
        "S".into()
    } else {
        "O".into()
    }
}

// ───── math helpers ─────────────────────────────────────────────

fn mod_diff_u32(a: u32, b: u32) -> u32 {
    a.wrapping_sub(b).min(b.wrapping_sub(a))
}

fn gcd_many(vals: &[u32]) -> u32 {
    if vals.is_empty() {
        return 1;
    }
    vals.iter().copied().reduce(gcd_two).unwrap_or(1)
}

fn gcd_two(mut a: u32, mut b: u32) -> u32 {
    while b != 0 {
        let t = a % b;
        a = b;
        b = t;
    }
    a.max(1)
}

// ───── test builders ────────────────────────────────────────────

fn build_seq_test(
    seq_nums: &[u32],
    seq_times: &[Instant],
    timestamps: &[u32],
    tcp_open_ipids: &[u16],
    tcp_closed_ipids: &[u16],
    icmp_ipids: &[u16],
) -> HashMap<String, String> {
    let n = seq_nums.len();
    let mut diffs = Vec::with_capacity(n - 1);
    for i in 1..n {
        diffs.push(mod_diff_u32(seq_nums[i], seq_nums[i - 1]));
    }
    let gcd = gcd_many(&diffs);
    let mut seq_rates = Vec::with_capacity(n - 1);
    for i in 1..n {
        let dt = seq_times[i]
            .saturating_duration_since(seq_times[i - 1])
            .as_micros()
            .max(1);
        seq_rates.push(diffs[i - 1] as f64 * 1_000_000.0 / dt as f64);
    }
    let avg: f64 = seq_rates.iter().sum::<f64>() / seq_rates.len().max(1) as f64;
    let mut isr = if avg > 0.0 {
        (avg.log2() * 8.0).round() as u32
    } else {
        0
    };
    if gcd == 0 {
        isr = 0;
    }

    let div_gcd = if gcd > 9 { gcd as f64 } else { 1.0 };
    let mean_normed = avg / div_gcd;
    let var: f64 = seq_rates
        .iter()
        .map(|r| {
            let t = r / div_gcd - mean_normed;
            t * t
        })
        .sum::<f64>()
        / (seq_rates.len().saturating_sub(1).max(1)) as f64;
    let sd = var.sqrt();
    let sp = if sd <= 1.0 {
        0u32
    } else {
        ((sd.log2() * 8.0).round() as u32).min(0xff)
    };

    let mut m = HashMap::new();
    m.insert("SP".into(), hex_val(sp));
    m.insert("GCD".into(), hex_val(gcd));
    m.insert("ISR".into(), hex_val(isr));
    m.insert("TI".into(), classify_ipid(tcp_open_ipids));
    m.insert(
        "CI".into(),
        if tcp_closed_ipids.is_empty() {
            "Z".into()
        } else {
            classify_ipid(tcp_closed_ipids)
        },
    );
    m.insert(
        "II".into(),
        if icmp_ipids.is_empty() {
            "O".into()
        } else {
            classify_ipid(icmp_ipids)
        },
    );
    m.insert("SS".into(), shared_ipid_seq(tcp_open_ipids, icmp_ipids));
    m.insert("TS".into(), classify_ts(timestamps, seq_times));
    m
}

fn build_ecn_test(reply: &Option<TcpReply>, probe_seq: u32) -> Option<HashMap<String, String>> {
    let mut m = HashMap::new();
    let Some(r) = reply else {
        m.insert("R".into(), "N".into());
        return Some(m);
    };
    m.insert("R".into(), "Y".into());
    m.insert("DF".into(), if r.df { "Y" } else { "N" }.into());
    m.insert("T".into(), hex_val(r.ttl as u32));
    m.insert("TG".into(), hex_val(ttl_guess(r.ttl) as u32));
    m.insert("W".into(), hex_val(r.window as u32));
    m.insert("O".into(), r.options_fp.clone());
    m.insert("CC".into(), cc_str(r.flags));
    m.insert("Q".into(), quirks_str(r.reserved, r.flags, r.urg_ptr));
    let _ = probe_seq;
    Some(m)
}

fn build_t1_test(
    reply: &Option<TcpReply>,
    probe_seq: u32,
    probe_ack: u32,
) -> Option<HashMap<String, String>> {
    let mut m = HashMap::new();
    let Some(r) = reply else {
        m.insert("R".into(), "N".into());
        return Some(m);
    };
    m.insert("R".into(), "Y".into());
    m.insert("DF".into(), if r.df { "Y" } else { "N" }.into());
    m.insert("T".into(), hex_val(r.ttl as u32));
    m.insert("TG".into(), hex_val(ttl_guess(r.ttl) as u32));
    m.insert("S".into(), seq_relation(r.seq, probe_ack));
    m.insert("A".into(), ack_relation(r.ack_num, probe_seq));
    m.insert("F".into(), flags_str(r.flags));
    m.insert("RD".into(), rd_value(&r.data));
    m.insert("Q".into(), quirks_str(r.reserved, r.flags, r.urg_ptr));
    Some(m)
}

fn build_tx_test(
    reply: &Option<TcpReply>,
    probe_seq: u32,
    probe_ack: u32,
) -> Option<HashMap<String, String>> {
    let mut m = HashMap::new();
    let Some(r) = reply else {
        m.insert("R".into(), "N".into());
        return Some(m);
    };
    m.insert("R".into(), "Y".into());
    m.insert("DF".into(), if r.df { "Y" } else { "N" }.into());
    m.insert("T".into(), hex_val(r.ttl as u32));
    m.insert("TG".into(), hex_val(ttl_guess(r.ttl) as u32));
    m.insert("W".into(), hex_val(r.window as u32));
    m.insert("S".into(), seq_relation(r.seq, probe_ack));
    m.insert("A".into(), ack_relation(r.ack_num, probe_seq));
    m.insert("F".into(), flags_str(r.flags));
    m.insert("O".into(), r.options_fp.clone());
    m.insert("RD".into(), rd_value(&r.data));
    m.insert("Q".into(), quirks_str(r.reserved, r.flags, r.urg_ptr));
    Some(m)
}

fn build_u1_test(
    reply: &Option<IcmpUnreachReply>,
    sent_ip_id: u16,
    sent_udp_cksum: u16,
    sent_ip_total_len: u16,
    sent_data: &[u8],
) -> Option<HashMap<String, String>> {
    let mut m = HashMap::new();
    let Some(r) = reply else {
        m.insert("R".into(), "N".into());
        return Some(m);
    };
    m.insert("R".into(), "Y".into());
    m.insert("DF".into(), if r.df { "Y" } else { "N" }.into());
    m.insert("T".into(), hex_val(r.ttl as u32));
    m.insert("TG".into(), hex_val(ttl_guess(r.ttl) as u32));
    m.insert("IPL".into(), hex_val(r.ip_total_len as u32));
    m.insert("UN".into(), hex_val(r.unused));
    m.insert(
        "RIPL".into(),
        if r.embedded_ip_total_len == sent_ip_total_len {
            "G".into()
        } else {
            hex_val(r.embedded_ip_total_len as u32)
        },
    );
    m.insert(
        "RID".into(),
        if r.embedded_ip_id == sent_ip_id {
            "G".into()
        } else {
            hex_val(r.embedded_ip_id as u32)
        },
    );
    m.insert(
        "RIPCK".into(),
        if r.embedded_ip_cksum == 0 {
            "Z".into()
        } else if r.embedded_ip_cksum == r.embedded_ip_cksum_computed {
            "G".into()
        } else {
            "I".into()
        },
    );
    m.insert(
        "RUCK".into(),
        if r.embedded_udp_cksum == 0 {
            "Z".into()
        } else if r.embedded_udp_cksum == sent_udp_cksum {
            "G".into()
        } else {
            "I".into()
        },
    );
    let rud_ok = r.embedded_udp_data.len() >= sent_data.len().min(r.embedded_udp_data.len())
        && r.embedded_udp_data
            .starts_with(&sent_data[..sent_data.len().min(r.embedded_udp_data.len())]);
    m.insert(
        "RUD".into(),
        if rud_ok && !r.embedded_udp_data.is_empty() {
            "G".into()
        } else {
            "I".into()
        },
    );
    Some(m)
}

fn build_ie_test(
    ie1: &Option<IcmpEchoReply>,
    ie2: &Option<IcmpEchoReply>,
) -> Option<HashMap<String, String>> {
    let mut m = HashMap::new();
    if ie1.is_none() && ie2.is_none() {
        m.insert("R".into(), "N".into());
        return Some(m);
    }
    m.insert("R".into(), "Y".into());
    let dfi = match (ie1.as_ref().map(|r| r.df), ie2.as_ref().map(|r| r.df)) {
        (Some(true), Some(true)) => "Y",
        (Some(false), Some(false)) => "N",
        (Some(true), Some(false)) => "S",
        _ => "O",
    };
    m.insert("DFI".into(), dfi.into());
    if let Some(r) = ie1.as_ref().or(ie2.as_ref()) {
        m.insert("T".into(), hex_val(r.ttl as u32));
        m.insert("TG".into(), hex_val(ttl_guess(r.ttl) as u32));
    }
    let cd = match (ie1.as_ref().map(|r| r.code), ie2.as_ref().map(|r| r.code)) {
        (Some(0), Some(0)) => "Z",
        (Some(9), Some(0)) => "S",
        _ => "O",
    };
    m.insert("CD".into(), cd.into());
    Some(m)
}

// ───── main entry point ─────────────────────────────────────────

/// Run all IPv4 OS probes against `target`. Returns a subject fingerprint.
pub fn probe_ipv4_os(
    target: Ipv4Addr,
    open_tcp: u16,
    closed_tcp: u16,
    closed_udp: u16,
    probe_timeout: Duration,
) -> Result<SubjectFingerprint> {
    let (mut tcp_tx, mut tcp_rx) = transport_channel(
        65536,
        TransportChannelType::Layer3(IpNextHeaderProtocols::Tcp),
    )
    .context("raw TCP for OS detection — try sudo / capabilities")?;
    let (_icmp_tx, mut icmp_rx) = transport_channel(
        65536,
        TransportChannelType::Layer3(IpNextHeaderProtocols::Icmp),
    )
    .context("raw ICMP for OS detection")?;

    let src = local_ipv4().context("local IPv4 for OS probes")?;
    let mut rng = rand::thread_rng();
    let tcp_port_base: u16 = 33000 + (rng.gen::<u32>() % PRIME_32K) as u16;
    let udp_port_base: u16 = 33000 + (rng.gen::<u32>() % PRIME_32K) as u16;
    let tcp_seq_base: u32 = rng.gen();
    let tcp_ack: u32 = rng.gen();
    let icmp_id: u16 = rng.gen();
    let mut ipid_ctr: u16 = rng.gen();

    // ── Phase 1: SEQ probes S1-S6 ──
    let mut seq_replies: [Option<TcpReply>; NUM_SEQ_SAMPLES] = std::array::from_fn(|_| None);
    for i in 0..NUM_SEQ_SAMPLES {
        let sport = tcp_port_base + i as u16;
        let id = next_ipid(&mut ipid_ctr);
        send_tcp_probe(
            &mut tcp_tx,
            src,
            target,
            sport,
            open_tcp,
            tcp_seq_base + i as u32,
            0,
            0,
            TcpFlags::SYN,
            PRB_WIN[i],
            0,
            false,
            PRB_OPTS[i],
            64,
            id,
        )?;
        seq_replies[i] = recv_tcp_reply(&mut tcp_rx, target, sport, probe_timeout)?;
        if i + 1 < NUM_SEQ_SAMPLES {
            thread::sleep(Duration::from_millis(OS_SEQ_PROBE_DELAY_MS));
        }
    }

    // ── Phase 2: ECN ──
    let ecn_sport = tcp_port_base + 6;
    send_tcp_probe(
        &mut tcp_tx,
        src,
        target,
        ecn_sport,
        open_tcp,
        tcp_seq_base,
        0,
        0,
        TcpFlags::SYN | TcpFlags::ECE | TcpFlags::CWR,
        PRB_WIN[6],
        0,
        false,
        PRB_OPTS[6],
        64,
        next_ipid(&mut ipid_ctr),
    )?;
    let ecn_reply = recv_tcp_reply(&mut tcp_rx, target, ecn_sport, probe_timeout)?;

    // ── Phase 3: T2-T4 (open port) ──
    let mut send_t = |idx: usize, dport: u16, flags: u8| -> io::Result<Option<TcpReply>> {
        let sport = tcp_port_base + idx as u16;
        let id = next_ipid(&mut ipid_ctr);
        send_tcp_probe(
            &mut tcp_tx,
            src,
            target,
            sport,
            dport,
            tcp_seq_base,
            tcp_ack,
            0,
            flags,
            PRB_WIN[idx],
            0,
            false,
            PRB_OPTS[idx],
            64,
            id,
        )?;
        recv_tcp_reply(&mut tcp_rx, target, sport, probe_timeout)
    };
    let t2_reply = send_t(7, open_tcp, 0)?;
    let t3_reply = send_t(
        8,
        open_tcp,
        TcpFlags::SYN | TcpFlags::FIN | TcpFlags::URG | TcpFlags::PSH,
    )?;
    let t4_reply = send_t(9, open_tcp, TcpFlags::ACK)?;

    // ── Phase 4: T5-T7 (closed port) ──
    let t5_reply = send_t(10, closed_tcp, TcpFlags::SYN)?;
    let t6_reply = send_t(11, closed_tcp, TcpFlags::ACK)?;
    let t7_reply = send_t(
        12,
        closed_tcp,
        TcpFlags::FIN | TcpFlags::PSH | TcpFlags::URG,
    )?;

    // ── Phase 5: U1 (UDP to closed port) ──
    let u1_data = [0x43u8; 300];
    let u1_ipid = next_ipid(&mut ipid_ctr);
    let u1_ip_total: u16 = 20 + 8 + 300;
    let u1_udp_ck = send_udp_probe(
        &mut tcp_tx,
        src,
        target,
        udp_port_base,
        closed_udp,
        &u1_data,
        64,
        true,
        u1_ipid,
    )?;
    let u1_reply = recv_icmp_unreachable(&mut icmp_rx, target, closed_udp, probe_timeout)?;

    // ── Phase 6: IE (2 ICMP echo requests) ──
    send_icmp_echo(
        &mut tcp_tx,
        src,
        target,
        9,
        icmp_id,
        295,
        120,
        64,
        true,
        0,
        next_ipid(&mut ipid_ctr),
    )?;
    let ie1 = recv_icmp_echo_reply(&mut icmp_rx, target, icmp_id, probe_timeout)?;
    thread::sleep(Duration::from_millis(OS_SEQ_PROBE_DELAY_MS));
    send_icmp_echo(
        &mut tcp_tx,
        src,
        target,
        0,
        icmp_id + 1,
        296,
        150,
        64,
        false,
        4,
        next_ipid(&mut ipid_ctr),
    )?;
    let ie2 = recv_icmp_echo_reply(&mut icmp_rx, target, icmp_id + 1, probe_timeout)?;

    // ── Build subject fingerprint ──
    let mut subject = SubjectFingerprint::default();

    let good: Vec<usize> = (0..NUM_SEQ_SAMPLES)
        .filter(|&i| seq_replies[i].is_some())
        .collect();
    if good.len() >= 4 {
        let tcp_open_ipids: Vec<u16> = good
            .iter()
            .map(|&i| seq_replies[i].as_ref().unwrap().ipid)
            .collect();
        let tcp_closed_ipids: Vec<u16> = [&t5_reply, &t6_reply, &t7_reply]
            .iter()
            .filter_map(|r| r.as_ref().map(|t| t.ipid))
            .collect();
        let icmp_ipids: Vec<u16> = [&ie1, &ie2]
            .iter()
            .filter_map(|r| r.as_ref().map(|e| e.ipid))
            .collect();
        let seq_nums: Vec<u32> = good
            .iter()
            .map(|&i| seq_replies[i].as_ref().unwrap().seq)
            .collect();
        let seq_times: Vec<Instant> = good
            .iter()
            .map(|&i| seq_replies[i].as_ref().unwrap().recv_time)
            .collect();
        let timestamps: Vec<u32> = good
            .iter()
            .map(|&i| seq_replies[i].as_ref().unwrap().tsval)
            .collect();

        // SEQ test
        subject.tests[0] = Some(build_seq_test(
            &seq_nums,
            &seq_times,
            &timestamps,
            &tcp_open_ipids,
            &tcp_closed_ipids,
            &icmp_ipids,
        ));

        // OPS test (O1-O6)
        let mut ops = HashMap::new();
        for (j, &i) in good.iter().enumerate().take(6) {
            ops.insert(
                format!("O{}", j + 1),
                seq_replies[i].as_ref().unwrap().options_fp.clone(),
            );
        }
        subject.tests[1] = Some(ops);

        // WIN test (W1-W6)
        let mut win = HashMap::new();
        for (j, &i) in good.iter().enumerate().take(6) {
            win.insert(
                format!("W{}", j + 1),
                hex_val(seq_replies[i].as_ref().unwrap().window as u32),
            );
        }
        subject.tests[2] = Some(win);
    }

    // ECN
    subject.tests[3] = build_ecn_test(&ecn_reply, tcp_seq_base);

    // T1 (first SEQ response)
    subject.tests[4] = build_t1_test(&seq_replies[0], tcp_seq_base, 0);

    // T2-T7
    subject.tests[5] = build_tx_test(&t2_reply, tcp_seq_base, tcp_ack);
    subject.tests[6] = build_tx_test(&t3_reply, tcp_seq_base, tcp_ack);
    subject.tests[7] = build_tx_test(&t4_reply, tcp_seq_base, tcp_ack);
    subject.tests[8] = build_tx_test(&t5_reply, tcp_seq_base, tcp_ack);
    subject.tests[9] = build_tx_test(&t6_reply, tcp_seq_base, tcp_ack);
    subject.tests[10] = build_tx_test(&t7_reply, tcp_seq_base, tcp_ack);

    // U1
    subject.tests[11] = build_u1_test(&u1_reply, u1_ipid, u1_udp_ck, u1_ip_total, &u1_data);

    // IE
    subject.tests[12] = build_ie_test(&ie1, &ie2);

    Ok(subject)
}

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

    // ─── internet_checksum (RFC 1071) ────────────────────────────────

    #[test]
    fn internet_checksum_known_vector_rfc1071() {
        // RFC 1071 worked-example: data = 00 01 f2 03 f4 f5 f6 f7
        // → sum = f201 + f203 + f4f5 + f6f7 = 2ddf0
        // → carry-fold: ddf0 + 2 = ddf2 → !ddf2 = 220d.
        let data = [0x00, 0x01, 0xf2, 0x03, 0xf4, 0xf5, 0xf6, 0xf7];
        assert_eq!(internet_checksum(&data), 0x220d);
    }

    #[test]
    fn internet_checksum_self_inverse() {
        // Replacing the checksum bytes with the computed checksum AND
        // re-running the checksum must yield 0 (canonical "checksum of
        // a packet with its own checksum field is 0" property).
        let mut data = vec![
            0x45, 0x00, 0x00, 0x14, 0x00, 0x00, 0x40, 0x00, 0x40, 0x06, 0x00,
            0x00, // checksum slot
            192, 168, 0, 1, 192, 168, 0, 2,
        ];
        let cs = internet_checksum(&data);
        data[10] = (cs >> 8) as u8;
        data[11] = cs as u8;
        assert_eq!(
            internet_checksum(&data),
            0,
            "packet with embedded checksum must verify as 0"
        );
    }

    #[test]
    fn internet_checksum_handles_odd_length() {
        // Odd byte gets shifted left 8 per RFC 1071.
        let data = [0xaa, 0xbb, 0xcc];
        // sum = aabb + cc00 = 176bb → 76bb + 1 = 76bc → !76bc = 8943.
        assert_eq!(internet_checksum(&data), 0x8943);
    }

    #[test]
    fn internet_checksum_empty_returns_all_ones() {
        assert_eq!(internet_checksum(&[]), 0xffff);
    }

    // ─── ttl_guess ───────────────────────────────────────────────────

    #[test]
    fn ttl_guess_buckets() {
        // Common OS TTL defaults: Linux=64, Windows=128, Solaris/Cisco=255.
        // ttl_guess rounds UP to the next standard.
        assert_eq!(ttl_guess(1), 32);
        assert_eq!(ttl_guess(32), 32);
        assert_eq!(ttl_guess(33), 64);
        assert_eq!(ttl_guess(64), 64);
        assert_eq!(ttl_guess(65), 128);
        assert_eq!(ttl_guess(128), 128);
        assert_eq!(ttl_guess(129), 255);
        assert_eq!(ttl_guess(255), 255);
    }

    // ─── flags_str ───────────────────────────────────────────────────

    #[test]
    fn flags_str_canonical_order_for_syn_ack() {
        // Per nmap doc-spec the string order is E·U·A·P·R·S·F.
        let s = flags_str(TcpFlags::SYN | TcpFlags::ACK);
        assert_eq!(s, "AS");
    }

    #[test]
    fn flags_str_empty_when_no_flags() {
        assert_eq!(flags_str(0), "");
    }

    #[test]
    fn flags_str_full_house() {
        let all = TcpFlags::ECE
            | TcpFlags::URG
            | TcpFlags::ACK
            | TcpFlags::PSH
            | TcpFlags::RST
            | TcpFlags::SYN
            | TcpFlags::FIN;
        assert_eq!(flags_str(all), "EUAPRSF");
    }

    // ─── quirks_str ──────────────────────────────────────────────────

    #[test]
    fn quirks_reserved_bits_set() {
        assert_eq!(quirks_str(1, 0, 0), "R");
    }

    #[test]
    fn quirks_urg_pointer_set_without_urg_flag() {
        // Probe stack quirk: URG pointer non-zero but URG flag clear.
        assert_eq!(quirks_str(0, 0, 1234), "U");
    }

    #[test]
    fn quirks_urg_pointer_with_urg_flag_no_quirk() {
        // URG pointer is legitimate when URG flag is also set.
        assert_eq!(quirks_str(0, TcpFlags::URG, 1234), "");
    }

    #[test]
    fn quirks_both_reserved_and_urg() {
        assert_eq!(quirks_str(1, 0, 100), "RU");
    }

    // ─── seq/ack relation classifiers (nmap fingerprint OPS) ────────

    #[test]
    fn seq_relation_zero_when_seq_is_zero() {
        assert_eq!(seq_relation(0, 12345), "Z");
    }

    #[test]
    fn seq_relation_equals_probe_ack_is_a() {
        assert_eq!(seq_relation(12345, 12345), "A");
    }

    #[test]
    fn seq_relation_one_above_probe_ack_is_a_plus() {
        assert_eq!(seq_relation(12346, 12345), "A+");
    }

    #[test]
    fn seq_relation_other_when_unrelated() {
        assert_eq!(seq_relation(99999, 12345), "O");
    }

    #[test]
    fn seq_relation_zero_branch_wins_over_wrapping() {
        // probe_ack at u32::MAX, response seq at 0 — the Z check fires
        // FIRST (resp_seq == 0), so even though wrap-around math would
        // classify this as A+, the contract returns Z. Pinning so a
        // future refactor doesn't silently flip the priority.
        assert_eq!(seq_relation(0, u32::MAX), "Z");
    }

    #[test]
    fn seq_relation_wrapping_a_plus_when_seq_nonzero() {
        // probe_ack = u32::MAX - 1, resp_seq = u32::MAX exercises the
        // wrap path: probe_ack.wrapping_add(1) == u32::MAX → A+.
        assert_eq!(seq_relation(u32::MAX, u32::MAX - 1), "A+");
    }

    #[test]
    fn ack_relation_zero() {
        assert_eq!(ack_relation(0, 100), "Z");
    }

    #[test]
    fn ack_relation_matches_probe_seq() {
        assert_eq!(ack_relation(100, 100), "S");
    }

    #[test]
    fn ack_relation_one_above_is_s_plus() {
        assert_eq!(ack_relation(101, 100), "S+");
    }

    #[test]
    fn ack_relation_other() {
        assert_eq!(ack_relation(50000, 100), "O");
    }

    // ─── cc_str (CC field of nmap fingerprint) ──────────────────────

    #[test]
    fn cc_ece_only_is_y() {
        assert_eq!(cc_str(TcpFlags::ECE), "Y");
    }

    #[test]
    fn cc_neither_is_n() {
        assert_eq!(cc_str(0), "N");
    }

    #[test]
    fn cc_both_is_s() {
        assert_eq!(cc_str(TcpFlags::ECE | TcpFlags::CWR), "S");
    }

    #[test]
    fn cc_cwr_only_is_o() {
        assert_eq!(cc_str(TcpFlags::CWR), "O");
    }

    // ─── rd_value (CRC32 of ICMP echo data) ─────────────────────────

    #[test]
    fn rd_value_empty_data_is_zero_literal() {
        assert_eq!(rd_value(&[]), "0");
    }

    #[test]
    fn rd_value_deterministic_for_same_input() {
        let a = rd_value(b"hello");
        let b = rd_value(b"hello");
        assert_eq!(a, b);
    }

    #[test]
    fn rd_value_diverges_on_one_byte_mutation() {
        assert_ne!(rd_value(b"hello"), rd_value(b"hellp"));
    }

    // ─── next_ipid (IP ID counter with wrap) ────────────────────────

    #[test]
    fn next_ipid_returns_current_then_increments() {
        let mut c: u16 = 42;
        assert_eq!(next_ipid(&mut c), 42);
        assert_eq!(c, 43);
    }

    #[test]
    fn next_ipid_wraps_at_max() {
        let mut c: u16 = u16::MAX;
        assert_eq!(next_ipid(&mut c), u16::MAX);
        assert_eq!(c, 0, "wraps cleanly to 0");
    }

    // ─── gcd helpers (used in TS-classifier and IP-ID GCD) ──────────

    #[test]
    fn gcd_two_basic() {
        assert_eq!(gcd_two(48, 18), 6);
        assert_eq!(gcd_two(100, 25), 25);
        assert_eq!(gcd_two(17, 13), 1); // coprime
    }

    #[test]
    fn gcd_two_with_zero_returns_other_min_1() {
        // The impl returns a.max(1) when b is 0 — so gcd(0,0) -> 1, not 0.
        assert_eq!(gcd_two(0, 0), 1);
        assert_eq!(gcd_two(7, 0), 7);
        assert_eq!(gcd_two(0, 5), 5);
    }

    #[test]
    fn gcd_many_empty_returns_one() {
        assert_eq!(gcd_many(&[]), 1);
    }

    #[test]
    fn gcd_many_single_value_returns_self() {
        assert_eq!(gcd_many(&[42]), 42);
    }

    #[test]
    fn gcd_many_reduces_across_values() {
        // gcd(12, 18, 30) = 6.
        assert_eq!(gcd_many(&[12, 18, 30]), 6);
    }

    #[test]
    fn gcd_many_coprime_set_is_one() {
        assert_eq!(gcd_many(&[7, 11, 13]), 1);
    }

    // ─── mod_diff_u32 (minimum circular distance) ───────────────────

    #[test]
    fn mod_diff_picks_shorter_arc() {
        // |10 - 5| = 5 (direct) vs |5 - 10| wraps = u32::MAX - 5 + 1 ≈ huge.
        // mod_diff returns the smaller of the two.
        assert_eq!(mod_diff_u32(10, 5), 5);
        assert_eq!(mod_diff_u32(5, 10), 5);
    }

    #[test]
    fn mod_diff_wraps_around_u32_max() {
        // Distance from u32::MAX to 0 going forward is 1.
        assert_eq!(mod_diff_u32(u32::MAX, 0), 1);
        assert_eq!(mod_diff_u32(0, u32::MAX), 1);
    }

    #[test]
    fn mod_diff_self_is_zero() {
        assert_eq!(mod_diff_u32(12345, 12345), 0);
    }

    // ─── classify_ipid (nmap OS-fingerprint IP-ID classifier) ───────

    #[test]
    fn classify_ipid_under_two_samples_is_other() {
        assert_eq!(classify_ipid(&[]), "O");
        assert_eq!(classify_ipid(&[100]), "O");
    }

    #[test]
    fn classify_ipid_all_zero_is_z() {
        assert_eq!(classify_ipid(&[0, 0, 0, 0]), "Z");
    }

    #[test]
    fn classify_ipid_zero_diffs_is_z() {
        // Non-zero values but no change between them.
        assert_eq!(classify_ipid(&[42, 42, 42]), "Z");
    }

    #[test]
    fn classify_ipid_random_jumps_is_rd() {
        // Any single diff > 20,000 forces RD (random-distribution).
        assert_eq!(classify_ipid(&[100, 60_000, 5_000]), "RD");
    }

    #[test]
    fn classify_ipid_small_byte_swapped_increments_is_bi() {
        // Values 100/110/.../140 have low-byte increments — and their
        // byte-swapped form (0x6400, 0x6E00, ...) ALSO has orderly
        // small diffs. The BI check fires before "I", so a sequence
        // whose swap_bytes form also looks incremental gets BI.
        assert_eq!(
            classify_ipid(&[100, 110, 120, 130, 140]),
            "BI",
            "byte-swap-orderly sequence MUST trip BI before I"
        );
    }

    #[test]
    fn classify_ipid_incremental_when_byte_swap_path_disqualified() {
        // To reach "I" the swap_bytes form must FAIL the BI check —
        // some adjacent swap_diff > 20,000. 100, 200, 300 swap to
        // 0x6400, 0xC800, 0x2C01 → 0xC800-0x6400 = 25,600 > 20k → BI
        // rejected → falls through to "I" via low-var low-avg path.
        assert_eq!(classify_ipid(&[100, 200, 300]), "I");
    }

    // ─── hex_val ────────────────────────────────────────────────────

    #[test]
    fn hex_val_uppercase_no_prefix() {
        assert_eq!(hex_val(0xdeadbeef), "DEADBEEF");
        assert_eq!(hex_val(0), "0");
        assert_eq!(hex_val(0xff), "FF");
    }

    #[test]
    fn seq_relation_one_below_probe_ack_is_other() {
        assert_eq!(seq_relation(12344, 12345), "O");
    }

    #[test]
    fn ack_relation_one_below_probe_seq_is_other() {
        assert_eq!(ack_relation(99, 100), "O");
    }

    #[test]
    fn classify_ipid_two_samples_small_diff_is_bi_or_incremental() {
        // Two-sample sequences with diff=1 often satisfy both byte-order and
        // incremental heuristics; pin either BI or I, not a single wrong label.
        let label = classify_ipid(&[1000, 1001]);
        assert!(label == "BI" || label == "I", "unexpected {label}");
    }

    #[test]
    fn gcd_many_all_equal_values() {
        assert_eq!(gcd_many(&[7, 7, 7, 7]), 7);
    }

    #[test]
    fn mod_diff_half_u32_range() {
        assert_eq!(mod_diff_u32(0, 1u32 << 31), 1u32 << 31);
    }

    #[test]
    fn rd_value_differs_for_empty_vs_nonempty() {
        assert_ne!(rd_value(&[]), rd_value(b"x"));
    }

    #[test]
    fn quirks_clean_packet_has_no_quirks() {
        assert_eq!(quirks_str(0, 0, 0), "");
    }

    #[test]
    fn classify_ts_single_sample_is_u() {
        assert_eq!(classify_ts(&[1000], &[Instant::now()]), "U");
    }

    #[test]
    fn classify_ts_all_zero_tsvals_is_zero() {
        let t0 = Instant::now();
        let t1 = t0 + Duration::from_millis(500);
        assert_eq!(classify_ts(&[0, 0, 0], &[t0, t1, t1]), "0");
    }

    #[test]
    fn classify_ts_same_recv_times_is_u() {
        let t0 = Instant::now();
        assert_eq!(classify_ts(&[1000, 5000], &[t0, t0]), "U");
    }

    #[test]
    fn shared_ipid_seq_needs_two_tcp_samples() {
        assert_eq!(shared_ipid_seq(&[100], &[100, 101]), "O");
    }

    #[test]
    fn shared_ipid_seq_needs_icmp_samples() {
        assert_eq!(shared_ipid_seq(&[100, 200, 300], &[]), "O");
    }

    #[test]
    fn shared_ipid_seq_incremental_tcp_and_icmp_close_is_s() {
        assert_eq!(shared_ipid_seq(&[100, 200, 300], &[301, 401]), "S");
    }

    #[test]
    fn shared_ipid_seq_far_icmp_gap_is_other() {
        assert_eq!(shared_ipid_seq(&[100, 200, 300], &[1000, 1100]), "O");
    }

    #[test]
    fn flags_str_fin_only() {
        assert_eq!(flags_str(TcpFlags::FIN), "F");
    }

    #[test]
    fn flags_str_rst_only() {
        assert_eq!(flags_str(TcpFlags::RST), "R");
    }

    #[test]
    fn flags_str_psh_urg_combo() {
        assert_eq!(flags_str(TcpFlags::PSH | TcpFlags::URG), "UP");
    }

    #[test]
    fn cc_str_cwr_without_ece_is_other() {
        assert_eq!(cc_str(TcpFlags::CWR), "O");
    }

    #[test]
    fn cc_str_ece_and_cwr_is_s() {
        assert_eq!(cc_str(TcpFlags::ECE | TcpFlags::CWR), "S");
    }

    #[test]
    fn rd_value_nonempty_is_uppercase_hex() {
        let v = rd_value(b"probe");
        assert!(!v.is_empty());
        assert!(v.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn gcd_many_single_element() {
        assert_eq!(gcd_many(&[42]), 42);
    }

    #[test]
    fn mod_diff_u32_identical_is_zero() {
        assert_eq!(mod_diff_u32(12345, 12345), 0);
    }

    #[test]
    fn hex_val_single_byte() {
        assert_eq!(hex_val(0xff), "FF");
    }

    #[test]
    fn quirks_reserved_nonzero_is_r() {
        assert_eq!(quirks_str(1, 0, 0), "R");
    }

    #[test]
    fn quirks_urg_without_urg_flag_is_u() {
        assert_eq!(quirks_str(0, 0, 1), "U");
    }

    #[test]
    fn classify_ts_two_zeros_with_elapsed_time_is_zero() {
        let t0 = Instant::now();
        let t1 = t0 + Duration::from_secs(2);
        assert_eq!(classify_ts(&[0, 0], &[t0, t1]), "0");
    }

    #[test]
    fn classify_ts_slow_increment_returns_one_bucket() {
        let t0 = Instant::now();
        let t1 = t0 + Duration::from_secs(1);
        assert_eq!(classify_ts(&[1000, 1004], &[t0, t1]), "1");
    }

    #[test]
    fn ttl_guess_exactly_thirty_two() {
        assert_eq!(ttl_guess(32), 32);
    }

    #[test]
    fn internet_checksum_all_ones_word_folds_to_zero() {
        let data = [0xff, 0xff];
        assert_eq!(internet_checksum(&data), 0);
    }

    #[test]
    fn internet_checksum_empty_is_ffff() {
        assert_eq!(internet_checksum(&[]), !0u16);
    }

    #[test]
    fn internet_checksum_odd_length_last_byte() {
        let data = [0x00, 0x01, 0x02];
        let cs = internet_checksum(&data);
        assert_ne!(cs, 0);
    }

    #[test]
    fn next_ipid_sequential_three_values() {
        let mut c = 100u16;
        assert_eq!(next_ipid(&mut c), 100);
        assert_eq!(next_ipid(&mut c), 101);
        assert_eq!(next_ipid(&mut c), 102);
    }

    #[test]
    fn flags_str_syn_ack_combo() {
        assert_eq!(flags_str(TcpFlags::SYN | TcpFlags::ACK), "AS");
    }

    #[test]
    fn ttl_guess_one_returns_thirty_two() {
        assert_eq!(ttl_guess(1), 32);
    }

    #[test]
    fn ttl_guess_two_fifty_five_returns_two_fifty_five() {
        assert_eq!(ttl_guess(255), 255);
    }

    #[test]
    fn gcd_two_equal_nonzero() {
        assert_eq!(gcd_two(13, 13), 13);
    }

    #[test]
    fn mod_diff_u32_wraparound() {
        assert_eq!(mod_diff_u32(0, u32::MAX), 1);
    }

    #[test]
    fn classify_ipid_single_increment_is_i_or_bi() {
        let label = classify_ipid(&[500, 501, 502, 503, 504]);
        assert!(label == "I" || label == "BI", "unexpected {label}");
    }
}
