use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashSet};
use url::Url;

pub const MAX_DISCOVERED_SUBRESOURCES: usize = 512;
pub const DEFAULT_MAX_BLOCKED_HOSTS: usize = 2_048;
pub const DEFAULT_MAX_SITE_ALLOWLIST: usize = 512;
const TRACKING_PARAM_NAMES: &[&str] = &[
    "fbclid",
    "gclid",
    "gclsrc",
    "dclid",
    "gbraid",
    "wbraid",
    "msclkid",
    "mc_cid",
    "mc_eid",
    "igshid",
    "yclid",
    "ttclid",
    "twclid",
    "rdt_cid",
    "epik",
    "srsltid",
    "irclickid",
    "rb_clickid",
    "_hsenc",
    "_hsmi",
    "mkt_tok",
    "vero_id",
    "spm",
    "sc_campaign",
    "sc_channel",
    "sc_content",
    "sc_medium",
    "sc_outcome",
    "sc_geo",
    "pk_campaign",
    "pk_kwd",
    "piwik_campaign",
    "piwik_kwd",
    "wickedid",
    "li_fat_id",
    "trk",
    "trkemail",
    "zanpid",
    "ref_src",
    "ref_url",
    "ncid",
    "soc_src",
    "soc_trk",
];
const SHARE_GLOBAL_TRACKING_PARAM_NAMES: &[&str] = &[
    "_gl",
    "li_fat_id",
    "ymid",
    "var",
    "s_cid",
    "wt_mc",
    "fbclid",
    "gclid",
    "gclsrc",
    "dclid",
    "gbraid",
    "wbraid",
    "msclkid",
    "mc_cid",
    "mc_eid",
    "igshid",
    "yclid",
    "ttclid",
    "twclid",
    "rdt_cid",
    "epik",
    "srsltid",
    "irclickid",
    "rb_clickid",
    "_hsenc",
    "_hsmi",
    "mkt_tok",
    "spm",
];

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SearchProvider {
    #[default]
    Google,
    #[serde(rename = "duckduckgo")]
    DuckDuckGo,
    Brave,
    Startpage,
}

impl SearchProvider {
    pub const ALL: [Self; 4] = [Self::Google, Self::DuckDuckGo, Self::Brave, Self::Startpage];

    pub fn id(&self) -> &'static str {
        match self {
            Self::Google => "google",
            Self::DuckDuckGo => "duckduckgo",
            Self::Brave => "brave",
            Self::Startpage => "startpage",
        }
    }

    pub fn label(&self) -> &'static str {
        match self {
            Self::Google => "Google",
            Self::DuckDuckGo => "DuckDuckGo",
            Self::Brave => "Brave Search",
            Self::Startpage => "Startpage",
        }
    }

    pub fn search_action(&self) -> &'static str {
        match self {
            Self::Google => "https://www.google.com/search",
            Self::DuckDuckGo => "https://duckduckgo.com/",
            Self::Brave => "https://search.brave.com/search",
            Self::Startpage => "https://www.startpage.com/sp/search",
        }
    }

    pub fn query_parameter(&self) -> &'static str {
        match self {
            Self::Startpage => "query",
            _ => "q",
        }
    }

    pub fn search_url(&self, query: &str) -> anyhow::Result<Url> {
        let mut url = Url::parse(self.search_action())?;
        url.query_pairs_mut()
            .append_pair(self.query_parameter(), query);
        Ok(url)
    }

    pub fn from_id(id: &str) -> Option<Self> {
        Self::ALL.into_iter().find(|provider| provider.id() == id)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shields {
    pub enabled: bool,
    pub https_upgrade: bool,
    pub global_privacy_control: bool,
    pub do_not_track: bool,
    pub strip_tracking_queries: bool,
    pub debounce_redirects: bool,
    pub block_trackers: bool,
    pub de_amp: bool,
    pub blocked_hosts: BTreeSet<String>,
    pub site_allowlist: BTreeSet<String>,
}

impl Default for Shields {
    fn default() -> Self {
        Self {
            enabled: true,
            https_upgrade: true,
            global_privacy_control: true,
            do_not_track: true,
            strip_tracking_queries: true,
            debounce_redirects: true,
            block_trackers: true,
            de_amp: true,
            blocked_hosts: built_in_blocked_hosts(),
            site_allowlist: BTreeSet::new(),
        }
    }
}

#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct PrivacyReport {
    pub original_url: String,
    pub final_url: String,
    pub upgraded_to_https: bool,
    pub stripped_query_params: Vec<String>,
    pub debounced_redirect: Option<String>,
    pub blocked_requests: Vec<String>,
    pub de_amped_to: Option<String>,
}

