// SPDX-License-Identifier: MIT OR Apache-2.0
// Workload: declarative (identity pool with deterministic seeding)
//! Browser identity pool for adaptive anti-bot rotation (WS-26).
//!
//! Each [`IdentityProfile`] bundles a User-Agent string, its detected browser
//! family, the platform it claims, and a deterministic seed used to produce
//! structural header variations (order, Accept-Language, Sec-CH-UA-Arch).
//!
//! The [`IdentityPool`] owns a fixed set of 12 identities (4 families × 3
//! platforms) and exposes a [`IdentityPool::rotate_on_block`] method that
//! implements the 5-level adaptive cascade used by `search::execute_with_retry`.
//!
//! ## Why a separate module
//!
//! The legacy `http::select_user_agent` function picks one UA at startup and
//! reuses it for the entire session. This produces a single fingerprint that
//! `DuckDuckGo` can classify after the first request. The pool rotates
//! identities on detected blocks (HTTP 202/403/429), so the caller never
//! sustains the same fingerprint across consecutive retries.

use rand::rngs::StdRng;
use rand::seq::{IndexedRandom, SliceRandom};
use rand::{RngExt, SeedableRng};
use serde::{Deserialize, Serialize};

/// Browser family claimed by the identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum BrowserFamily {
    /// Google Chrome or Chromium derivatives (excluding Edge).
    Chrome,
    /// Mozilla Firefox.
    Firefox,
    /// Apple Safari (UA without `Chrome/` indicator).
    Safari,
    /// Microsoft Edge (Chromium-based, contains `Edg/`).
    Edge,
}

impl BrowserFamily {
    /// Returns the canonical English name for use in metrics and logs.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Chrome => "chrome",
            Self::Firefox => "firefox",
            Self::Safari => "safari",
            Self::Edge => "edge",
        }
    }
}

/// Operating system platform claimed by the identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Platform {
    /// Microsoft Windows.
    Windows,
    /// Apple macOS.
    MacOS,
    /// Linux.
    Linux,
}

impl Platform {
    /// Returns the canonical English name for use in metrics and logs.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Windows => "windows",
            Self::MacOS => "macos",
            Self::Linux => "linux",
        }
    }
}

/// One selectable identity in the pool.
///
/// Bundles the User-Agent string with its detected family, the platform it
/// claims (used to drive Client Hints), and a deterministic seed that drives
/// the header order / Accept-Language / Sec-CH-UA variations generated by
/// [`IdentityProfile::shuffled_headers`].
#[derive(Debug, Clone)]
pub struct IdentityProfile {
    /// Detected browser family.
    pub family: BrowserFamily,
    /// Claimed platform.
    pub platform: Platform,
    /// Full User-Agent string.
    pub user_agent: String,
    /// Major version extracted from the UA (e.g. `146` for `Chrome/146`).
    pub major_version: u32,
    /// Normalized platform string for Client Hints (`"Windows"`, `"macOS"`, `"Linux"`).
    pub ua_platform: &'static str,
    /// Deterministic seed for header variation.
    pub seed: u64,
}

