//! Production SSRF-guarded source fetcher for the `citation` matcher.
//!
//! Separated from `citation.rs` so the matcher logic stays network-free and
//! testable offline; this module owns the one impure step. The guard is layered:
//! [`crate::matchers::citation::classify_url`] rejects an unsafe URL before any
//! connection, then
//! the host is resolved and every address is re-checked against the same
//! private/loopback/metadata block so a hostname pointing at an internal IP is
//! caught after DNS. Redirects are disabled (a redirect to a private IP would
//! bypass the post-DNS check), the body is size-capped, and the request times
//! out. A small TOCTOU window between the resolve check and the connect remains,
//! documented and acceptable for a developer test tool.

use std::net::{IpAddr, ToSocketAddrs};
use std::str::FromStr;
use std::time::Duration;

use async_trait::async_trait;

use super::citation::{classify_url, is_blocked_ip, FetchError, SourceFetcher};

/// Cap on a fetched source body. Anything past this is truncated before the
/// judge sees it, bounding both memory and judge token cost.
const MAX_BYTES: usize = 1_000_000;

/// Per-request timeout for the whole fetch.
const TIMEOUT: Duration = Duration::from_secs(10);

/// An SSRF-guarded HTTP fetcher backed by `reqwest`.
pub struct ReqwestFetcher {
    client: reqwest::Client,
}

impl ReqwestFetcher {
    /// Build a fetcher with redirects disabled and a request timeout.
    pub fn new() -> Result<Self, FetchError> {
        let client = reqwest::Client::builder()
            .timeout(TIMEOUT)
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .map_err(|e| FetchError::Unreachable(format!("http client build failed: {e}")))?;
        Ok(Self { client })
    }
}

#[async_trait]
impl SourceFetcher for ReqwestFetcher {
    async fn fetch(&self, url: &str) -> Result<String, FetchError> {
        classify_url(url).map_err(FetchError::Blocked)?;
        verify_host_resolves_global(url).await?;
        let resp = self
            .client
            .get(url)
            .send()
            .await
            .map_err(|e| FetchError::Unreachable(format!("request failed: {e}")))?;
        if !resp.status().is_success() {
            return Err(FetchError::Unreachable(format!(
                "source returned status {}",
                resp.status()
            )));
        }
        let bytes = resp
            .bytes()
            .await
            .map_err(|e| FetchError::Unreachable(format!("reading body failed: {e}")))?;
        let capped = &bytes[..bytes.len().min(MAX_BYTES)];
        Ok(String::from_utf8_lossy(capped).to_string())
    }
}

/// Resolve the URL's host and reject it if any resolved address is private,
/// loopback, or metadata. An IP-literal host was already checked by
/// [`classify_url`], so this only does work for named hosts. DNS resolution is
/// blocking, so it runs on the blocking pool.
async fn verify_host_resolves_global(url: &str) -> Result<(), FetchError> {
    let parsed =
        url::Url::parse(url).map_err(|e| FetchError::Blocked(format!("unparseable url: {e}")))?;
    let host = parsed
        .host_str()
        .ok_or_else(|| FetchError::Blocked("url has no host".to_string()))?
        .to_string();
    if IpAddr::from_str(&host).is_ok() {
        // An IP literal was already vetted by classify_url; no DNS to do.
        return Ok(());
    }
    let port = parsed.port_or_known_default().unwrap_or(443);
    let target = format!("{host}:{port}");
    let resolved = tokio::task::spawn_blocking(move || target.to_socket_addrs())
        .await
        .map_err(|e| FetchError::Unreachable(format!("resolver task failed: {e}")))?
        .map_err(|e| FetchError::Unreachable(format!("dns resolution failed: {e}")))?;
    let mut any = false;
    for addr in resolved {
        any = true;
        if is_blocked_ip(&addr.ip()) {
            return Err(FetchError::Blocked(format!(
                "host {host} resolves to a private or metadata address"
            )));
        }
    }
    if !any {
        return Err(FetchError::Unreachable(format!(
            "host {host} did not resolve"
        )));
    }
    Ok(())
}

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

    // The guard must reject unsafe URLs before any network call, so these run
    // offline. The happy fetch path needs a live network and is opt-in.

    #[tokio::test]
    async fn rejects_link_local_metadata_address() {
        let f = ReqwestFetcher::new().unwrap();
        let err = f.fetch("http://169.254.169.254/latest/meta-data/").await;
        assert!(matches!(err, Err(FetchError::Blocked(_))), "got {err:?}");
    }

    #[tokio::test]
    async fn rejects_loopback_host() {
        let f = ReqwestFetcher::new().unwrap();
        assert!(matches!(
            f.fetch("http://localhost:8080/x").await,
            Err(FetchError::Blocked(_))
        ));
        assert!(matches!(
            f.fetch("http://127.0.0.1/x").await,
            Err(FetchError::Blocked(_))
        ));
    }

    #[tokio::test]
    async fn rejects_private_ip_literal() {
        let f = ReqwestFetcher::new().unwrap();
        assert!(matches!(
            f.fetch("http://10.0.0.5/secret").await,
            Err(FetchError::Blocked(_))
        ));
        assert!(matches!(
            f.fetch("http://192.168.1.1/").await,
            Err(FetchError::Blocked(_))
        ));
    }

    #[tokio::test]
    async fn rejects_non_http_scheme() {
        let f = ReqwestFetcher::new().unwrap();
        assert!(matches!(
            f.fetch("file:///etc/passwd").await,
            Err(FetchError::Blocked(_))
        ));
    }
}