impl PrivacyReport {
    pub fn has_actions(&self) -> bool {
        self.upgraded_to_https
            || !self.stripped_query_params.is_empty()
            || self.debounced_redirect.is_some()
            || !self.blocked_requests.is_empty()
            || self.de_amped_to.is_some()
    }
}

impl Shields {
    pub fn is_enabled_for(&self, url: &Url) -> bool {
        self.enabled
            && is_web_url(url)
            && url
                .host_str()
                .map(|host| !self.site_allowlist.contains(&canonical_host(host)))
                .unwrap_or(false)
    }

    pub fn prepare_navigation(&self, input: &str) -> anyhow::Result<(Url, PrivacyReport)> {
        self.prepare_navigation_with_search(input, SearchProvider::default())
    }

    pub fn prepare_navigation_with_search(
        &self,
        input: &str,
        search_provider: SearchProvider,
    ) -> anyhow::Result<(Url, PrivacyReport)> {
        let mut url = parse_navigation_input_with_search(input, search_provider)?;
        let mut report = PrivacyReport {
            original_url: input.to_string(),
            final_url: url.to_string(),
            ..PrivacyReport::default()
        };

        if !self.is_enabled_for(&url) {
            report.final_url = url.to_string();
            return Ok((url, report));
        }

        if self.debounce_redirects
            && let Some(destination) = debounced_destination(&url)
        {
            report.debounced_redirect = Some(destination.to_string());
            url = destination;
        }

        if self.https_upgrade && url.scheme() == "http" && !is_local_host(&url) {
            let _ = url.set_scheme("https");
            report.upgraded_to_https = true;
        }

        if self.strip_tracking_queries {
            let removed = strip_tracking_params(&mut url);
            report.stripped_query_params = removed;
        }

        report.final_url = url.to_string();
        Ok((url, report))
    }

    pub fn clean_share_url(&self, input: &str) -> anyhow::Result<Url> {
        let (mut url, _) = self.prepare_navigation(input)?;
        if self.strip_tracking_queries && self.is_enabled_for(&url) {
            strip_share_tracking_params(&mut url);
        }
        Ok(url)
    }

