//! Minimal peer descriptor used by [`super::iroh_transport::IrohTransport::connect_peer`].
//!
//! This type lived in `peat-protocol::network::peer_config::PeerInfo` until
//! ADR-062 moved the transport into peat-mesh. The full peer-config loader
//! (TOML parsing, validation, etc.) remains in peat-protocol and re-exports
//! this struct so existing call sites keep working.

#[cfg(feature = "automerge-backend")]
use anyhow::{Context, Result};
#[cfg(feature = "automerge-backend")]
use iroh::EndpointId;
#[cfg(feature = "automerge-backend")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "automerge-backend")]
use std::net::SocketAddr;

/// A statically-configured peer that `IrohTransport::connect_peer` can dial.
#[cfg(feature = "automerge-backend")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerInfo {
    /// Human-readable peer name
    pub name: String,
    /// Iroh PublicKey (EndpointId) in hex format
    pub node_id: String,
    /// Direct addresses to try connecting to
    pub addresses: Vec<String>,
    /// Optional relay URL
    pub relay_url: Option<String>,
}

#[cfg(feature = "automerge-backend")]
impl PeerInfo {
    /// Parse `node_id` (hex string) as an iroh [`EndpointId`].
    pub fn endpoint_id(&self) -> Result<EndpointId> {
        let bytes = hex::decode(&self.node_id).context("Failed to decode node_id hex")?;
        if bytes.len() != 32 {
            anyhow::bail!(
                "Invalid node_id length: expected 32 bytes, got {}",
                bytes.len()
            );
        }
        let mut array = [0u8; 32];
        array.copy_from_slice(&bytes);
        EndpointId::from_bytes(&array).context("Failed to construct EndpointId from bytes")
    }

    /// Parse `addresses` as a list of [`SocketAddr`]s.
    pub fn socket_addrs(&self) -> Result<Vec<SocketAddr>> {
        self.addresses
            .iter()
            .map(|addr| {
                addr.parse()
                    .with_context(|| format!("Invalid address: {}", addr))
            })
            .collect()
    }
}

/// Whether `addr` is a routable LAN/WAN address worth dialing.
///
/// mDNS advertisements routinely include addresses that are useless — or
/// actively harmful — to dial from a *different* host: IPv4/IPv6 loopback
/// (`127.0.0.0/8`, `::1`), IPv4 link-local (`169.254.0.0/16`), and **scopeless
/// IPv6 link-local (`fe80::/10`)**. The link-local ones are the dangerous case
/// on Android: a `fe80::…` with no scope id cannot be dialed, and a peer's
/// loopback resolves to *our own* machine, so handing these to
/// `connect_peer`/iroh wastes connection attempts on paths that can never reach
/// the peer and can mask the one good LAN path (peat-atak-plugin field finding:
/// a Pi advertising `[fe80::…, 127.0.0.1, 192.168.x.y]` was never reachable from
/// the Android device until the junk paths were dropped). Keep only addresses
/// that can actually route to another host on the segment.
#[cfg(feature = "automerge-backend")]
fn is_routable_addr(addr: &SocketAddr) -> bool {
    use std::net::IpAddr;
    match addr.ip() {
        IpAddr::V4(v4) => {
            !v4.is_loopback() && !v4.is_link_local() && !v4.is_unspecified() && !v4.is_broadcast()
        }
        IpAddr::V6(v6) => {
            // fe80::/10 link-local — `is_unicast_link_local` is unstable, so
            // test the prefix directly.
            let link_local = (v6.segments()[0] & 0xffc0) == 0xfe80;
            !v6.is_loopback() && !v6.is_unspecified() && !link_local
        }
    }
}

/// Bridge a peer discovered via the peat-controlled mDNS browse
/// ([`crate::discovery::PeerInfo`]) into the dialable [`PeerInfo`] that
/// [`super::iroh_transport::IrohTransport::connect_peer`] accepts.
///
/// On Android, iroh's own swarm-discovery cannot enumerate interfaces from the
/// wildcard bind, so the peat-controlled `_peat._udp` browse (exposed via
/// `IrohTransport::peat_mdns_events`) is the only working discovery path. The
/// browse's `PeerInfo` carries the peer's `node_id` (the hex `EndpointId` on the
/// wire) and concrete `SocketAddr`s, which is exactly what `connect_peer` needs
/// to dial by advertised address instead of relying on the (empty-on-Android)
/// iroh address book. A peer that advertised a non-hex `node_id` (e.g. a
/// formation id) simply fails the later [`PeerInfo::endpoint_id`] parse and is
/// skipped by the caller — no panic.
///
/// Non-routable advertised addresses (loopback / link-local) are dropped here
/// via [`is_routable_addr`] so the dialer only attempts paths that can reach the
/// peer from another host. If a peer advertised *only* non-routable addresses,
/// the resulting `addresses` is empty and the caller skips it (correct — it is
/// not reachable).
#[cfg(feature = "automerge-backend")]
impl From<crate::discovery::PeerInfo> for PeerInfo {
    fn from(discovered: crate::discovery::PeerInfo) -> Self {
        PeerInfo {
            name: discovered.node_id.clone(),
            node_id: discovered.node_id,
            addresses: discovered
                .addresses
                .iter()
                .filter(|addr| is_routable_addr(addr))
                .map(|addr| addr.to_string())
                .collect(),
            relay_url: discovered.relay_url,
        }
    }
}

