//! Local-interface filter for the iroh `Endpoint`'s advertised address set (peat#890).
//!
//! When a caller binds the iroh endpoint to an unspecified address — `0.0.0.0`
//! or `[::]` — iroh enumerates every local interface and publishes the full
//! set to peers as candidate dial targets. On a host with docker bridges,
//! a tailscale interface, and a Wi-Fi NIC alongside the LAN Ethernet, that
//! "full set" can be a dozen addresses — most of them unreachable from any
//! given peer. Peers race the candidates and dial the bad ones, producing
//! the `noq_udp: sendmsg EIO/EINVAL` warnings the field report in peat#890
//! caught.
//!
//! This module enumerates the host's interfaces via [`if_addrs`], drops
//! addresses that are known to be non-routable from a typical LAN peer's
//! perspective, and returns the survivors. Callers pass each survivor to
//! iroh's `Endpoint::Builder::bind_addr_with_opts` so the endpoint is bound
//! only to the chosen interfaces — which is what controls what ends up in
//! the published `NodeAddr` for peers to dial.
//!
//! # Where this is used
//!
//! - `peat_mesh::storage::iroh_blob_store::IrohBlobStore::build_endpoint_with_hooks`
//!   calls [`select_advertise_interfaces`] when the configured bind address
//!   is unspecified.
//! - `peat-protocol::network::iroh_transport::IrohTransport::from_seed_at_addr`
//!   in the consumer crate does the same; it depends on this module via the
//!   public `peat_mesh::storage::interface_filter` re-export so the
//!   transport-specific filter logic lives in one place (peat-mesh, per
//!   peat/SKILL.md's transport-agnosticism rule).
//!
//! # Default filter heuristics
//!
//! - **Loopback** (`127.0.0.0/8`, `::1/128`) — never useful to advertise
//!   off-host.
//! - **Link-local** (`169.254.0.0/16`, `fe80::/10`) — DHCP-failure / scope-
//!   limited; not routable.
//! - **Tailscale CGNAT** (`100.64.0.0/10`, RFC 6598) — intra-tailnet only.
//!   Peers on a normal LAN can't reach them. The tailscale interface name
//!   pattern (`tailscale*`) is also matched as a belt-and-braces signal.
//! - **Docker / container-runtime bridges** — matched by *interface name*
//!   pattern rather than by IP range, so operators whose actual LAN sits in
//!   `172.16.0.0/12` (a legitimate RFC 1918 range) aren't filtered out.
//!   Specifically: `docker0` (default bridge), `br-<12-hex>` (docker's
//!   user-defined-network convention — *not* arbitrary `br-*` names like
//!   `br-corp` or `br-lan`, which are legitimate Linux bridge names),
//!   `podman*`, `cni0`, `cni-*`.
//!
//! # Overrides
//!
//! - **`PEAT_ADVERTISE_ALL_INTERFACES=1`** disables the filter entirely —
//!   restores the pre-peat#890 behavior of binding to every interface.
//!   Useful for diagnostics or for hosts where the heuristics are wrong.
//!   Loopback is still dropped (always non-useful).
//! - **`PEAT_ADVERTISE_INTERFACES=eno1,eth0`** restricts the advertise set
//!   to an explicit allowlist of interface names. Case-sensitive. Names not
//!   present on the host are silently skipped. Overrides defaults *and* the
//!   bypass switch — operator-pinned beats default heuristics. Loopback is
//!   the one exception: it's always dropped (even if explicitly listed)
//!   because a remote peer can never dial `127.0.0.1`.
//!
//! Both knobs are read once on every call to [`select_advertise_interfaces`].

use std::net::{IpAddr, SocketAddr};

/// Env var that disables the default heuristics (peat#890).
pub const ADVERTISE_ALL_INTERFACES_ENV: &str = "PEAT_ADVERTISE_ALL_INTERFACES";

/// Env var that restricts advertisement to an explicit allowlist of
/// interface names, comma-separated (peat#890).
pub const ADVERTISE_INTERFACES_ENV: &str = "PEAT_ADVERTISE_INTERFACES";