    pub fn request_headers(&self, url: &Url) -> Vec<(&'static str, &'static str)> {
        if !self.is_enabled_for(url) {
            return Vec::new();
        }

        let mut headers = vec![
            (
                "Accept",
                "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            ),
            ("Accept-Language", "en-US,en;q=0.9"),
        ];

        headers.extend(self.privacy_headers(url));
        headers
    }

    pub fn privacy_headers(&self, url: &Url) -> Vec<(&'static str, &'static str)> {
        if !self.is_enabled_for(url) {
            return Vec::new();
        }

        let mut headers = Vec::new();

        if self.https_upgrade {
            headers.push(("Upgrade-Insecure-Requests", "1"));
        }

        if self.global_privacy_control {
            headers.push(("Sec-GPC", "1"));
        }

        if self.do_not_track {
            headers.push(("DNT", "1"));
        }

        headers
    }

    pub fn should_block_request(&self, page_url: &Url, request_url: &Url) -> bool {
        if !self.block_trackers || !self.is_enabled_for(page_url) || !is_web_url(request_url) {
            return false;
        }

        let Some(host) = request_url.host_str().map(canonical_host) else {
            return false;
        };

        self.blocked_hosts
            .iter()
            .any(|blocked| blocked_host_matches(&host, blocked))
    }

    pub fn allow_site(&mut self, url: &Url) {
        if is_web_url(url)
            && let Some(host) = url.host_str()
        {
            self.site_allowlist.insert(canonical_host(host));
            self.enforce_limits();
        }
    }

    pub fn protect_site(&mut self, url: &Url) {
        if is_web_url(url)
            && let Some(host) = url.host_str()
        {
            self.site_allowlist.remove(&canonical_host(host));
        }
    }

    pub fn enforce_limits(&mut self) {
        normalize_blocked_hosts(&mut self.blocked_hosts);
        normalize_host_set(&mut self.site_allowlist, DEFAULT_MAX_SITE_ALLOWLIST);
    }
}

fn normalize_blocked_hosts(values: &mut BTreeSet<String>) {
    let required = built_in_blocked_hosts();
    let custom_max = DEFAULT_MAX_BLOCKED_HOSTS.saturating_sub(required.len());
    let mut normalized = values
        .iter()
        .filter_map(|host| normalize_host_rule(host))
        .filter(|host| !required.contains(host))
        .collect::<BTreeSet<_>>();
    prune_btree_set(&mut normalized, custom_max);
    normalized.extend(required);
    *values = normalized;
}

fn normalize_host_set(values: &mut BTreeSet<String>, max_values: usize) {
    *values = values
        .iter()
        .filter_map(|host| normalize_host_rule(host))
        .collect();
    prune_btree_set(values, max_values);
}

fn prune_btree_set(values: &mut BTreeSet<String>, max_values: usize) {
    let max_values = max_values.max(1);
    while values.len() > max_values {
        let Some(value) = values.first().cloned() else {
            break;
        };
        values.remove(&value);
    }
}

pub fn parse_navigation_input(input: &str) -> anyhow::Result<Url> {
    parse_navigation_input_with_search(input, SearchProvider::default())
}

pub fn parse_navigation_input_with_search(
    input: &str,
    search_provider: SearchProvider,
) -> anyhow::Result<Url> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        anyhow::bail!("empty navigation input");
    }

    if let Ok(url) = Url::parse(trimmed) {
        if matches!(url.scheme(), "http" | "https" | "cephas") || url.as_str() == "about:blank" {
            return Ok(url);
        }
        anyhow::bail!("unsupported URL scheme: {}", url.scheme());
    }

    if looks_like_domain(trimmed) {
        return Ok(Url::parse(&format!("https://{trimmed}"))?);
    }

    search_provider.search_url(trimmed)
}

pub fn strip_tracking_params(url: &mut Url) -> Vec<String> {
    let Some(query) = url.query().map(ToString::to_string) else {
        return Vec::new();
    };

    let original_pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
    let mut removed = Vec::new();
    let kept: Vec<(String, String)> = original_pairs
        .into_iter()
        .filter_map(|(key, value)| {
            let lower = key.to_ascii_lowercase();
            let is_tracker = lower.starts_with("utm_")
                || TRACKING_PARAM_NAMES.contains(&lower.as_str())
                || lower.starts_with("vero_")
                || lower.starts_with("oly_");

            if is_tracker {
                removed.push(key);
                None
            } else {
                Some((key, value))
            }
        })
        .collect();

    if removed.is_empty() {
        return removed;
    }

    url.set_query(None);
    if !kept.is_empty() {
        let mut pairs = url.query_pairs_mut();
        for (key, value) in kept {
            pairs.append_pair(&key, &value);
        }
    }

    if query.is_empty() {
        url.set_query(Some(""));
    }

    removed.sort();
    removed.dedup();
    removed
}

pub fn strip_share_tracking_params(url: &mut Url) -> Vec<String> {
    strip_share_tracking_params_inner(url, 0)
}

