//! TLS-impersonate profiles - wire-identical browser ClientHello.
//!
//! `reqwest` + `rustls` cannot produce a Chrome / Firefox / Safari
//! `ClientHello` byte-for-byte: rustls's extension ordering and
//! GREASE placement are library choices, not user-tunable. Edge
//! WAFs (Cloudflare, Akamai, Fastly's Sigsci, Imperva's Bot
//! Protection) JA3- and JA4-fingerprint inbound TLS *before*
//! looking at HTTP, and a rustls fingerprint is unmistakably "not a
//! browser" - the connection gets blocked or shunted to a JS
//! challenge before any payload-level evasion has a chance to run.
//!
//! This module is the canonical home for the *type-level* contract:
//! the [`ImpersonateProfile`] enum, its CLI parser, and the
//! [`supported_profiles`] hint. Every scanner in the Santh fleet
//! that needs TLS-impersonate refers to these types - that way
//! profile names ("chrome131", "firefox133") are a fleet-wide
//! invariant, not a per-scanner string.
//!
//! The `wreq`-backed [`StealthClient`](crate::tls_impersonate_stealth::StealthClient)
//! implementation is gated behind the optional `tls-impersonate` cargo feature
//! because `wreq` pulls `boring-sys` which compiles BoringSSL
//! from C (~30-60 s first build). Default `scanclient` consumers
//! pay zero extra cost; consumers that opt into TLS impersonation
//! enable the feature explicitly.
//!
//! ## Donor history
//!
//! The substrate originally shipped in
//! `wafrift::transport::stealth` (570 LoC, branch
//! `cc/secfinding-v04-upgrade`). Phase 08 of `MASTER_PLAN`
//! consolidates it here so every scanner - gqlprobe, keyhog,
//! gossan, secbench, captchaforge - can reach the same profile
//! catalogue without duplicating the wreq plumbing.
//!
//! ## Why a type-level substrate ships first
//!
//! Lifting the full `wreq`-backed `StealthClient` is a
//! 570-LoC change that touches build configuration (new optional
//! deps), DNS-bogon guards (a separate cross-cutting helper), and
//! the request shape used by every consumer. Shipping the *types*
//! first lets consumers start coding against
//! `scanclient::tls_impersonate::ImpersonateProfile::parse` while
//! the full implementation lands behind it. The wafrift consumer
//! is migrated in a follow-up commit (it currently re-exports
//! these types so the wire identity is preserved).

use std::fmt;

/// One concrete browser fingerprint the stealth client can wear.
///
/// Variants name a (browser, major version) tuple. The set mirrors
/// what `wreq 5.x` ships and what real WAFs cluster their
/// "Chrome / Firefox / Safari / Edge" rules around - older minor
/// versions are not enumerated because their JA3 typically matches
/// the previous major from the WAF's perspective.
///
/// New profiles get added as `wreq` ships them; the canonical
/// list is on the `wreq_util::Emulation` enum. Each variant here
/// has a stable [`name`](ImpersonateProfile::name) string that
/// doubles as the `--tls-impersonate` CLI value across the fleet.
///
/// The enum is `#[non_exhaustive]` so new profiles can be added
/// without breaking existing match arms in consumers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum ImpersonateProfile {
    /// Chrome 120 (older major; kept for back-compat with WAFs
    /// pinned to slightly older fingerprints).
    Chrome120,
    /// Chrome 131 (current major at substrate-lift time).
    Chrome131,
    /// Microsoft Edge 131 (Chromium-based but distinguishable
    /// in JA3 by Edge-specific extension ordering).
    Edge131,
    /// Firefox 133.
    Firefox133,
    /// Safari 17.5 (macOS / iOS - kept separate from Safari 18
    /// because the JA3 differs in cipher-suite ordering).
    Safari17_5,
    /// Safari 18 (current major).
    Safari18,
    /// OkHttp 5 (Android - common when impersonating mobile API
    /// clients; WAFs that look for "mobile app" traffic key off
    /// the absence of Sec-CH-UA and the OkHttp-specific ALPN
    /// ordering).
    OkHttp5,
}

