//! Gap-coverage tests for scanclient.
//!
//! These tests pin public-API surface that is not already exercised by the
//! unit, adversarial, integration, or property suites.  Specifically:
//!
//! * `urlutil` – `resolve_url` edge cases (protocol-relative, path-relative,
//!   no-slash base), `clean_url` with no query / only tracking params, and
//!   `dedup_host_key` fallback path.
//! * `ScanResponse::from_parts` – header lookup, `contains` on binary body,
//!   `body_bytes`/`body_text` round-trip.
//! * `Error` variant display messages – every error variant has a
//!   human-readable message; test the ones not covered elsewhere.
//! * `retry` – `should_retry_status` covers all six retryable codes and
//!   rejects 200/404; `backoff_delay` boundary at attempt 0 and saturation.
//! * `bogon` re-export – `ip_addr_is_bogon` is reachable from `scanclient::bogon`.
//! * `HttpConfig::default` field values – pins the safety defaults.

#![allow(
    clippy::uninlined_format_args,
    clippy::manual_string_new,
    clippy::needless_raw_string_hashes
)]

use reqwest::StatusCode;
use scanclient::bogon::ip_addr_is_bogon;
use scanclient::urlutil::{clean_url, dedup_host_key, resolve_url, strip_www_host};
use scanclient::{Error, HttpConfig, ScanResponse};
use std::net::IpAddr;
use std::str::FromStr;

// ── urlutil::resolve_url ──────────────────────────────────────────────────────

#[test]
fn resolve_url_upgrades_protocol_relative_to_https() {
    let result = resolve_url("https://base.com/dir/page", "//cdn.example.com/script.js");
    assert_eq!(result, "https://cdn.example.com/script.js");
}

#[test]
fn resolve_url_resolves_root_absolute_path() {
    let result = resolve_url("https://base.com/a/b/c", "/other/path");
    assert_eq!(result, "https://base.com/other/path");
}

#[test]
fn resolve_url_resolves_relative_against_directory() {
    // relative from "https://base.com/dir/" should be "https://base.com/dir/foo"
    let result = resolve_url("https://base.com/dir/page.html", "foo.html");
    assert_eq!(result, "https://base.com/dir/foo.html");
}

#[test]
fn resolve_url_base_with_trailing_slash() {
    // base ends in a slash; relative joins at that directory level
    let result = resolve_url("https://base.com/", "page.html");
    assert_eq!(result, "https://base.com/page.html");
}

#[test]
fn resolve_url_absolute_https_passes_through() {
    let result = resolve_url("https://base.com/a/", "https://other.com/b");
    assert_eq!(result, "https://other.com/b");
}

#[test]
fn resolve_url_absolute_http_passes_through() {
    let result = resolve_url("https://base.com/a/", "http://other.com/b");
    assert_eq!(result, "http://other.com/b");
}

// ── urlutil::clean_url ────────────────────────────────────────────────────────

#[test]
fn clean_url_no_query_string_is_identity() {
    let url = "https://example.com/path/to/page";
    assert_eq!(clean_url(url), url);
}

#[test]
fn clean_url_only_tracking_params_removes_entire_query() {
    let url = "https://example.com/page?fbclid=ABC123&utm_source=google";
    let cleaned = clean_url(url);
    assert!(
        !cleaned.contains('?'),
        "query string should be fully removed: {cleaned}"
    );
}

#[test]
fn clean_url_preserves_non_tracking_params() {
    let url = "https://example.com/search?q=rust&fbclid=XYZ&page=2";
    let cleaned = clean_url(url);
    assert!(cleaned.contains("q=rust"), "should keep q: {cleaned}");
    assert!(cleaned.contains("page=2"), "should keep page: {cleaned}");
    assert!(!cleaned.contains("fbclid"), "should drop fbclid: {cleaned}");
}

#[test]
fn clean_url_strips_fragment() {
    let url = "https://example.com/page?id=1#section";
    let cleaned = clean_url(url);
    assert!(
        !cleaned.contains('#'),
        "fragment must be stripped: {cleaned}"
    );
    assert!(
        cleaned.contains("id=1"),
        "non-tracking param must survive: {cleaned}"
    );
}

#[test]
fn clean_url_malformed_url_returned_unchanged() {
    let url = "not-a-url at all";
    assert_eq!(clean_url(url), url);
}

// ── urlutil::dedup_host_key ───────────────────────────────────────────────────