/// One (iface, addr, prefix-length) tuple that survived the filter.
///
/// The prefix length matters because iroh's `Endpoint::Builder` only allows
/// one address per family to be marked as the *default route* (`/0`), and
/// when binding multiple specific addresses each must carry the netmask of
/// its interface so iroh can route outgoing flows by longest-prefix match
/// against destination addresses. Passing every bind as `/0` produces:
///
/// ```text
/// Only a single default address can be set per IP family
/// ```
///
/// — so we carry the real CIDR prefix from `if_addrs` straight through to
/// `bind_addr_with_opts(addr, BindOpts::default().set_prefix_len(plen))`.
#[derive(Debug, Clone, Copy)]
pub struct BoundAddr {
    /// `(interface_ip, requested_port)` to pass to `bind_addr_with_opts`.
    pub addr: SocketAddr,
    /// CIDR prefix of the interface (from `if_addrs::Ifv{4,6}Addr::prefixlen`).
    /// Forwarded to `BindOpts::set_prefix_len` so iroh routes flows by
    /// longest-prefix match instead of treating every binding as the
    /// default route.
    pub prefix_len: u8,
}

/// Outcome of one filter pass — keeps the iface name alongside the IP so the
/// caller can log a friendly "bound 2 of 7 — chose eno1, wlp11s0" line.
#[derive(Debug, Clone)]
pub struct InterfaceSelection {
    /// Sockets to actually bind, one per surviving (iface, address) pair.
    pub bind_addrs: Vec<BoundAddr>,
    /// Interface names that survived the filter, in selection order. Same
    /// length as `bind_addrs`. Used purely for logging.
    pub kept_interfaces: Vec<String>,
    /// Interface names the filter dropped, with a static reason string.
    /// Order matches enumeration order. Used purely for logging.
    pub dropped: Vec<(String, &'static str)>,
}

/// Enumerate local interfaces and return the addresses that should be
/// bound + advertised, given the requested port.
///
/// `requested_port` is the port from the caller's `--bind` socket addr;
/// every surviving interface gets a `SocketAddr` at that port.
///
/// `want_v6` controls whether IPv6 addresses are considered. The caller
/// passes `false` to skip v6 (e.g. when only IPv4 multicast / sync is in
/// use); passes `true` to include IPv6 too.
///
/// If `if_addrs` returns no interfaces, or none survive the filter, the
/// returned `bind_addrs` is empty — the caller should fall back to a
/// permissive bind (e.g. `0.0.0.0:port`) and surface a warning.
pub fn select_advertise_interfaces(requested_port: u16, want_v6: bool) -> InterfaceSelection {
    select_advertise_interfaces_with(if_addrs::get_if_addrs(), requested_port, want_v6)
}

/// Testable / injectable variant of [`select_advertise_interfaces`].
///
/// Same filter behavior as the convenience function above, but takes the
/// `if_addrs::get_if_addrs()` result as an argument so tests can hand it
/// a synthetic interface list (host-independent assertions) *and* exercise
/// the `Err` branch (`if_addrs` failure → empty `bind_addrs` + a
/// `(enumeration failed, if_addrs error)` drop record) without needing
/// dependency-injection scaffolding around the platform call.
///
/// `pub(crate)` deliberately: this surface exists for in-crate tests, and
/// its signature exposes `if_addrs::Interface` — a type from a pre-1.0
/// third-party crate. Promoting that to a public API would couple
/// downstream consumers to whichever `if_addrs` major peat-mesh happens
/// to pin. Production callers should use [`select_advertise_interfaces`].
pub(crate) fn select_advertise_interfaces_with(
    enumerated: std::io::Result<Vec<if_addrs::Interface>>,
    requested_port: u16,
    want_v6: bool,
) -> InterfaceSelection {
    let mut bind_addrs = Vec::new();
    let mut kept_interfaces = Vec::new();
    let mut dropped = Vec::new();

    // Read env-var overrides once. Both `unset` and `"0"` keep filtering on.
    let bypass = matches!(
        std::env::var(ADVERTISE_ALL_INTERFACES_ENV).as_deref(),
        Ok("1") | Ok("true") | Ok("yes")
    );
    let allowlist: Option<Vec<String>> = std::env::var(ADVERTISE_INTERFACES_ENV).ok().map(|s| {
        s.split(',')
            .map(|t| t.trim().to_string())
            .filter(|t| !t.is_empty())
            .collect()
    });

    let ifaces = match enumerated {
        Ok(v) => v,
        Err(e) => {
            tracing::warn!(
                error = %e,
                "if_addrs::get_if_addrs() failed; advertise filter disabled — caller should fall back to unspecified bind"
            );
            return InterfaceSelection {
                bind_addrs,
                kept_interfaces,
                dropped: vec![("(enumeration failed)".to_string(), "if_addrs error")],
            };
        }
    };

    for iface in ifaces {
        let name = iface.name.clone();
        let ip = iface.ip();
        // Pull the CIDR prefix from `if_addrs` so it can be threaded into
        // `BindOpts::set_prefix_len` downstream. v4 and v6 carry it on
        // different variants.
        let prefix_len: u8 = match &iface.addr {
            if_addrs::IfAddr::V4(v4) => v4.prefixlen,
            if_addrs::IfAddr::V6(v6) => v6.prefixlen,
        };

        // IPv6 gate first — if the caller doesn't want v6, drop without
        // examining further.
        if ip.is_ipv6() && !want_v6 {
            dropped.push((name, "ipv6 not requested"));
            continue;
        }

        // Loopback is always dropped — even under the bypass — because a
        // remote peer can never dial our 127.0.0.1.
        if iface.is_loopback() {
            dropped.push((name, "loopback"));
            continue;
        }

        // The explicit allowlist short-circuits both the bypass switch and
        // the default heuristics — operator-pinned beats defaults.
        if let Some(allow) = allowlist.as_ref() {
            if !allow.contains(&name) {
                dropped.push((name, "not in PEAT_ADVERTISE_INTERFACES allowlist"));
                continue;
            }
            bind_addrs.push(BoundAddr {
                addr: SocketAddr::new(ip, requested_port),
                prefix_len,
            });
            kept_interfaces.push(name);
            continue;
        }

        // Bypass: skip default heuristics, keep everything else (loopback
        // already filtered above).
        if bypass {
            bind_addrs.push(BoundAddr {
                addr: SocketAddr::new(ip, requested_port),
                prefix_len,
            });
            kept_interfaces.push(name);
            continue;
        }

        if let Some(reason) = default_filter_reason(&name, &ip) {
            dropped.push((name, reason));
            continue;
        }

        bind_addrs.push(BoundAddr {
            addr: SocketAddr::new(ip, requested_port),
            prefix_len,
        });
        kept_interfaces.push(name);
    }

    InterfaceSelection {
        bind_addrs,
        kept_interfaces,
        dropped,
    }
}

/// Pure-function default filter. Returns `Some(reason)` if the
/// (interface name, IP) pair should be dropped, `None` to keep.
///
/// Does **not** check loopback — `select_advertise_interfaces` handles
/// loopback unconditionally before reaching here, so this function focuses
/// on the heuristics that the bypass switch should turn off.
///
/// Reasons are `&'static str` so they show up cleanly in trace output
/// without allocation.
pub fn default_filter_reason(name: &str, ip: &IpAddr) -> Option<&'static str> {
    // Link-local: 169.254/16 for v4, fe80::/10 for v6. Both are
    // scope-limited and not routable off-link.
    match ip {
        IpAddr::V4(v4) if v4.is_link_local() => return Some("ipv4 link-local"),
        IpAddr::V6(v6) if (v6.segments()[0] & 0xffc0) == 0xfe80 => {
            return Some("ipv6 link-local");
        }
        _ => {}
    }