fn strip_share_tracking_params_inner(url: &mut Url, depth: usize) -> Vec<String> {
    let Some(query) = url.query().map(ToString::to_string) else {
        return Vec::new();
    };

    let host = url.host_str().map(canonical_host);
    let original_pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
    let mut removed = Vec::new();
    let mut kept = Vec::new();

    for (key, value) in original_pairs {
        let lower = key.to_ascii_lowercase();
        if is_share_tracking_param(host.as_deref(), &lower) {
            removed.push(key);
            continue;
        }

        let (value, nested_removed) = strip_nested_share_url_value(&key, value, depth);
        removed.extend(nested_removed);
        kept.push((key, value));
    }

    if removed.is_empty() {
        return removed;
    }

    url.set_query(None);
    if !kept.is_empty() {
        let mut pairs = url.query_pairs_mut();
        for (key, value) in kept {
            pairs.append_pair(&key, &value);
        }
    }

    if query.is_empty() {
        url.set_query(Some(""));
    }

    removed.sort();
    removed.dedup();
    removed
}

fn strip_nested_share_url_value(key: &str, value: String, depth: usize) -> (String, Vec<String>) {
    if depth >= 3 {
        return (value, Vec::new());
    }
    let Ok(mut nested) = Url::parse(&value) else {
        return (value, Vec::new());
    };
    if !matches!(nested.scheme(), "http" | "https") {
        return (value, Vec::new());
    }

    let removed = strip_share_tracking_params_inner(&mut nested, depth + 1)
        .into_iter()
        .map(|param| format!("{key}.{param}"))
        .collect::<Vec<_>>();
    if removed.is_empty() {
        (value, removed)
    } else {
        (nested.to_string(), removed)
    }
}

pub fn debounced_destination(url: &Url) -> Option<Url> {
    let host = url.host_str()?.to_ascii_lowercase();
    let redirector_hosts = [
        "t.co",
        "l.facebook.com",
        "lm.facebook.com",
        "out.reddit.com",
        "www.google.com",
        "google.com",
        "duckduckgo.com",
        "l.instagram.com",
        "href.li",
        "link.zhihu.com",
    ];

    if !redirector_hosts
        .iter()
        .any(|candidate| host == *candidate || host.ends_with(&format!(".{candidate}")))
    {
        return None;
    }

    let keys = [
        "url",
        "u",
        "target",
        "to",
        "dest",
        "destination",
        "redirect",
        "redirect_url",
    ];
    for (key, value) in url.query_pairs() {
        if keys
            .iter()
            .any(|candidate| key.eq_ignore_ascii_case(candidate))
            && let Ok(destination) = Url::parse(&value)
            && (destination.scheme() == "http" || destination.scheme() == "https")
        {
            return Some(destination);
        }
    }

    None
}

pub fn discover_subresources(html: &str, base: &Url) -> Vec<Url> {
    let re = Regex::new(r#"(?is)(?:src|href)\s*=\s*["']([^"'#\s][^"']*)["']"#)
        .expect("valid subresource regex");

    let mut seen = HashSet::new();
    let mut urls = Vec::new();
    for captures in re.captures_iter(html) {
        if urls.len() >= MAX_DISCOVERED_SUBRESOURCES {
            break;
        }

        let Some(raw) = captures
            .get(1)
            .map(|m| html_escape::decode_html_entities(m.as_str()))
        else {
            continue;
        };

        if raw.starts_with("data:")
            || raw.starts_with("blob:")
            || raw.starts_with("mailto:")
            || raw.starts_with("tel:")
            || raw.starts_with("javascript:")
        {
            continue;
        }

        if let Ok(url) = base.join(&raw)
            && seen.insert(url.to_string())
        {
            urls.push(url);
        }
    }

    urls
}

fn looks_like_domain(input: &str) -> bool {
    let without_path = input.split('/').next().unwrap_or(input);
    without_path.contains('.') && !without_path.contains(' ') && !without_path.contains('\t')
}

fn is_local_host(url: &Url) -> bool {
    matches!(
        url.host_str(),
        Some("localhost") | Some("127.0.0.1") | Some("::1")
    )
}

fn is_web_url(url: &Url) -> bool {
    matches!(url.scheme(), "http" | "https")
}

fn canonical_host(host: &str) -> String {
    host.trim_end_matches('.').to_ascii_lowercase()
}

fn normalize_host_rule(input: &str) -> Option<String> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return None;
    }

    if let Ok(url) = Url::parse(trimmed) {
        return url.host_str().map(canonical_host);
    }

    let without_path = trimmed
        .split(['/', '?', '#'])
        .next()
        .unwrap_or(trimmed)
        .trim();
    let without_wildcard = without_path
        .strip_prefix("*.")
        .unwrap_or(without_path)
        .trim_start_matches('.');
    if without_wildcard.is_empty() || without_wildcard.chars().any(char::is_whitespace) {
        return None;
    }

    Url::parse(&format!("https://{without_wildcard}"))
        .ok()
        .and_then(|url| url.host_str().map(canonical_host))
}

