use std::net::IpAddr;

use tokio::net::lookup_host;

pub(super) async fn validate_navigation(
    raw: &str,
    allow_private_hosts: bool,
    allowed_domains: &[String],
) -> Result<(), String> {
    let url = reqwest::Url::parse(raw).map_err(|error| format!("invalid URL: {error}"))?;
    if !matches!(url.scheme(), "http" | "https") {
        return Err("only http:// and https:// URLs are allowed".to_string());
    }
    if !url.username().is_empty() || url.password().is_some() {
        return Err("URL userinfo is not allowed".to_string());
    }
    let host = url
        .host_str()
        .ok_or_else(|| "URL must include a host".to_string())?
        .trim_end_matches('.')
        .to_ascii_lowercase();

    if !allowed_domains.is_empty() && !host_allowed(&host, allowed_domains) {
        return Err(format!("host '{host}' is not in browser.allowed_domains"));
    }

    if allow_private_hosts {
        return Ok(());
    }
    if is_private_host(&host) {
        return Err(format!("blocked local/private host: {host}"));
    }
    if host.parse::<IpAddr>().is_err() {
        let port = url.port_or_known_default().unwrap_or(80);
        let addresses = lookup_host((host.as_str(), port))
            .await
            .map_err(|error| format!("failed to resolve host '{host}': {error}"))?;
        for address in addresses {
            if is_private_ip(address.ip()) {
                return Err(format!(
                    "blocked host '{host}' because DNS resolved to local/private IP {}",
                    address.ip()
                ));
            }
        }
    }
    Ok(())
}

fn host_allowed(host: &str, patterns: &[String]) -> bool {
    patterns.iter().any(|pattern| {
        let pattern = pattern.trim().trim_end_matches('.').to_ascii_lowercase();
        if let Some(suffix) = pattern.strip_prefix("*.") {
            host == suffix || host.ends_with(&format!(".{suffix}"))
        } else {
            host == pattern
        }
    })
}

fn is_private_host(host: &str) -> bool {
    host == "localhost"
        || host.ends_with(".localhost")
        || host.ends_with(".local")
        || host.parse::<IpAddr>().is_ok_and(is_private_ip)
}

fn is_private_ip(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(ip) => {
            ip.is_loopback()
                || ip.is_private()
                || ip.is_link_local()
                || ip.is_broadcast()
                || ip.is_unspecified()
                || ip.octets()[0] == 0
                || ip.octets()[0] >= 224
        }
        IpAddr::V6(ip) => {
            ip.is_loopback()
                || ip.is_unspecified()
                || (ip.segments()[0] & 0xfe00) == 0xfc00
                || (ip.segments()[0] & 0xffc0) == 0xfe80
        }
    }
}

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

    #[test]
    fn wildcard_matches_bare_and_subdomains() {
        let patterns = vec!["*.example.com".to_string()];
        assert!(host_allowed("example.com", &patterns));
        assert!(host_allowed("cdn.example.com", &patterns));
        assert!(!host_allowed("notexample.com", &patterns));
    }

    #[tokio::test]
    async fn rejects_direct_private_addresses() {
        let error = validate_navigation("http://127.0.0.1/test", false, &[])
            .await
            .unwrap_err();
        assert!(error.contains("local/private"));
    }
}