    // Tailscale CGNAT range (RFC 6598). Intra-tailnet only — not
    // reachable from a regular LAN peer. `100.64.0.0/10` means second-
    // octet bits 6-7 == 0b01, which is what the `& 0xc0 == 0x40` test
    // checks: octets 64..=127 pass, 128.. fail.
    if let IpAddr::V4(v4) = ip {
        let oct = v4.octets();
        if oct[0] == 100 && (oct[1] & 0xc0) == 0x40 {
            return Some("tailscale CGNAT (100.64.0.0/10)");
        }
    }

    // Tailscale interface name as belt-and-braces (in case the operator
    // pointed it at a non-CGNAT pool).
    if name.starts_with("tailscale") {
        return Some("tailscale interface");
    }

    // Docker bridges — match by name, not by IP range, so legitimate
    // 172.16.0.0/12 LANs aren't caught. `docker0` is the default bridge;
    // user-defined networks produce `br-<12-hex>` (the network ID's
    // first 12 chars). Restricting the suffix to exactly 12 hex chars
    // avoids false positives on legitimate Linux bridge interfaces
    // operators name `br-corp`, `br-lan`, `br-vlan10`, etc.
    if name == "docker0" || is_docker_user_bridge_name(name) {
        return Some("docker bridge");
    }

    // Podman / containerd default bridges follow similar conventions.
    if name.starts_with("podman") || name == "cni0" || name.starts_with("cni-") {
        return Some("container runtime bridge");
    }

    None
}

/// Returns `true` if `name` matches docker's `br-<12-hex>` user-defined-network
/// convention (e.g. `br-5ca0dfe21ee8`). Does **not** match `br-corp`, `br-lan`,
/// `br-vlan10`, or any other shape — so operators with legitimate Linux
/// bridges named like that are unaffected by the default filter.
fn is_docker_user_bridge_name(name: &str) -> bool {
    let Some(suffix) = name.strip_prefix("br-") else {
        return false;
    };
    suffix.len() == 12 && suffix.bytes().all(|b| b.is_ascii_hexdigit())
}