impl ImpersonateProfile {
    /// Parse a CLI string into a profile.
    ///
    /// Accepts case-insensitive aliases:
    /// * `chrome` / `chrome-latest` → latest Chrome
    /// * `firefox` / `firefox-latest` / `ff` → latest Firefox
    /// * `safari` / `safari-latest` → latest Safari
    /// * `okhttp` → OkHttp 5
    /// * Exact names: `chrome131`, `firefox133`, `safari17_5`, etc.
    ///
    /// # Errors
    ///
    /// Returns [`ParseProfileError`] with the offending input
    /// string when the value does not match a known profile. The
    /// error's `Display` lists the supported set so the CLI hint
    /// is one print away.
    pub fn parse(raw: &str) -> Result<Self, ParseProfileError> {
        match raw.to_ascii_lowercase().as_str() {
            "chrome" | "chrome-latest" | "chrome131" | "chrome131_0_0_0" => Ok(Self::Chrome131),
            "chrome120" | "chrome120_0_0_0" => Ok(Self::Chrome120),
            "edge" | "edge-latest" | "edge131" => Ok(Self::Edge131),
            "firefox" | "firefox-latest" | "firefox133" | "ff" => Ok(Self::Firefox133),
            "safari" | "safari-latest" | "safari18" => Ok(Self::Safari18),
            "safari17" | "safari17_5" => Ok(Self::Safari17_5),
            "okhttp" | "okhttp5" => Ok(Self::OkHttp5),
            _ => Err(ParseProfileError(raw.to_string())),
        }
    }

    /// Stable string name of this profile - the canonical
    /// `--tls-impersonate` value across the fleet.
    ///
    /// Round-trips through [`parse`](Self::parse) for every
    /// variant (the parser accepts each `name()` output as a
    /// valid input).
    #[must_use]
    pub fn name(&self) -> &'static str {
        match self {
            Self::Chrome120 => "chrome120",
            Self::Chrome131 => "chrome131",
            Self::Edge131 => "edge131",
            Self::Firefox133 => "firefox133",
            Self::Safari17_5 => "safari17_5",
            Self::Safari18 => "safari18",
            Self::OkHttp5 => "okhttp5",
        }
    }

    /// Browser family this profile impersonates. Useful for
    /// metrics that aggregate over browser identity rather than
    /// version.
    #[must_use]
    pub fn family(&self) -> &'static str {
        match self {
            Self::Chrome120 | Self::Chrome131 => "chrome",
            Self::Edge131 => "edge",
            Self::Firefox133 => "firefox",
            Self::Safari17_5 | Self::Safari18 => "safari",
            Self::OkHttp5 => "okhttp",
        }
    }
}

impl fmt::Display for ImpersonateProfile {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.name())
    }
}

/// All supported profile names, for error messages and `--help`
/// output. Order matches the most-likely-to-be-useful ordering
/// (current Chrome first, mobile last).
#[must_use]
pub fn supported_profiles() -> Vec<&'static str> {
    vec![
        "chrome131",
        "chrome120",
        "edge131",
        "firefox133",
        "safari18",
        "safari17_5",
        "okhttp5",
    ]
}

/// Error type for [`ImpersonateProfile::parse`]. Wraps the
/// offending input string and renders a hint listing the supported
/// set so the CLI can surface it directly.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseProfileError(pub String);

impl fmt::Display for ParseProfileError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "unknown impersonate profile {:?} (supported: {})",
            self.0,
            supported_profiles().join(", ")
        )
    }
}