#[test]
fn dedup_host_key_returns_registrable_domain_for_http_url() {
    let key = dedup_host_key("https://www.Example.COM/path?id=1");
    // registrable_domain lowercases and strips www
    assert_eq!(key, "example.com");
}

#[test]
fn dedup_host_key_falls_back_for_mailto_uri() {
    let key = dedup_host_key("mailto:user@example.com");
    // no registrable_domain; clean_url round-trips it
    assert!(
        key.contains("user@example.com"),
        "key should contain address: {key}"
    );
}

// ── urlutil::strip_www_host ───────────────────────────────────────────────────

#[test]
fn strip_www_host_empty_string() {
    // edge case: strip_www_host on "" must not panic
    let result = strip_www_host("");
    assert_eq!(&*result, "");
}

#[test]
fn strip_www_host_exactly_www_dot() {
    // "www." alone strips the prefix and leaves an empty string
    let result = strip_www_host("www.");
    assert_eq!(&*result, "");
}

// ── ScanResponse::from_parts ──────────────────────────────────────────────────

#[test]
fn scan_response_from_parts_status() {
    let r = ScanResponse::from_parts(StatusCode::NOT_FOUND, Default::default(), vec![]);
    assert_eq!(r.status(), StatusCode::NOT_FOUND);
}

#[test]
fn scan_response_body_bytes_round_trip() {
    let body = b"hello world".to_vec();
    let r = ScanResponse::from_parts(StatusCode::OK, Default::default(), body.clone());
    assert_eq!(r.body_bytes(), body.as_slice());
}

#[test]
fn scan_response_body_text_valid_utf8() {
    let r = ScanResponse::from_parts(StatusCode::OK, Default::default(), b"hello".to_vec());
    assert_eq!(r.body_text().unwrap(), "hello");
}

#[test]
fn scan_response_body_text_invalid_utf8_returns_error() {
    let r = ScanResponse::from_parts(StatusCode::OK, Default::default(), vec![0xff, 0xfe]);
    assert!(r.body_text().is_err());
}

#[test]
fn scan_response_contains_searches_body() {
    let r = ScanResponse::from_parts(
        StatusCode::OK,
        Default::default(),
        b"Error: invalid token".to_vec(),
    );
    assert!(r.contains("invalid token"));
    assert!(!r.contains("authorized"));
}

#[test]
fn scan_response_contains_on_binary_body() {
    // contains should not panic on non-UTF-8 data
    let r = ScanResponse::from_parts(StatusCode::OK, Default::default(), vec![0xff, 0xfe, 0x00]);
    // Whether or not the needle matches is secondary; must not panic
    let _ = r.contains("something");
}

#[test]
fn scan_response_header_value_lookup() {
    let mut headers = reqwest::header::HeaderMap::new();
    headers.insert(
        reqwest::header::CONTENT_TYPE,
        reqwest::header::HeaderValue::from_static("text/plain"),
    );
    let r = ScanResponse::from_parts(StatusCode::OK, headers, b"data".to_vec());
    assert_eq!(r.header_value("content-type"), Some("text/plain"));
    assert_eq!(r.header_value("x-does-not-exist"), None);
}

// ── retry functions ───────────────────────────────────────────────────────────

#[test]
fn should_retry_status_retryable_codes() {
    use reqwest::StatusCode;
    use scanclient::retry::should_retry_status;
    // All six explicitly retryable codes
    assert!(should_retry_status(StatusCode::TOO_MANY_REQUESTS));
    assert!(should_retry_status(StatusCode::REQUEST_TIMEOUT));
    assert!(should_retry_status(StatusCode::INTERNAL_SERVER_ERROR));
    assert!(should_retry_status(StatusCode::BAD_GATEWAY));
    assert!(should_retry_status(StatusCode::SERVICE_UNAVAILABLE));
    assert!(should_retry_status(StatusCode::GATEWAY_TIMEOUT));
}

#[test]
fn should_retry_status_non_retryable_codes() {
    use reqwest::StatusCode;
    use scanclient::retry::should_retry_status;
    assert!(!should_retry_status(StatusCode::OK));
    assert!(!should_retry_status(StatusCode::NOT_FOUND));
    assert!(!should_retry_status(StatusCode::UNAUTHORIZED));
    assert!(!should_retry_status(StatusCode::FORBIDDEN));
}

#[test]
fn backoff_delay_at_attempt_zero_equals_base() {
    use scanclient::retry::backoff_delay;
    use std::time::Duration;
    assert_eq!(backoff_delay(100, 0), Duration::from_millis(100));
}