/// Emit a single INFO line summarizing a selection — used by the iroh
/// endpoint builders so operators can see, at startup, which interfaces
/// were bound and which were filtered.
///
/// Skipping is intentional: empty selections (e.g. when the caller passed
/// a non-unspecified bind addr and didn't invoke the filter) get no log
/// line, keeping default UX quiet.
pub fn log_selection(sel: &InterfaceSelection) {
    if sel.bind_addrs.is_empty() && sel.dropped.is_empty() {
        return;
    }
    let kept = sel
        .kept_interfaces
        .iter()
        .zip(sel.bind_addrs.iter())
        .map(|(name, b)| format!("{name}={}/{}", b.addr, b.prefix_len))
        .collect::<Vec<_>>()
        .join(", ");
    let drops = sel
        .dropped
        .iter()
        .map(|(name, reason)| format!("{name}({reason})"))
        .collect::<Vec<_>>()
        .join(", ");
    tracing::info!(
        kept_count = sel.bind_addrs.len(),
        dropped_count = sel.dropped.len(),
        kept = %kept,
        dropped = %drops,
        "interface filter applied (peat#890); set PEAT_ADVERTISE_ALL_INTERFACES=1 to bypass"
    );
}

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

    #[test]
    fn keeps_normal_lan_v4() {
        assert_eq!(
            default_filter_reason("eno1", &IpAddr::V4(Ipv4Addr::new(192, 168, 228, 236))),
            None
        );
    }

    #[test]
    fn keeps_normal_wifi_v4() {
        assert_eq!(
            default_filter_reason("wlp11s0", &IpAddr::V4(Ipv4Addr::new(192, 168, 1, 81))),
            None
        );
    }

    #[test]
    fn filters_link_local_v4() {
        assert_eq!(
            default_filter_reason("eth0", &IpAddr::V4(Ipv4Addr::new(169, 254, 5, 7))),
            Some("ipv4 link-local")
        );
    }

    #[test]
    fn filters_tailscale_cgnat_by_ip() {
        // 100.95.15.51 is in 100.64.0.0/10 — tailscale's pool.
        assert_eq!(
            default_filter_reason("tailscale0", &IpAddr::V4(Ipv4Addr::new(100, 95, 15, 51))),
            Some("tailscale CGNAT (100.64.0.0/10)")
        );
    }

    #[test]
    fn filters_tailscale_by_name_when_ip_is_outside_cgnat() {
        // Outside 100.64/10 but on a tailscale-named iface — name rule
        // catches it.
        assert_eq!(
            default_filter_reason("tailscale0", &IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5))),
            Some("tailscale interface")
        );
    }

    #[test]
    fn filters_docker_default_bridge() {
        assert_eq!(
            default_filter_reason("docker0", &IpAddr::V4(Ipv4Addr::new(172, 17, 0, 1))),
            Some("docker bridge")
        );
    }

    #[test]
    fn filters_docker_user_bridge_by_name() {
        assert_eq!(
            default_filter_reason("br-5ca0dfe21ee8", &IpAddr::V4(Ipv4Addr::new(172, 20, 0, 1))),
            Some("docker bridge")
        );
    }

    #[test]
    fn filters_podman_bridge() {
        assert_eq!(
            default_filter_reason("podman0", &IpAddr::V4(Ipv4Addr::new(10, 88, 0, 1))),
            Some("container runtime bridge")
        );
    }

    #[test]
    fn filters_cni_bridge() {
        assert_eq!(
            default_filter_reason("cni0", &IpAddr::V4(Ipv4Addr::new(10, 244, 0, 1))),
            Some("container runtime bridge")
        );
    }

    /// A legitimate LAN that happens to sit in `172.16.0.0/12` (RFC 1918,
    /// permitted by the rule) on a non-docker iface name must pass the
    /// filter — name-based matching protects this case (peat#890 ack).
    #[test]
    fn keeps_legitimate_172_lan() {
        assert_eq!(
            default_filter_reason("eth0", &IpAddr::V4(Ipv4Addr::new(172, 18, 5, 7))),
            None
        );
    }

    #[test]
    fn filters_v6_link_local() {
        let v6 = "fe80::1234".parse::<IpAddr>().unwrap();
        assert_eq!(default_filter_reason("eth0", &v6), Some("ipv6 link-local"));
    }

    /// Legitimate Linux bridges named `br-corp`, `br-lan`, `br-vlan10`
    /// must pass the filter. Only docker's `br-<12-hex>` convention is
    /// dropped (peat-mesh#152 QA — preserve legitimate corporate
    /// bridges).
    #[test]
    fn keeps_legitimate_br_named_bridges() {
        assert_eq!(
            default_filter_reason("br-corp", &IpAddr::V4(Ipv4Addr::new(192, 168, 50, 1))),
            None
        );
        assert_eq!(
            default_filter_reason("br-lan", &IpAddr::V4(Ipv4Addr::new(192, 168, 51, 1))),
            None
        );
        assert_eq!(
            default_filter_reason("br-vlan10", &IpAddr::V4(Ipv4Addr::new(10, 10, 0, 1))),
            None
        );
    }

    /// The `br-<12-hex>` matcher is exactly 12 hex chars; anything
    /// longer / shorter / non-hex falls through to the keep path
    /// (peat-mesh#152 QA — convention precision).
    #[test]
    fn docker_user_bridge_matcher_is_precise() {
        // Docker's actual format: 12 lowercase hex chars.
        assert!(is_docker_user_bridge_name("br-5ca0dfe21ee8"));
        assert!(is_docker_user_bridge_name("br-9247d62ee31c"));
        // Uppercase hex also accepted (lenient on docker's convention).
        assert!(is_docker_user_bridge_name("br-ABCDEF012345"));

        // Anything that doesn't fit the 12-hex shape is *not* docker.
        assert!(!is_docker_user_bridge_name("br-corp"));
        assert!(!is_docker_user_bridge_name("br-lan"));
        assert!(!is_docker_user_bridge_name("br-vlan10"));
        // 11 hex — too short.
        assert!(!is_docker_user_bridge_name("br-5ca0dfe21ee"));
        // 13 hex — too long.
        assert!(!is_docker_user_bridge_name("br-5ca0dfe21ee8f"));
        // 12 chars, but one is not hex.
        assert!(!is_docker_user_bridge_name("br-5ca0dfe21eg8"));
        // No `br-` prefix at all.
        assert!(!is_docker_user_bridge_name("eth0"));
    }

    // -----------------------------------------------------------------
    // `select_advertise_interfaces_with` surface tests (peat-mesh#152 QA
    // SUGGESTION cleanup).
    //
    // These exercise the public API — env-var overrides, error-path
    // handling, return-shape contracts — using *synthetic* interface
    // lists rather than the host's real `if_addrs::get_if_addrs()`
    // result. That makes the assertions deterministic and
    // host-independent, and lets us cover the `Err(io::Error)` branch
    // without dependency-injection scaffolding. Each test sets/resets
    // process-wide env vars, so they're `#[serial]` to avoid
    // concurrent-mutation collisions.
    // -----------------------------------------------------------------

    use serial_test::serial;
    use std::net::Ipv6Addr;

    fn clear_filter_env() {
        // SAFETY: serialized via `#[serial]`; no other test in this
        // crate mutates these vars concurrently.
        unsafe {
            std::env::remove_var(ADVERTISE_ALL_INTERFACES_ENV);
            std::env::remove_var(ADVERTISE_INTERFACES_ENV);
        }
    }

    /// Construct a synthetic `if_addrs::Interface` for a v4 (name, ip, prefix).
    /// `is_loopback()` is derived from `ip.is_loopback()` (matches the
    /// real `if_addrs::IfAddr::is_loopback` semantics).
    fn iface_v4(name: &str, ip: Ipv4Addr, prefix: u8) -> if_addrs::Interface {
        if_addrs::Interface {
            name: name.to_string(),
            addr: if_addrs::IfAddr::V4(if_addrs::Ifv4Addr {
                ip,
                netmask: Ipv4Addr::new(255, 255, 255, 0),
                prefixlen: prefix,
                broadcast: None,
            }),
            index: None,
            oper_status: if_addrs::IfOperStatus::Up,
            is_p2p: false,
        }
    }

    fn iface_v6(name: &str, ip: Ipv6Addr, prefix: u8) -> if_addrs::Interface {
        if_addrs::Interface {
            name: name.to_string(),
            addr: if_addrs::IfAddr::V6(if_addrs::Ifv6Addr {
                ip,
                netmask: Ipv6Addr::new(0xffff, 0xffff, 0xffff, 0xffff, 0, 0, 0, 0),
                prefixlen: prefix,
                broadcast: None,
            }),
            index: None,
            oper_status: if_addrs::IfOperStatus::Up,
            is_p2p: false,
        }
    }

    /// Synthetic fixture matching the laptop's Scenario 4 host shape:
    /// loopback, two legitimate LAN/Wi-Fi NICs, a tailscale CGNAT
    /// interface, three docker bridges (one default + two user-defined
    /// `br-<12-hex>`), and a podman bridge. Mirrors the real host the
    /// rpi-ci/rpi-ci2/laptop validation ran on.
    fn host_with_docker_tailscale() -> Vec<if_addrs::Interface> {
        vec![
            iface_v4("lo", Ipv4Addr::new(127, 0, 0, 1), 8),
            iface_v4("eno1", Ipv4Addr::new(192, 168, 228, 236), 24),
            iface_v4("wlp11s0", Ipv4Addr::new(192, 168, 1, 81), 24),
            iface_v4("tailscale0", Ipv4Addr::new(100, 95, 15, 51), 32),
            iface_v4("docker0", Ipv4Addr::new(172, 17, 0, 1), 16),
            iface_v4("br-5ca0dfe21ee8", Ipv4Addr::new(172, 18, 0, 1), 16),
            iface_v4("br-9247d62ee31c", Ipv4Addr::new(172, 19, 0, 1), 16),
            iface_v4("podman0", Ipv4Addr::new(10, 88, 0, 1), 16),
            iface_v6("eno1", "fe80::1".parse().unwrap(), 64),
        ]
    }

    /// On the Scenario-4-shaped host, the default filter keeps
    /// exactly `eno1` + `wlp11s0` and drops everything else with the
    /// expected reasons.
    #[test]
    #[serial]
    fn select_default_filters_known_bad_interfaces() {
        clear_filter_env();
        let host = host_with_docker_tailscale();
        let sel = select_advertise_interfaces_with(Ok(host.clone()), 39001, false);

        // Expected values derived from the fixture (single source of
        // truth) — a future fixture tweak propagates without forcing
        // edits across every assert in lock-step (peat-mesh#154 QA).
        let eno1 = iface_by_name(&host, "eno1");
        let wlp = iface_by_name(&host, "wlp11s0");
        let eno1_prefix = match &eno1.addr {
            if_addrs::IfAddr::V4(v4) => v4.prefixlen,
            if_addrs::IfAddr::V6(v6) => v6.prefixlen,
        };
        let wlp_prefix = match &wlp.addr {
            if_addrs::IfAddr::V4(v4) => v4.prefixlen,
            if_addrs::IfAddr::V6(v6) => v6.prefixlen,
        };

        assert_eq!(
            sel.kept_interfaces,
            vec!["eno1".to_string(), "wlp11s0".to_string()],
            "kept={:?} dropped={:?}",
            sel.kept_interfaces,
            sel.dropped
        );
        assert_eq!(sel.bind_addrs.len(), 2);
        assert_eq!(sel.bind_addrs[0].addr, SocketAddr::new(eno1.ip(), 39001));
        assert_eq!(sel.bind_addrs[0].prefix_len, eno1_prefix);
        assert_eq!(sel.bind_addrs[1].addr, SocketAddr::new(wlp.ip(), 39001));
        assert_eq!(sel.bind_addrs[1].prefix_len, wlp_prefix);

        // Drop reasons are the load-bearing assertions for the
        // *heuristics*, so they stay verbatim — the fixture's iface
        // names are deliberately the docker / tailscale / podman names
        // we expect the heuristic to match, and pinning the reason
        // strings is the regression guard.
        let drops: std::collections::HashMap<&str, &str> =
            sel.dropped.iter().map(|(n, r)| (n.as_str(), *r)).collect();
        assert_eq!(drops.get("lo"), Some(&"loopback"));
        assert_eq!(
            drops.get("tailscale0"),
            Some(&"tailscale CGNAT (100.64.0.0/10)")
        );
        assert_eq!(drops.get("docker0"), Some(&"docker bridge"));
        assert_eq!(drops.get("br-5ca0dfe21ee8"), Some(&"docker bridge"));
        assert_eq!(drops.get("br-9247d62ee31c"), Some(&"docker bridge"));
        assert_eq!(drops.get("podman0"), Some(&"container runtime bridge"));
    }

    /// Resolve the fixture entry for an interface name. Lets exact-shape
    /// tests derive expected values from the fixture (single source of
    /// truth) rather than re-encoding them as string constants.
    fn iface_by_name<'a>(host: &'a [if_addrs::Interface], name: &str) -> &'a if_addrs::Interface {
        host.iter()
            .find(|i| i.name == name)
            .unwrap_or_else(|| panic!("fixture missing interface {name}"))
    }

    /// `PEAT_ADVERTISE_ALL_INTERFACES=1` bypasses default heuristics:
    /// docker bridges, tailscale, and podman come back into the kept
    /// set. Loopback stays dropped (unconditional, even under bypass).
    #[test]
    #[serial]
    fn select_bypass_keeps_more_interfaces() {
        clear_filter_env();
        let host = host_with_docker_tailscale();

        let baseline = select_advertise_interfaces_with(Ok(host.clone()), 39001, false);
        // SAFETY: serialized via #[serial].
        unsafe {
            std::env::set_var(ADVERTISE_ALL_INTERFACES_ENV, "1");
        }
        let bypassed = select_advertise_interfaces_with(Ok(host.clone()), 39001, false);
        clear_filter_env();

        // Baseline: 2 kept (eno1, wlp11s0).
        // Bypass: 7 kept (eno1, wlp11s0, tailscale0, docker0, br-..., br-..., podman0).
        // (Loopback dropped in both; ipv6 dropped in both because want_v6=false.)
        assert_eq!(baseline.bind_addrs.len(), 2, "baseline kept count");
        assert_eq!(
            bypassed.bind_addrs.len(),
            7,
            "bypass should keep eno1, wlp11s0, tailscale0, docker0, 2×br-*, podman0; got kept={:?}",
            bypassed.kept_interfaces
        );
        assert!(
            bypassed.dropped.iter().any(|(_, r)| *r == "loopback"),
            "loopback must remain dropped under bypass; dropped={:?}",
            bypassed.dropped
        );
        // Bypass keeps strictly more than baseline on this fixture.
        assert!(bypassed.bind_addrs.len() > baseline.bind_addrs.len());
    }

    /// Bypass parse contract: only `1`/`true`/`yes` activate the
    /// switch. Typos like `on` or `enabled` are silently rejected (the
    /// safe direction — better to keep filtering than to silently
    /// disable it). Deterministic now that we use a synthetic host.
    #[test]
    #[serial]
    fn select_bypass_rejects_garbage_values() {
        clear_filter_env();
        let host = host_with_docker_tailscale();

        for v in ["0", "false", "on", "enabled", "garbage", "", "yes_please"] {
            // SAFETY: serialized via #[serial].
            unsafe {
                std::env::set_var(ADVERTISE_ALL_INTERFACES_ENV, v);
            }
            let sel = select_advertise_interfaces_with(Ok(host.clone()), 39001, false);
            assert_eq!(
                sel.bind_addrs.len(),
                2,
                "value {v:?} should NOT activate bypass — expected baseline of 2 kept (eno1, wlp11s0), got {} ({:?})",
                sel.bind_addrs.len(),
                sel.kept_interfaces
            );
        }
        clear_filter_env();
    }

    /// `PEAT_ADVERTISE_INTERFACES=eno1` keeps only the named interface
    /// and reports the rest as `"not in PEAT_ADVERTISE_INTERFACES allowlist"`.
    /// Even tailscale + docker — usually filtered by heuristic — are
    /// reported under the allowlist rule, since the allowlist
    /// short-circuits the heuristic check.
    #[test]
    #[serial]
    fn select_allowlist_pins_explicit_interface() {
        clear_filter_env();
        // SAFETY: serialized via #[serial].
        unsafe {
            std::env::set_var(ADVERTISE_INTERFACES_ENV, "eno1");
        }
        let sel = select_advertise_interfaces_with(Ok(host_with_docker_tailscale()), 39001, false);
        clear_filter_env();

        assert_eq!(sel.kept_interfaces, vec!["eno1".to_string()]);
        assert_eq!(sel.bind_addrs.len(), 1);
        assert_eq!(
            sel.bind_addrs[0].addr,
            "192.168.228.236:39001".parse().unwrap()
        );

        // Every non-loopback / non-keep interface should be reported
        // under the allowlist drop reason. Loopback still reports
        // "loopback" — operator-pinned beats defaults, except loopback
        // is unconditional.
        let allowlist_drops = sel
            .dropped
            .iter()
            .filter(|(_, r)| *r == "not in PEAT_ADVERTISE_INTERFACES allowlist")
            .count();
        assert_eq!(
            allowlist_drops, 6,
            "expected 6 non-allowlisted v4 interfaces dropped via allowlist rule \
             (wlp11s0, tailscale0, docker0, br-*, br-*, podman0); dropped={:?}",
            sel.dropped
        );
        // Loopback drop reason is "loopback", not the allowlist reason.
        let lo_drop = sel
            .dropped
            .iter()
            .find(|(n, _)| n == "lo")
            .expect("lo should appear in dropped");
        assert_eq!(lo_drop.1, "loopback");
    }

    /// `PEAT_ADVERTISE_INTERFACES=__nope__` (no real interface matches)
    /// yields zero kept binds. Deterministic now that we use a
    /// synthetic host — covers the "operator pinned a typo" failure
    /// mode without depending on the host's interface list.
    #[test]
    #[serial]
    fn select_allowlist_with_no_matches_yields_empty_binds() {
        clear_filter_env();
        // SAFETY: serialized via #[serial].
        unsafe {
            std::env::set_var(ADVERTISE_INTERFACES_ENV, "__definitely-not-a-real-iface__");
        }
        let sel = select_advertise_interfaces_with(Ok(host_with_docker_tailscale()), 39001, false);
        clear_filter_env();

        assert!(sel.bind_addrs.is_empty(), "kept={:?}", sel.kept_interfaces);
        // All 7 non-loopback v4 interfaces should fall under the
        // allowlist drop reason.
        let allowlist_drops = sel
            .dropped
            .iter()
            .filter(|(_, r)| *r == "not in PEAT_ADVERTISE_INTERFACES allowlist")
            .count();
        assert_eq!(allowlist_drops, 7);
    }

    /// Empty / whitespace-only allowlist entries are tolerated — a
    /// stray comma like `"eno1,,eth0"` must not bind the empty
    /// interface name `""` to anything. Equivalent to "all-empty
    /// allowlist", which is the same as "no matches".
    #[test]
    #[serial]
    fn select_allowlist_trims_empty_entries() {
        clear_filter_env();
        // SAFETY: serialized via #[serial].
        unsafe {
            std::env::set_var(ADVERTISE_INTERFACES_ENV, ", ,  ,");
        }
        let sel = select_advertise_interfaces_with(Ok(host_with_docker_tailscale()), 39001, false);
        clear_filter_env();
        assert!(sel.bind_addrs.is_empty(), "kept={:?}", sel.kept_interfaces);
    }

    /// `if_addrs::get_if_addrs()` failure produces an empty
    /// `bind_addrs` with one synthetic drop record so the caller can
    /// fall back to the unspecified bind cleanly (peat-mesh#152 QA
    /// SUGGESTION — error-path coverage).
    #[test]
    #[serial]
    fn select_handles_get_if_addrs_error() {
        clear_filter_env();
        let err: std::io::Result<Vec<if_addrs::Interface>> =
            Err(std::io::Error::other("simulated enumeration failure"));
        let sel = select_advertise_interfaces_with(err, 39001, false);

        assert!(sel.bind_addrs.is_empty());
        assert!(sel.kept_interfaces.is_empty());
        assert_eq!(
            sel.dropped,
            vec![("(enumeration failed)".to_string(), "if_addrs error")]
        );
    }

    /// `want_v6=false` drops IPv6 interfaces with the expected reason;
    /// `want_v6=true` keeps them (subject to the same heuristics).
    /// Pins the v6 gate explicitly so a refactor can't silently swap
    /// the test on a host that happens to have no v6 interfaces.
    #[test]
    #[serial]
    fn select_v6_gate_respects_caller_choice() {
        clear_filter_env();
        let v6_only = vec![iface_v6("eth0", "2001:db8::1".parse().unwrap(), 64)];

        let no_v6 = select_advertise_interfaces_with(Ok(v6_only.clone()), 39001, false);
        assert!(no_v6.bind_addrs.is_empty());
        assert_eq!(
            no_v6.dropped,
            vec![("eth0".to_string(), "ipv6 not requested")]
        );

        let with_v6 = select_advertise_interfaces_with(Ok(v6_only), 39001, true);
        assert_eq!(with_v6.kept_interfaces, vec!["eth0".to_string()]);
        assert_eq!(with_v6.bind_addrs.len(), 1);
        assert_eq!(with_v6.bind_addrs[0].prefix_len, 64);
    }

    /// The convenience wrapper [`select_advertise_interfaces`] (the
    /// production entry point) is just a thin shim over
    /// [`select_advertise_interfaces_with`]. The only host-dependent
    /// behavior worth pinning at the wrapper level is "calling the
    /// real `get_if_addrs()` succeeds on a Linux test host" — anything
    /// stronger would re-introduce the host-dependence the synthetic
    /// fixture tests above are designed to avoid.
    #[test]
    #[serial]
    fn select_wrapper_calls_real_if_addrs_without_panicking() {
        clear_filter_env();
        let sel = select_advertise_interfaces(39001, false);
        // At a minimum the host has `lo`, which must be reported as
        // dropped under "loopback". This is the only host-shape
        // invariant we can rely on for any Linux/macOS/Windows runner.
        assert!(
            sel.dropped.iter().any(|(_, r)| *r == "loopback"),
            "wrapper should at least drop loopback on any normal host; dropped={:?}",
            sel.dropped
        );
    }
}