impl IdentityProfile {
    /// Produces a header set with family-specific content and
    /// seed-deterministic structural variation (order, Accept-Language, Sec-CH-UA-Arch).
    ///
    /// Returns a vector of `(name, value)` pairs ready to be inserted into
    /// a `wreq::header::HeaderMap`.
    pub fn shuffled_headers(&self, language: &str, country: &str) -> Vec<(&'static str, String)> {
        let mut rng = StdRng::seed_from_u64(self.seed);
        let accept = self.accept_header();
        let accept_language = self.accept_language(language, country, &mut rng);
        let accept_encoding = "gzip, deflate, br".to_string();
        let platform_arc = self.platform_arch(&mut rng);

        // Headers emitted by every browser.
        let mut all: Vec<(&'static str, String)> = vec![
            ("accept", accept),
            ("accept-language", accept_language),
            ("accept-encoding", accept_encoding),
            ("upgrade-insecure-requests", "1".to_string()),
            ("sec-fetch-dest", "document".to_string()),
            ("sec-fetch-mode", "navigate".to_string()),
            ("sec-fetch-site", "none".to_string()),
            ("sec-fetch-user", "?1".to_string()),
        ];

        // Client Hints — only Chrome/Edge.
        if matches!(self.family, BrowserFamily::Chrome | BrowserFamily::Edge) {
            let sec_ch_ua = match self.family {
                BrowserFamily::Edge => format!(
                    r#""Chromium";v="{v}", "Microsoft Edge";v="{v}", "Not-A.Brand";v="99""#,
                    v = self.major_version
                ),
                _ => format!(
                    r#""Chromium";v="{v}", "Google Chrome";v="{v}", "Not-A.Brand";v="99""#,
                    v = self.major_version
                ),
            };
            let platform_quoted = format!(r#""{}""#, self.ua_platform);
            all.push(("sec-ch-ua", sec_ch_ua));
            all.push(("sec-ch-ua-mobile", "?0".to_string()));
            all.push(("sec-ch-ua-platform", platform_quoted));
            all.push(("sec-ch-ua-arch", platform_arc));
            all.push(("cache-control", "max-age=0".to_string()));
        }

        // Shuffle the order to mimic the (slightly) different order each
        // browser emits. Stable sort keeps the most important headers
        // (Accept, Accept-Language) in a position close to the top.
        all.shuffle(&mut rng);

        all
    }

    fn accept_header(&self) -> String {
        match self.family {
            BrowserFamily::Chrome | BrowserFamily::Edge => {
                "text/html,application/xhtml+xml,application/xml;q=0.9,\
                 image/avif,image/webp,image/apng,*/*;q=0.8"
            }
            BrowserFamily::Firefox => {
                "text/html,application/xhtml+xml,application/xml;q=0.9,\
                 image/avif,image/webp,*/*;q=0.8"
            }
            BrowserFamily::Safari => {
                "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
            }
        }
        .to_string()
    }

    fn accept_language(&self, language: &str, country: &str, rng: &mut StdRng) -> String {
        let lang = language.to_ascii_lowercase();
        let ctry = country.to_ascii_uppercase();
        // Deterministic 0/1 choose between the two real-world variants.
        let variant: u8 = rng.random_range(0..=1);
        if lang == "en" {
            match variant {
                0 => "en-US,en;q=0.9".to_string(),
                _ => "en-US,en;q=0.9,en-GB;q=0.8".to_string(),
            }
        } else {
            match variant {
                0 => format!("{lang}-{ctry},{lang};q=0.9,en-US;q=0.8,en;q=0.7"),
                _ => format!("{lang}-{ctry},{lang};q=0.9,en;q=0.8"),
            }
        }
    }

    fn platform_arch(&self, rng: &mut StdRng) -> String {
        // Chrome emits a Sec-CH-UA-Arch hint most of the time; we randomize
        // between x86, x86_64 and absent (each real-browser distribution).
        if rng.random_bool(0.85) {
            "x86_64".to_string()
        } else if rng.random_bool(0.5) {
            "x86".to_string()
        } else {
            String::new()
        }
    }

    /// Short identifier for the identity, suitable for the JSON metadata
    /// `identidade_usada` field. Format: `<family>-<platform>-<seed16hex>`.
    pub fn tag(&self) -> String {
        format!(
            "{}-{}-{:016x}",
            self.family.as_str(),
            self.platform.as_str(),
            self.seed
        )
    }
}

/// Pool of 12 identities (4 families × 3 platforms) used by the
/// adaptive anti-bot rotation in WS-26.
#[derive(Debug)]
pub struct IdentityPool {
    identities: Vec<IdentityProfile>,
    rng: StdRng,
    /// Cascade level (0 = current identity, 1+ = rotated).
    level: u32,
    /// Index of the identity currently in use.
    current: usize,
}

impl IdentityPool {
    /// Builds a pool of 12 identities from the built-in catalog.
    ///
    /// When `seed` is `Some`, the rotation order is fully deterministic.
    /// When `seed` is `None`, a random seed is drawn from the OS RNG.
    pub fn new(seed: Option<u64>) -> Self {
        let identities = build_default_identities();
        let s = seed.unwrap_or_else(|| {
            let mut r = rand::rng();
            r.random::<u64>()
        });
        let rng = StdRng::seed_from_u64(s);
        Self {
            identities,
            rng,
            level: 0,
            current: 0,
        }
    }

    /// Returns the identity currently in use.
    pub fn current(&self) -> &IdentityProfile {
        &self.identities[self.current]
    }