fn blocked_host_matches(host: &str, rule: &str) -> bool {
    let Some(rule) = normalize_host_rule(rule) else {
        return false;
    };
    host == rule
        || host
            .strip_suffix(&rule)
            .is_some_and(|prefix| prefix.ends_with('.'))
}

fn is_share_tracking_param(host: Option<&str>, lower: &str) -> bool {
    lower.starts_with("utm_")
        || lower.starts_with("hsa_")
        || lower.starts_with("vero_")
        || lower.starts_with("oly_")
        || SHARE_GLOBAL_TRACKING_PARAM_NAMES.contains(&lower)
        || host
            .map(|host| share_site_tracking_param_names(host).contains(&lower))
            .unwrap_or(false)
}

fn share_site_tracking_param_names(host: &str) -> &'static [&'static str] {
    match host {
        "twitter.com" | "www.twitter.com" | "x.com" => &["ref_src", "ref_url"],
        "www.instagram.com" => &["igshid", "ig_rid"],
        host if AMAZON_SHARE_HOSTS.contains(&host) => &[
            "__mk_de_de",
            "aaxitk",
            "content-id",
            "creativeid",
            "crid",
            "dib",
            "dib_tag",
            "hsa_cr_id",
            "ipredirectfrom",
            "ipredirectoriginalurl",
            "keywords",
            "lp_asins",
            "lp_query",
            "lp_slot",
            "overridebasecountry",
            "pageloadid",
            "pd_rd_plhdr",
            "pd_rd_r",
            "pd_rd_w",
            "pd_rd_wg",
            "pf_rd_r",
            "pf_rd_p",
            "plink",
            "psc",
            "ref",
            "ref_",
            "ref_pageloadid",
            "sbo",
            "social_share",
            "source_code",
            "sprefix",
            "s_prefix",
            "sp_csd",
            "sr",
            "store_ref",
        ],
        "www.handelsblatt.com" => &["share"],
        host if SHOPEE_SHARE_HOSTS.contains(&host) => &["sp_atk", "xptdk"],
        "www.tokopedia.com" => &["extparam"],
        "open.spotify.com" => &["si"],
        "www.meetup.com" => &["eventorigin", "recid", "recsource", "searchid"],
        "www.facebook.com" => &["__cft__[0]", "__tn__"],
        host if WIKIMEDIA_SHARE_HOSTS.contains(&host) => &["wprov"],
        "www.bilibili.com" | "space.bilibili.com" | "live.bilibili.com" => {
            &["spm_id_from", "broadcast_type", "is_room_feed", "live_from"]
        }
        "www.humblebundle.com" => &["hmb_source", "hmb_medium", "hmb_campaign"],
        _ => &[],
    }
}