#[cfg(all(test, feature = "automerge-backend"))]
mod tests {
    use super::*;

    fn peer(node_id: &str, addrs: &[&str]) -> PeerInfo {
        PeerInfo {
            name: "test".to_string(),
            node_id: node_id.to_string(),
            addresses: addrs.iter().map(|s| s.to_string()).collect(),
            relay_url: None,
        }
    }

    /// Non-hex `node_id` is the most common static-peer-config typo. The error
    /// must name the hex-decode failure so operators can find the bad TOML line.
    #[test]
    fn endpoint_id_rejects_non_hex_node_id() {
        let p = peer("not hex at all!!", &[]);
        let err = p.endpoint_id().expect_err("non-hex node_id must error");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("hex"),
            "error must mention hex decoding: got `{msg}`"
        );
    }

    /// Hex that decodes but is the wrong length is the second-most-common
    /// static-peer-config typo (truncated paste). The bail message must name
    /// the length mismatch.
    #[test]
    fn endpoint_id_rejects_wrong_length_node_id() {
        let p = peer("deadbeef", &[]); // 4 bytes, not 32
        let err = p.endpoint_id().expect_err("4-byte node_id must error");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("32 bytes"),
            "error must name the expected length: got `{msg}`"
        );
    }

    /// Malformed `addresses` entry must surface as a parse error naming the
    /// offending address — `socket_addrs()` is the choke point for static
    /// peer-config TOML loading.
    #[test]
    fn socket_addrs_rejects_invalid_address() {
        let p = peer("00".repeat(32).as_str(), &["not-a-socket-addr"]);
        let err = p.socket_addrs().expect_err("malformed address must error");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("not-a-socket-addr"),
            "error must name the bad address: got `{msg}`"
        );
    }

    /// The mDNS-browse → dialable bridge must preserve the hex `node_id` (so
    /// `endpoint_id()` still parses) and stringify each discovered `SocketAddr`
    /// into `connect_peer`'s `addresses` — otherwise discovered Android peers
    /// can never be dialed.
    #[test]
    fn from_discovered_peer_preserves_id_and_stringifies_addrs() {
        let discovered = crate::discovery::PeerInfo::new(
            "00".repeat(32),
            vec!["192.168.1.5:51080".parse().unwrap()],
        );
        let dialable: PeerInfo = discovered.into();
        assert_eq!(dialable.node_id, "00".repeat(32));
        assert_eq!(dialable.name, "00".repeat(32));
        assert_eq!(dialable.addresses, vec!["192.168.1.5:51080".to_string()]);
        assert_eq!(dialable.relay_url, None);
    }

    /// Loopback and link-local advertised addresses must be dropped: dialing
    /// them from another host wastes attempts (loopback → our own machine) or is
    /// impossible (scopeless `fe80::`), and they can mask the one routable LAN
    /// path. Mirrors the field case where a Pi advertised
    /// `[fe80::…, 127.0.0.1, 192.168.x.y]` and only the LAN addr is usable.
    #[test]
    fn from_discovered_peer_drops_loopback_and_link_local() {
        let discovered = crate::discovery::PeerInfo::new(
            "11".repeat(32),
            vec![
                "127.0.0.1:51071".parse().unwrap(), // IPv4 loopback — drop
                "[fe80::8aa2:9eff:fec2:89db]:51071".parse().unwrap(), // IPv6 link-local — drop
                "[::1]:51071".parse().unwrap(),     // IPv6 loopback — drop
                "169.254.10.5:51071".parse().unwrap(), // IPv4 link-local — drop
                "192.168.1.65:51071".parse().unwrap(), // routable LAN — keep
            ],
        );
        let dialable: PeerInfo = discovered.into();
        assert_eq!(
            dialable.addresses,
            vec!["192.168.1.65:51071".to_string()],
            "only the routable LAN address should survive the filter"
        );
    }

    /// A peer that advertised only non-routable addresses yields an empty
    /// address list — the caller then skips it (it genuinely cannot be reached).
    #[test]
    fn from_discovered_peer_with_only_non_routable_addrs_is_empty() {
        let discovered = crate::discovery::PeerInfo::new(
            "22".repeat(32),
            vec![
                "127.0.0.1:51071".parse().unwrap(),
                "[fe80::1]:51071".parse().unwrap(),
            ],
        );
        let dialable: PeerInfo = discovered.into();
        assert!(
            dialable.addresses.is_empty(),
            "all non-routable addresses must be filtered out"
        );
    }
}