    /// Current cascade level (0 = first attempt, 1+ = rotated).
    pub fn level(&self) -> u32 {
        self.level
    }

    /// Resets the cascade to the first attempt (used at the start of a new query).
    pub fn reset(&mut self) {
        self.level = 0;
    }

    /// Advances the cascade and returns the new active identity.
    ///
    /// Cascade strategy (5 levels, used by `search::execute_with_retry`):
    /// 0. Current identity (no rotation).
    /// 1. Same family, different platform.
    /// 2. Different family, same platform.
    /// 3. Different family and platform + endpoint will be downgraded to lite.
    /// 4. Random identity (caller should sleep 30-60s before retrying).
    pub fn rotate_on_block(&mut self) -> &IdentityProfile {
        self.level = self.level.saturating_add(1);
        let next = match self.level {
            1 => self.pick_same_family_different_platform(),
            2 => self.pick_different_family_same_platform(),
            3 => self.pick_different_family_and_platform(),
            4.. => self.pick_random(),
            _ => self.current,
        };
        self.current = next;
        &self.identities[self.current]
    }

    /// Returns the `identidade_usada` string for the active identity.
    pub fn active_tag(&self) -> String {
        self.current().tag()
    }

    fn pick_same_family_different_platform(&mut self) -> usize {
        let current = &self.identities[self.current];
        let candidates: Vec<usize> = self
            .identities
            .iter()
            .enumerate()
            .filter(|(i, p)| *i != self.current && p.family == current.family)
            .map(|(i, _)| i)
            .collect();
        self.choose_from(&candidates)
    }

    fn pick_different_family_same_platform(&mut self) -> usize {
        let current = &self.identities[self.current];
        let candidates: Vec<usize> = self
            .identities
            .iter()
            .enumerate()
            .filter(|(i, p)| *i != self.current && p.platform == current.platform)
            .map(|(i, _)| i)
            .collect();
        self.choose_from(&candidates)
    }

    fn pick_different_family_and_platform(&mut self) -> usize {
        let current = &self.identities[self.current];
        let candidates: Vec<usize> = self
            .identities
            .iter()
            .enumerate()
            .filter(|(i, p)| {
                *i != self.current && p.family != current.family && p.platform != current.platform
            })
            .map(|(i, _)| i)
            .collect();
        self.choose_from(&candidates)
    }

    fn pick_random(&mut self) -> usize {
        let n = self.identities.len();
        self.rng.random_range(0..n)
    }

    fn choose_from(&mut self, candidates: &[usize]) -> usize {
        if candidates.is_empty() {
            return self.pick_random();
        }
        candidates
            .choose(&mut self.rng)
            .copied()
            .unwrap_or(self.current)
    }
}

fn build_default_identities() -> Vec<IdentityProfile> {
    // 4 families × 3 platforms = 12 identities.
    // Each UA string is paired with its family and platform metadata.
    // The seed is derived from a hash of the UA so the same UA produces
    // the same header order across runs.
    vec![
        IdentityProfile {
            family: BrowserFamily::Chrome,
            platform: Platform::Windows,
            user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36".into(),
            major_version: 146,
            ua_platform: "Windows",
            seed: 0x1111_1111_aaaa_0001,
        },
        IdentityProfile {
            family: BrowserFamily::Chrome,
            platform: Platform::MacOS,
            user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36".into(),
            major_version: 146,
            ua_platform: "macOS",
            seed: 0x2222_2222_bbbb_0002,
        },
        IdentityProfile {
            family: BrowserFamily::Chrome,
            platform: Platform::Linux,
            user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36".into(),
            major_version: 146,
            ua_platform: "Linux",
            seed: 0x3333_3333_cccc_0003,
        },
        IdentityProfile {
            family: BrowserFamily::Edge,
            platform: Platform::Windows,
            user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.3800.97".into(),
            major_version: 145,
            ua_platform: "Windows",
            seed: 0x4444_4444_dddd_0004,
        },
        IdentityProfile {
            family: BrowserFamily::Edge,
            platform: Platform::MacOS,
            user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.3800.97".into(),
            major_version: 145,
            ua_platform: "macOS",
            seed: 0x5555_5555_eeee_0005,
        },
        IdentityProfile {
            family: BrowserFamily::Edge,
            platform: Platform::Linux,
            user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.3800.97".into(),
            major_version: 145,
            ua_platform: "Linux",
            seed: 0x6666_6666_ffff_0006,
        },
        IdentityProfile {
            family: BrowserFamily::Firefox,
            platform: Platform::Windows,
            user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0".into(),
            major_version: 134,
            ua_platform: "Windows",
            seed: 0x7777_7777_aaaa_0007,
        },
        IdentityProfile {
            family: BrowserFamily::Firefox,
            platform: Platform::MacOS,
            user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.6; rv:134.0) Gecko/20100101 Firefox/134.0".into(),
            major_version: 134,
            ua_platform: "macOS",
            seed: 0x8888_8888_bbbb_0008,
        },
        IdentityProfile {
            family: BrowserFamily::Firefox,
            platform: Platform::Linux,
            user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0".into(),
            major_version: 134,
            ua_platform: "Linux",
            seed: 0x9999_9999_cccc_0009,
        },
        IdentityProfile {
            family: BrowserFamily::Safari,
            platform: Platform::Windows,
            user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15".into(),
            major_version: 17,
            ua_platform: "Windows",
            seed: 0xaaaa_aaaa_dddd_000a,
        },
        IdentityProfile {
            family: BrowserFamily::Safari,
            platform: Platform::MacOS,
            user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15".into(),
            major_version: 17,
            ua_platform: "macOS",
            seed: 0xbbbb_bbbb_eeee_000b,
        },
        IdentityProfile {
            family: BrowserFamily::Safari,
            platform: Platform::Linux,
            user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15".into(),
            major_version: 17,
            ua_platform: "Linux",
            seed: 0xcccc_cccc_ffff_000c,
        },
    ]
}

