//! Optional CDP-level SSRF guard for headless-browser navigations.
//!
//! The reqwest clients get a DNS-filtering resolver (see
//! [`crate::crawler::safety::SafeResolver`]) so an image/API host that
//! resolves to an internal IP is refused at connect time. Chromium, however,
//! does its **own** DNS and connection handling, so that resolver can't see
//! browser navigations. Without a second guard, a scraped chapter page that
//! `302`-redirects the browser to `http://127.0.0.1:5432/` (or an
//! attacker-owned hostname that resolves to `169.254.169.254`) would be
//! loaded and parsed as if it were catalog content.
//!
//! This module installs CDP `Fetch` interception on a page so every
//! **Document** (main-frame) request and redirect is re-validated through the
//! same [`ensure_public_target`] + resolved-IP check the reqwest paths use,
//! failing any that target an internal address.
//!
//! **Opt-in / default-off.** Enabling `Fetch` means every intercepted request
//! *must* be resolved by a live handler or the navigation hangs, so this is a
//! fragile hook in the crawler's critical path. It ships behind
//! `CRAWLER_SSRF_INTERCEPT` (default `false`) and the wiring has **not** been
//! exercised against a real Chromium in CI — validate with a manual crawl
//! before enabling in production. When disabled, [`open_page`] is byte-for-byte
//! the previous `browser.new_page(url)` behavior. Even when enabled, a failure
//! to install the guard falls back to an unguarded navigation rather than
//! failing the crawl.

use std::net::IpAddr;
use std::sync::atomic::{AtomicBool, Ordering};

use chromiumoxide::browser::Browser;
use chromiumoxide::cdp::browser_protocol::fetch::{
    ContinueRequestParams, EnableParams, EventRequestPaused, FailRequestParams, RequestPattern,
    RequestStage,
};
use chromiumoxide::cdp::browser_protocol::network::{ErrorReason, ResourceType};
use chromiumoxide::error::Result as CdpResult;
use chromiumoxide::Page;
use futures_util::StreamExt;
use reqwest::Url;

use crate::crawler::safety::{ensure_public_target, is_private_ip};

/// Process-wide toggle, set once at startup from `CRAWLER_SSRF_INTERCEPT`.
/// A single boot-time flag (rather than threading a param through every
/// crawler navigation signature) keeps the off-path a no-op.
static ENABLED: AtomicBool = AtomicBool::new(false);

pub fn set_enabled(on: bool) {
    ENABLED.store(on, Ordering::Relaxed);
}

pub fn is_enabled() -> bool {
    ENABLED.load(Ordering::Relaxed)
}

/// What to do with a navigation URL, decided without DNS where possible so the
/// security-critical branching is unit-testable.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Verdict {
    /// Safe to continue (non-network scheme, or a public IP literal).
    Allow,
    /// Refuse — bad scheme handled elsewhere, localhost, or a private IP literal.
    Block,
    /// A hostname that must be resolved to decide (DNS-rebinding check).
    ResolveHost { host: String, port: u16 },
}

/// Pure decision over a URL string. `data:` / `blob:` / `about:` and other
/// non-http(s) schemes are allowed (not a network-SSRF vector); http(s) with a
/// private/loopback/localhost literal is blocked; an http(s) hostname needs
/// resolution.
pub(crate) fn verdict(url: &str) -> Verdict {
    let Ok(parsed) = Url::parse(url) else {
        // Unparseable — let Chromium reject it; not our call to make.
        return Verdict::Allow;
    };
    match parsed.scheme() {
        "http" | "https" => {}
        _ => return Verdict::Allow,
    }
    // Literal private IP / localhost / missing host → block (string check).
    if ensure_public_target(url).is_err() {
        return Verdict::Block;
    }
    match parsed.host_str() {
        Some(host) if !is_ip_literal(host) => Verdict::ResolveHost {
            host: host.to_string(),
            port: parsed.port_or_known_default().unwrap_or(80),
        },
        // Public IP literal (ensure_public_target already passed it).
        _ => Verdict::Allow,
    }
}

/// Whether `host` (as reqwest's `host_str()` yields it) is an IP literal.
/// IPv6 literals arrive bracketed (`[::1]`), which don't parse as `IpAddr`
/// directly — strip the brackets first.
fn is_ip_literal(host: &str) -> bool {
    let unbracketed = host
        .strip_prefix('[')
        .and_then(|s| s.strip_suffix(']'))
        .unwrap_or(host);
    unbracketed.parse::<IpAddr>().is_ok()
}

/// Async decision: resolves hostnames and blocks if any resolved address is
/// private (DNS rebinding). A resolution failure is *not* treated as blocked —
/// Chromium won't be able to connect either, so there's nothing to exfiltrate.
pub(crate) async fn is_blocked(url: &str) -> bool {
    match verdict(url) {
        Verdict::Allow => false,
        Verdict::Block => true,
        Verdict::ResolveHost { host, port } => match tokio::net::lookup_host((host.as_str(), port))
            .await
        {
            Ok(addrs) => addrs.map(|a| a.ip()).any(|ip| is_private_ip(&ip)),
            Err(_) => false,
        },
    }
}