const AMAZON_SHARE_HOSTS: &[&str] = &[
    "www.amazon.com",
    "www.amazon.de",
    "www.amazon.nl",
    "www.amazon.fr",
    "www.amazon.co.jp",
    "www.amazon.in",
    "www.amazon.es",
    "www.amazon.ac",
    "www.amazon.cn",
    "www.amazon.eg",
    "www.amazon.co.uk",
    "www.amazon.it",
    "www.amazon.pl",
    "www.amazon.sg",
    "www.amazon.ca",
    "www.amazon.com.au",
    "www.amazon.com.br",
    "www.amazon.com.mx",
    "www.amazon.com.be",
    "www.amazon.com.tr",
    "www.amazon.sa",
    "www.amazon.ae",
    "www.audible.com",
    "www.audible.co.uk",
    "www.audible.de",
    "www.audible.fr",
    "www.audible.it",
];

const SHOPEE_SHARE_HOSTS: &[&str] = &[
    "shopee.co.id",
    "shopee.tw",
    "shopee.vn",
    "shopee.co.vn",
    "shopee.ph",
    "shopee.com.my",
    "shopee.sg",
    "shopee.com.br",
    "shopee.com.mx",
    "shopee.com.co",
    "shopee.cl",
];

const WIKIMEDIA_SHARE_HOSTS: &[&str] = &[
    "en.wikipedia.org",
    "ja.wikipedia.org",
    "de.wikipedia.org",
    "ru.wikipedia.org",
    "es.wikipedia.org",
    "fr.wikipedia.org",
    "zh.wikipedia.org",
    "it.wikipedia.org",
    "fa.wikipedia.org",
    "pt.wikipedia.org",
    "www.mediawiki.org",
    "w.wiki",
    "www.wikidata.org",
    "test.wikidata.org",
    "www.wikifunctions.org",
    "meta.wikimedia.org",
    "commons.wikimedia.org",
];