/// Probe result reported by the `--probe` flag of the CLI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeReport {
    /// Endpoint that was probed (`"html"` or `"lite"`).
    pub endpoint: String,
    /// HTTP status returned by DDG.
    pub status: u16,
    /// Round-trip latency in milliseconds.
    pub latency_ms: u64,
    /// Whether the response carried a `Set-Cookie` header.
    pub has_set_cookie: bool,
    /// Identity tag used for the probe.
    pub identity: String,
}

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

    #[test]
    fn pool_has_twelve_identities() {
        let pool = IdentityPool::new(Some(42));
        // 12 entries are loaded from the catalog; we just check that the
        // cursor returns one of them.
        let _ = pool.current();
    }

    #[test]
    fn rotation_advances_cascade_level() {
        let mut pool = IdentityPool::new(Some(42));
        assert_eq!(pool.level(), 0);
        let first = pool.active_tag();
        pool.rotate_on_block();
        assert_eq!(pool.level(), 1);
        // After 1st rotation, identity changed.
        assert_ne!(pool.active_tag(), first);
    }

    #[test]
    fn deterministic_seed_produces_same_sequence() {
        let mut a = IdentityPool::new(Some(99));
        let mut b = IdentityPool::new(Some(99));
        for _ in 0..3 {
            let ta = a.active_tag();
            let tb = b.active_tag();
            assert_eq!(ta, tb, "deterministic seed must produce same tag");
            a.rotate_on_block();
            b.rotate_on_block();
        }
    }

    #[test]
    fn shuffled_headers_include_family_specific_values() {
        let pool = IdentityPool::new(Some(7));
        let headers = pool.current().shuffled_headers("pt", "br");
        let names: Vec<&str> = headers.iter().map(|(n, _)| *n).collect();
        assert!(names.contains(&"accept"));
        assert!(names.contains(&"accept-language"));
        assert!(names.contains(&"sec-fetch-dest"));
        // Chrome/Edge identities emit Sec-CH-UA.
        if matches!(
            pool.current().family,
            BrowserFamily::Chrome | BrowserFamily::Edge
        ) {
            assert!(names.contains(&"sec-ch-ua"));
            assert!(names.contains(&"sec-ch-ua-platform"));
        }
    }

    #[test]
    fn tag_format_is_stable() {
        let pool = IdentityPool::new(Some(1));
        let tag = pool.active_tag();
        // Format: <family>-<platform>-<16hex>
        let parts: Vec<&str> = tag.split('-').collect();
        assert_eq!(parts.len(), 3, "tag must have 3 parts: {tag}");
        assert_eq!(parts[2].len(), 16, "seed part must be 16 hex chars: {tag}");
    }
}