/// Open a page for navigation. With interception off, this is exactly
/// `browser.new_page(url)`. With it on, the page is created blank (no network),
/// the navigation guard is installed, and only then does it navigate — so the
/// initial request and any redirects pass through the guard.
pub async fn open_page(browser: &Browser, url: &str) -> CdpResult<Page> {
    if !is_enabled() {
        return browser.new_page(url).await;
    }
    let page = browser.new_page("about:blank").await?;
    if let Err(e) = install_navigation_guard(&page).await {
        // Fail open on install error: navigate unguarded rather than wedge the
        // crawl. The reqwest-layer resolver still covers image downloads.
        tracing::warn!(url = %url, error = %e, "SSRF navigation guard failed to install; navigating unguarded");
    }
    page.goto(url).await?;
    Ok(page)
}

/// Enable `Fetch` for Document requests on `page` and spawn a task that
/// continues/fails each paused request per [`is_blocked`].
async fn install_navigation_guard(page: &Page) -> CdpResult<()> {
    // Intercept only main-frame Document requests at the request stage — a
    // navigation plus its redirects, nothing else. Keeps the paused-request
    // volume tiny so the handler can't become a page-load bottleneck.
    let pattern = RequestPattern::builder()
        .resource_type(ResourceType::Document)
        .request_stage(RequestStage::Request)
        .build();
    page.execute(EnableParams {
        patterns: Some(vec![pattern]),
        handle_auth_requests: None,
    })
    .await?;

    let mut paused = page.event_listener::<EventRequestPaused>().await?;
    let handler_page = page.clone();
    tokio::spawn(async move {
        while let Some(ev) = paused.next().await {
            let request_id = ev.request_id.clone();
            let outcome = if is_blocked(&ev.request.url).await {
                tracing::warn!(
                    url = %ev.request.url,
                    "SSRF guard blocked browser navigation to an internal target"
                );
                handler_page
                    .execute(FailRequestParams::new(request_id, ErrorReason::BlockedByClient))
                    .await
                    .map(|_| ())
            } else {
                handler_page
                    .execute(ContinueRequestParams::new(request_id))
                    .await
                    .map(|_| ())
            };
            if let Err(e) = outcome {
                // The page/session is gone (page closed) — the stream will end
                // too; stop handling so the task exits rather than spins.
                tracing::debug!(error = %e, "fetch interceptor: continue/fail failed, ending handler");
                break;
            }
        }
    });
    Ok(())
}

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

    #[test]
    fn verdict_allows_non_network_schemes() {
        for url in ["about:blank", "data:text/html,hi", "blob:abc", "chrome://version"] {
            assert_eq!(verdict(url), Verdict::Allow, "{url} should be allowed");
        }
    }

    #[test]
    fn verdict_blocks_private_ip_literals_and_localhost() {
        for url in [
            "http://127.0.0.1/",
            "http://10.0.0.1/",
            "http://192.168.1.1:5432/",
            "http://169.254.169.254/latest/meta-data/",
            "http://[::1]/",
            "http://[::ffff:127.0.0.1]/",
            "http://localhost/",
            "https://[fd00::1]/",
        ] {
            assert_eq!(verdict(url), Verdict::Block, "{url} should be blocked");
        }
    }

    #[test]
    fn verdict_allows_public_ip_literals() {
        for url in ["http://93.184.216.34/", "https://[2606:4700:4700::1111]/"] {
            assert_eq!(verdict(url), Verdict::Allow, "{url} should be allowed");
        }
    }

    #[test]
    fn verdict_defers_hostnames_to_resolution() {
        assert_eq!(
            verdict("https://cdn.example.com/img.jpg"),
            Verdict::ResolveHost {
                host: "cdn.example.com".to_string(),
                port: 443
            }
        );
        assert_eq!(
            verdict("http://catalog.test:8080/list"),
            Verdict::ResolveHost {
                host: "catalog.test".to_string(),
                port: 8080
            }
        );
    }

    #[tokio::test]
    async fn is_blocked_true_for_private_literal_no_dns() {
        assert!(is_blocked("http://169.254.169.254/").await);
        assert!(!is_blocked("http://93.184.216.34/").await);
        // Non-network scheme is always allowed through.
        assert!(!is_blocked("about:blank").await);
    }

    #[test]
    fn enabled_toggle_roundtrips() {
        // Global; restore afterwards so other tests see the default.
        let prev = is_enabled();
        set_enabled(true);
        assert!(is_enabled());
        set_enabled(false);
        assert!(!is_enabled());
        set_enabled(prev);
    }
}