#[test]
fn backoff_delay_saturates_at_max() {
    use scanclient::retry::{MAX_RETRY_DELAY_MS, backoff_delay};
    use std::time::Duration;
    // attempt 20 saturates
    assert_eq!(
        backoff_delay(100, 20),
        Duration::from_millis(MAX_RETRY_DELAY_MS)
    );
    assert_eq!(
        backoff_delay(100, 16),
        Duration::from_millis(MAX_RETRY_DELAY_MS)
    );
}

// ── bogon re-export ───────────────────────────────────────────────────────────

#[test]
fn bogon_reexport_loopback_ipv4_is_bogon() {
    let ip = IpAddr::from_str("127.0.0.1").unwrap();
    assert!(ip_addr_is_bogon(ip));
}

#[test]
fn bogon_reexport_loopback_ipv6_is_bogon() {
    let ip = IpAddr::from_str("::1").unwrap();
    assert!(ip_addr_is_bogon(ip));
}

#[test]
fn bogon_reexport_link_local_is_bogon() {
    let ip = IpAddr::from_str("169.254.1.1").unwrap();
    assert!(ip_addr_is_bogon(ip));
}

#[test]
fn bogon_reexport_private_rfc1918_is_bogon() {
    let ip = IpAddr::from_str("192.168.0.1").unwrap();
    assert!(ip_addr_is_bogon(ip));
}

#[test]
fn bogon_reexport_public_ip_is_not_bogon() {
    let ip = IpAddr::from_str("8.8.8.8").unwrap();
    assert!(!ip_addr_is_bogon(ip));
}

// ── Error display messages ────────────────────────────────────────────────────

#[test]
fn error_redirect_blocked_display() {
    let err = Error::RedirectBlocked {
        url: "http://10.0.0.1/secret".to_string(),
    };
    let msg = err.to_string();
    assert!(
        msg.contains("http://10.0.0.1/secret"),
        "url missing from: {msg}"
    );
}

#[test]
fn error_response_body_too_large_display() {
    let err = Error::ResponseBodyTooLarge {
        limit: 1024,
        observed: 2048,
    };
    let msg = err.to_string();
    assert!(msg.contains("1024"), "limit missing from: {msg}");
    assert!(msg.contains("2048"), "observed missing from: {msg}");
}

#[test]
fn error_redirect_limit_exceeded_display() {
    let err = Error::RedirectLimitExceeded {
        url: "https://example.com/loop".to_string(),
        max: 5,
    };
    let msg = err.to_string();
    assert!(msg.contains("5"), "max missing from: {msg}");
    assert!(msg.contains("example.com"), "url missing from: {msg}");
}

#[test]
fn error_invalid_timeout_display() {
    let err = Error::InvalidTimeout {
        field: "timeout_secs",
        value: 0,
        max: 300,
    };
    let msg = err.to_string();
    assert!(msg.contains("timeout_secs"), "field missing from: {msg}");
    assert!(
        msg.contains('0'.to_string().as_str()),
        "value missing from: {msg}"
    );
}

// ── HttpConfig default safety invariants ─────────────────────────────────────

#[test]
fn http_config_default_tls_verify_is_true() {
    assert!(
        HttpConfig::default().tls_verify,
        "TLS verification must be on by default"
    );
}

#[test]
fn http_config_default_invalid_certs_false() {
    assert!(
        !HttpConfig::default().tls_accept_invalid_certs,
        "tls_accept_invalid_certs must default to false"
    );
}

#[test]
fn http_config_default_invalid_hostnames_false() {
    assert!(
        !HttpConfig::default().tls_accept_invalid_hostnames,
        "tls_accept_invalid_hostnames must default to false"
    );
}

#[test]
fn http_config_default_max_body_size_is_10mb() {
    assert_eq!(
        HttpConfig::default().max_body_size,
        10 * 1024 * 1024,
        "default max_body_size must be 10 MiB"
    );
}

#[test]
fn http_config_default_retry_non_idempotent_is_false() {
    assert!(
        !HttpConfig::default().retry_non_idempotent_methods,
        "POST retries must be opt-in, not on by default"
    );
}

#[test]
fn http_config_default_no_rate_limit() {
    assert!(
        HttpConfig::default().rate_limit_per_sec.is_none(),
        "rate limiter must be disabled by default"
    );
}