impl std::error::Error for ParseProfileError {}

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

    #[test]
    fn parse_round_trips_for_every_canonical_name() {
        // PROVING: every variant's `name()` parses back to the same
        // variant. This is the wire-format invariant that lets
        // scanners write `profile.name()` to a log line and consumers
        // re-parse it without disagreement.
        for variant in [
            ImpersonateProfile::Chrome120,
            ImpersonateProfile::Chrome131,
            ImpersonateProfile::Edge131,
            ImpersonateProfile::Firefox133,
            ImpersonateProfile::Safari17_5,
            ImpersonateProfile::Safari18,
            ImpersonateProfile::OkHttp5,
        ] {
            let name = variant.name();
            let parsed = ImpersonateProfile::parse(name).unwrap_or_else(|err| {
                panic!("{variant:?}.name() = {name:?} failed to round-trip: {err}")
            });
            assert_eq!(parsed, variant);
        }
    }

    #[test]
    fn parse_accepts_case_insensitive_aliases() {
        assert_eq!(
            ImpersonateProfile::parse("CHROME").unwrap(),
            ImpersonateProfile::Chrome131
        );
        assert_eq!(
            ImpersonateProfile::parse("Chrome-Latest").unwrap(),
            ImpersonateProfile::Chrome131
        );
        assert_eq!(
            ImpersonateProfile::parse("FF").unwrap(),
            ImpersonateProfile::Firefox133
        );
        assert_eq!(
            ImpersonateProfile::parse("Safari").unwrap(),
            ImpersonateProfile::Safari18
        );
    }

    #[test]
    fn parse_returns_listing_supported_set_on_unknown() {
        let err = ImpersonateProfile::parse("opera42").unwrap_err();
        assert_eq!(err.0, "opera42");
        let rendered = err.to_string();
        assert!(
            rendered.contains("opera42"),
            "render must include offending input"
        );
        for name in supported_profiles() {
            assert!(
                rendered.contains(name),
                "render must list every supported profile name; missing {name}: {rendered}"
            );
        }
    }

    #[test]
    fn family_groups_browser_versions() {
        assert_eq!(ImpersonateProfile::Chrome120.family(), "chrome");
        assert_eq!(ImpersonateProfile::Chrome131.family(), "chrome");
        assert_eq!(ImpersonateProfile::Safari17_5.family(), "safari");
        assert_eq!(ImpersonateProfile::Safari18.family(), "safari");
        assert_eq!(ImpersonateProfile::Edge131.family(), "edge");
        assert_eq!(ImpersonateProfile::Firefox133.family(), "firefox");
        assert_eq!(ImpersonateProfile::OkHttp5.family(), "okhttp");
    }

    #[test]
    fn supported_profiles_lists_every_variant_name() {
        // PROVING: the CLI hint and the enum stay in lockstep. If a
        // new variant is added but not added to supported_profiles,
        // the CLI lists fewer profiles than parse() accepts - silent
        // documentation drift. Pin that every variant's name is in
        // the hint.
        let listed = supported_profiles();
        for variant in [
            ImpersonateProfile::Chrome120,
            ImpersonateProfile::Chrome131,
            ImpersonateProfile::Edge131,
            ImpersonateProfile::Firefox133,
            ImpersonateProfile::Safari17_5,
            ImpersonateProfile::Safari18,
            ImpersonateProfile::OkHttp5,
        ] {
            assert!(
                listed.contains(&variant.name()),
                "supported_profiles missing {variant:?}.name() = {:?}: listed={listed:?}",
                variant.name()
            );
        }
    }

    #[test]
    fn display_matches_name() {
        assert_eq!(
            format!("{}", ImpersonateProfile::Chrome131),
            ImpersonateProfile::Chrome131.name()
        );
    }

    #[test]
    fn ordering_is_stable_and_total() {
        // PROVING: Ord lets consumers collect profiles into a
        // BTreeMap. Stable ordering means a metrics aggregation
        // emits the same order across runs.
        let mut profiles = vec![
            ImpersonateProfile::Safari18,
            ImpersonateProfile::Chrome120,
            ImpersonateProfile::OkHttp5,
        ];
        profiles.sort();
        assert_eq!(
            profiles,
            vec![
                ImpersonateProfile::Chrome120,
                ImpersonateProfile::Safari18,
                ImpersonateProfile::OkHttp5,
            ]
        );
    }

    /// REGRESSION: case-insensitive parse must NOT normalise away
    /// the digits. Pre-fix, a parser that lowercased and then did
    /// `starts_with("chrome")` would silently accept `chrome999`
    /// as Chrome131. Pin that arbitrary version suffixes fail.
    #[test]
    fn parse_rejects_unknown_chrome_version_with_known_prefix() {
        assert!(ImpersonateProfile::parse("chrome999").is_err());
        assert!(ImpersonateProfile::parse("firefox200").is_err());
    }

    /// REGRESSION: empty / whitespace inputs must hit the error
    /// path, not silently produce a default. Pre-fix, a parser
    /// that returned a default on empty would mask a CLI bug where
    /// the user forgot to supply `--tls-impersonate=`'s value.
    #[test]
    fn parse_rejects_empty_and_whitespace() {
        assert!(ImpersonateProfile::parse("").is_err());
        assert!(ImpersonateProfile::parse("   ").is_err());
    }
}