fn built_in_blocked_hosts() -> BTreeSet<String> {
    [
        "doubleclick.net",
        "googleadservices.com",
        "googlesyndication.com",
        "googletagmanager.com",
        "googletagservices.com",
        "google-analytics.com",
        "analytics.google.com",
        "facebook.net",
        "connect.facebook.net",
        "ads.twitter.com",
        "analytics.twitter.com",
        "scorecardresearch.com",
        "quantserve.com",
        "taboola.com",
        "outbrain.com",
        "criteo.com",
        "adsrvr.org",
        "adnxs.com",
        "rubiconproject.com",
        "pubmatic.com",
        "moatads.com",
        "hotjar.com",
        "segment.io",
        "mixpanel.com",
        "amplitude.com",
        "cdn.optimizely.com",
        "newrelic.com",
        "nr-data.net",
    ]
    .into_iter()
    .map(String::from)
    .collect()
}

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

    #[test]
    fn navigation_upgrades_and_strips_tracking() {
        let shields = Shields::default();
        let (url, report) = shields
            .prepare_navigation("http://example.com/?utm_source=x&a=1&fbclid=abc")
            .unwrap();

        assert_eq!(url.as_str(), "https://example.com/?a=1");
        assert!(report.upgraded_to_https);
        assert_eq!(report.stripped_query_params, vec!["fbclid", "utm_source"]);
    }

    #[test]
    fn navigation_strips_modern_click_identifiers() {
        let shields = Shields::default();
        let (url, report) = shields
            .prepare_navigation("https://example.com/?id=7&ttclid=a&rdt_cid=b&epik=c&srsltid=d")
            .unwrap();

        assert_eq!(url.as_str(), "https://example.com/?id=7");
        assert_eq!(
            report.stripped_query_params,
            vec!["epik", "rdt_cid", "srsltid", "ttclid"]
        );
    }

    #[test]
    fn navigation_debounces_redirectors() {
        let shields = Shields::default();
        let (url, report) = shields
            .prepare_navigation("https://l.facebook.com/l.php?u=https%3A%2F%2Fexample.com%2Fstory%3Fgclid%3D1%26id%3D7")
            .unwrap();

        assert_eq!(url.as_str(), "https://example.com/story?id=7");
        assert_eq!(
            report.debounced_redirect.as_deref(),
            Some("https://example.com/story?gclid=1&id=7")
        );
    }

    #[test]
    fn clean_share_url_uses_firefox_global_strip_on_share_params() {
        let shields = Shields::default();
        let url = shields
            .clean_share_url("https://example.com/?_gl=1&utm_source=x&id=7")
            .unwrap();

        assert_eq!(url.as_str(), "https://example.com/?id=7");
    }

    #[test]
    fn clean_share_url_uses_site_specific_share_rules_without_changing_navigation() {
        let shields = Shields::default();
        let input = "https://www.amazon.com/dp/example?ref=share&psc=1&tag=keep&id=7";
        let (navigation, report) = shields.prepare_navigation(input).unwrap();
        let clean = shields.clean_share_url(input).unwrap();

        assert_eq!(navigation.as_str(), input);
        assert!(report.stripped_query_params.is_empty());
        assert_eq!(
            clean.as_str(),
            "https://www.amazon.com/dp/example?tag=keep&id=7"
        );
    }

    #[test]
    fn clean_share_url_strips_nested_url_values() {
        let shields = Shields::default();
        let clean = shields
            .clean_share_url("https://example.com/share?target=https%3A%2F%2Fdest.example%2F%3F_gl%3D1%26id%3D7&utm_source=x")
            .unwrap();

        assert_eq!(
            clean.as_str(),
            "https://example.com/share?target=https%3A%2F%2Fdest.example%2F%3Fid%3D7"
        );
    }

    #[test]
    fn search_provider_ids_are_unique_and_resolvable() {
        let mut ids = BTreeSet::new();
        for provider in SearchProvider::ALL {
            assert!(ids.insert(provider.id()));
            assert_eq!(SearchProvider::from_id(provider.id()), Some(provider));
            assert_eq!(
                serde_json::to_value(provider).unwrap().as_str(),
                Some(provider.id())
            );
            assert_eq!(
                serde_json::from_value::<SearchProvider>(serde_json::json!(provider.id())).unwrap(),
                provider
            );
            assert!(!provider.label().trim().is_empty());
        }
    }

    #[test]
    fn search_providers_build_provider_specific_urls() {
        assert_eq!(
            parse_navigation_input_with_search("rust webkit", SearchProvider::Google)
                .unwrap()
                .as_str(),
            "https://www.google.com/search?q=rust+webkit"
        );
        assert_eq!(
            parse_navigation_input_with_search("rust webkit", SearchProvider::DuckDuckGo)
                .unwrap()
                .as_str(),
            "https://duckduckgo.com/?q=rust+webkit"
        );
        assert_eq!(
            parse_navigation_input_with_search("rust webkit", SearchProvider::Brave)
                .unwrap()
                .as_str(),
            "https://search.brave.com/search?q=rust+webkit"
        );
        assert_eq!(
            parse_navigation_input_with_search("rust webkit", SearchProvider::Startpage)
                .unwrap()
                .as_str(),
            "https://www.startpage.com/sp/search?query=rust+webkit"
        );
    }

    #[test]
    fn blocks_tracker_subdomains() {
        let shields = Shields::default();
        let page = Url::parse("https://news.example/").unwrap();
        let tracker = Url::parse("https://stats.google-analytics.com/script.js").unwrap();

        assert!(shields.should_block_request(&page, &tracker));
    }

    #[test]
    fn armour_host_rules_normalize_and_match_only_host_boundaries() {
        let mut shields = Shields {
            blocked_hosts: BTreeSet::from([
                " HTTPS://TRACKER.EXAMPLE./pixel ".to_string(),
                "*.Ads.Example".to_string(),
                "bad host".to_string(),
            ]),
            ..Shields::default()
        };
        shields.enforce_limits();

        assert!(shields.blocked_hosts.contains("tracker.example"));
        assert!(shields.blocked_hosts.contains("ads.example"));
        assert!(!shields.blocked_hosts.contains("bad host"));

        let page = Url::parse("https://news.example/").unwrap();
        assert!(shields.should_block_request(
            &page,
            &Url::parse("https://cdn.tracker.example/pixel.js").unwrap()
        ));
        assert!(shields.should_block_request(
            &page,
            &Url::parse("https://a.ads.example/script.js").unwrap()
        ));
        assert!(!shields.should_block_request(
            &page,
            &Url::parse("https://nottracker.example/pixel.js").unwrap()
        ));
    }

    #[test]
    fn armour_is_scoped_to_web_origins_and_site_allowlist() {
        let mut shields = Shields::default();
        let web = Url::parse("https://Example.COM./article").unwrap();
        let internal = Url::parse("cephas://settings").unwrap();
        let tracker = Url::parse("https://stats.google-analytics.com/script.js").unwrap();

        assert!(shields.is_enabled_for(&web));
        assert!(!shields.is_enabled_for(&internal));
        assert!(shields.should_block_request(&web, &tracker));

        shields.allow_site(&internal);
        assert!(shields.site_allowlist.is_empty());

        shields.allow_site(&web);
        assert!(shields.site_allowlist.contains("example.com"));
        assert!(!shields.is_enabled_for(&web));
        assert!(!shields.should_block_request(&web, &tracker));

        shields.protect_site(&web);
        assert!(shields.is_enabled_for(&web));
    }

    #[test]
    fn armour_request_headers_follow_feature_toggles() {
        let url = Url::parse("https://example.com/").unwrap();
        let mut shields = Shields::default();

        let headers = shields.request_headers(&url);
        assert!(headers.contains(&("Upgrade-Insecure-Requests", "1")));
        assert!(headers.contains(&("Sec-GPC", "1")));
        assert!(headers.contains(&("DNT", "1")));

        shields.https_upgrade = false;
        shields.global_privacy_control = false;
        shields.do_not_track = false;
        let headers = shields.request_headers(&url);
        assert!(!headers.contains(&("Upgrade-Insecure-Requests", "1")));
        assert!(!headers.contains(&("Sec-GPC", "1")));
        assert!(!headers.contains(&("DNT", "1")));
        assert!(headers.iter().any(|(name, _)| *name == "Accept"));

        shields.allow_site(&url);
        assert!(shields.request_headers(&url).is_empty());
    }

    #[test]
    fn armour_enforce_limits_preserves_built_in_tracker_rules() {
        let page = Url::parse("https://news.example/").unwrap();
        let tracker = Url::parse("https://stats.google-analytics.com/script.js").unwrap();
        let mut shields = Shields {
            blocked_hosts: BTreeSet::new(),
            ..Shields::default()
        };

        shields.enforce_limits();

        assert!(shields.blocked_hosts.contains("google-analytics.com"));
        assert!(shields.should_block_request(&page, &tracker));

        for index in 0..(DEFAULT_MAX_BLOCKED_HOSTS + 20) {
            shields
                .blocked_hosts
                .insert(format!("custom-{index:04}.example"));
        }
        shields.enforce_limits();

        assert_eq!(shields.blocked_hosts.len(), DEFAULT_MAX_BLOCKED_HOSTS);
        assert!(shields.blocked_hosts.contains("google-analytics.com"));
        assert!(shields.should_block_request(&page, &tracker));
    }

    #[test]
    fn search_terms_become_search_url() {
        let url = parse_navigation_input("rust webview browser").unwrap();

        assert_eq!(url.host_str(), Some("www.google.com"));
        assert_eq!(
            url.query_pairs().find(|(k, _)| k == "q").unwrap().1,
            "rust webview browser"
        );
    }

    #[test]
    fn navigation_rejects_unsafe_non_web_schemes() {
        assert!(parse_navigation_input("file:///etc/passwd").is_err());
        assert!(parse_navigation_input("data:text/html,hello").is_err());
        assert!(parse_navigation_input("javascript:alert(1)").is_err());
        assert_eq!(
            parse_navigation_input("about:blank").unwrap().as_str(),
            "about:blank"
        );
        assert_eq!(
            parse_navigation_input("cephas://settings")
                .unwrap()
                .as_str(),
            "cephas://settings"
        );
    }
}
