use std::collections::{HashMap, HashSet};

use regex::Regex;
use serde::{Deserialize, Serialize};
use url::{Url, form_urlencoded::byte_serialize};

use crate::error::{DdgsError, Result};
use crate::html::{clean_html, first_capture};
use crate::options::{SafeSearch, TextOptions};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextResult {
    pub title: String,
    pub href: String,
    pub body: String,
}

#[derive(Debug, Clone)]
struct RankedTextResult {
    result: TextResult,
    count: usize,
}

#[derive(Debug, Deserialize)]
struct ApiResponse {
    #[serde(rename = "Results", default)]
    results: Vec<ApiTopic>,
    #[serde(rename = "RelatedTopics", default)]
    related_topics: Vec<ApiTopic>,
}

#[derive(Debug, Deserialize)]
struct ApiTopic {
    #[serde(rename = "FirstURL")]
    first_url: Option<String>,
    #[serde(rename = "Text")]
    text: Option<String>,
    #[serde(rename = "Result")]
    result: Option<String>,
    #[serde(rename = "Topics", default)]
    topics: Vec<ApiTopic>,
}

#[derive(Debug, Deserialize)]
struct GrokipediaResponse {
    #[serde(default)]
    results: Vec<GrokipediaItem>,
}

#[derive(Debug, Deserialize)]
struct GrokipediaItem {
    title: String,
    slug: String,
    #[serde(default)]
    snippet: String,
}

#[derive(Debug, Deserialize)]
struct WikipediaQuery {
    query: WikipediaPages,
}

#[derive(Debug, Deserialize)]
struct WikipediaPages {
    pages: HashMap<String, WikipediaPage>,
}

#[derive(Debug, Deserialize)]
struct WikipediaPage {
    #[serde(default)]
    extract: String,
}

pub async fn search_api(
    http: &reqwest::Client,
    query: &str,
    options: &TextOptions,
) -> Result<Vec<TextResult>> {
    let page = options.page.max(1);
    let offset = (page - 1) * options.max_results.unwrap_or(30);
    let response = http
        .get("https://api.duckduckgo.com/")
        .query(&[
            ("q", query),
            ("format", "json"),
            ("no_html", "1"),
            ("no_redirect", "1"),
            ("skip_disambig", "1"),
            ("kl", options.region.as_str()),
            ("kp", options.safesearch.as_str()),
            ("s", &offset.to_string()),
        ])
        .send()
        .await?
        .error_for_status()?
        .json::<ApiResponse>()
        .await?;

    let mut results = Vec::new();
    collect_topics(&response.results, &mut results);
    collect_topics(&response.related_topics, &mut results);

    if let Some(max_results) = options.max_results {
        results.truncate(max_results);
    }

    Ok(results)
}

pub async fn search_auto(
    http: &reqwest::Client,
    query: &str,
    options: &TextOptions,
) -> Result<Vec<TextResult>> {
    let mut ranked = HashMap::<String, RankedTextResult>::new();
    let mut last_error = None;

    if let Err(error) = merge_search(&mut ranked, search_wikipedia(http, query, options).await) {
        last_error = Some(error);
    }
    if should_continue_auto(options, &ranked) {
        if let Err(error) = merge_search(&mut ranked, search_grokipedia(http, query, options).await)
        {
            last_error = Some(error);
        }
    }
    if should_continue_auto(options, &ranked) {
        if let Err(error) = merge_search(&mut ranked, search_yahoo(http, query, options).await) {
            last_error = Some(error);
        }
    }
    if should_continue_auto(options, &ranked) {
        if let Err(error) = merge_search(&mut ranked, search_startpage(http, query, options).await)
        {
            last_error = Some(error);
        }
    }
    if should_continue_auto(options, &ranked) {
        if let Err(error) = merge_search(&mut ranked, search_brave(http, query, options).await) {
            last_error = Some(error);
        }
    }
    if should_continue_auto(options, &ranked) {
        if let Err(error) = merge_search(&mut ranked, search_google(http, query, options).await) {
            last_error = Some(error);
        }
    }
    if should_continue_auto(options, &ranked) {
        if let Err(error) = merge_search(&mut ranked, search_mojeek(http, query, options).await) {
            last_error = Some(error);
        }
    }
    if should_continue_auto(options, &ranked) {
        if let Err(error) = merge_search(&mut ranked, search_yandex(http, query, options).await) {
            last_error = Some(error);
        }
    }
    if should_continue_auto(options, &ranked) {
        if let Err(error) = merge_search(&mut ranked, search_html(http, query, options).await) {
            last_error = Some(error);
        }
    }
    if should_continue_auto(options, &ranked) {
        if let Err(error) = merge_search(&mut ranked, search_lite(http, query, options).await) {
            last_error = Some(error);
        }
    }
    if should_continue_auto(options, &ranked) {
        if let Err(error) = merge_search(&mut ranked, search_api(http, query, options).await) {
            last_error = Some(error);
        }
    }

    let mut results = rank_results(ranked, query);
    if let Some(max_results) = options.max_results {
        results.truncate(max_results);
    }

    if results.is_empty() {
        return Err(
            last_error.unwrap_or_else(|| DdgsError::Parse("no text results found".to_string()))
        );
    }

    Ok(results)
}

pub async fn search_wikipedia(
    http: &reqwest::Client,
    query: &str,
    options: &TextOptions,
) -> Result<Vec<TextResult>> {
    let lang = options.region.as_str().split('-').nth(1).unwrap_or("en");
    let encoded_query = encode_query(query);
    let response = http
        .get(format!(
            "https://{lang}.wikipedia.org/w/api.php?action=opensearch&profile=fuzzy&limit=1&search={encoded_query}"
        ))
        .send()
        .await?
        .error_for_status()?
        .json::<serde_json::Value>()
        .await?;
    let Some(title) = response
        .get(1)
        .and_then(|value| value.get(0))
        .and_then(serde_json::Value::as_str)
    else {
        return Ok(Vec::new());
    };
    let Some(href) = response
        .get(3)
        .and_then(|value| value.get(0))
        .and_then(serde_json::Value::as_str)
    else {
        return Ok(Vec::new());
    };

    let body = fetch_wikipedia_extract(http, lang, title)
        .await
        .unwrap_or_default();
    if body.contains("may refer to:") {
        return Ok(Vec::new());
    }

    Ok(vec![TextResult {
        title: title.to_string(),
        href: href.to_string(),
        body: clean_html(&body),
    }])
}

async fn fetch_wikipedia_extract(
    http: &reqwest::Client,
    lang: &str,
    title: &str,
) -> Result<String> {
    let response = http
        .get(format!(
            "https://{lang}.wikipedia.org/w/api.php?action=query&format=json&prop=extracts&titles={}&explaintext=0&exintro=0&redirects=1",
            encode_query(title)
        ))
        .send()
        .await?
        .error_for_status()?
        .json::<WikipediaQuery>()
        .await?;

    Ok(response
        .query
        .pages
        .into_values()
        .next()
        .map(|page| page.extract)
        .unwrap_or_default())
}

pub async fn search_grokipedia(
    http: &reqwest::Client,
    query: &str,
    _options: &TextOptions,
) -> Result<Vec<TextResult>> {
    let response = http
        .get("https://grokipedia.com/api/typeahead")
        .query(&[("query", query), ("limit", "1")])
        .send()
        .await?
        .error_for_status()?
        .json::<GrokipediaResponse>()
        .await?;

    Ok(response
        .results
        .into_iter()
        .take(1)
        .map(|item| TextResult {
            title: item.title.trim_matches('_').to_string(),
            href: format!("https://grokipedia.com/page/{}", item.slug),
            body: item
                .snippet
                .split_once("\n\n")
                .map(|(_, body)| body)
                .unwrap_or(&item.snippet)
                .to_string(),
        })
        .collect())
}

pub async fn search_google(
    http: &reqwest::Client,
    query: &str,
    options: &TextOptions,
) -> Result<Vec<TextResult>> {
    let start = ((options.page.max(1) - 1) * 10).to_string();
    let mut request = http.get("https://www.google.com/search").query(&[
        ("q", query),
        ("filter", google_safesearch(options.safesearch)),
        ("start", &start),
    ]);

    let lang_region = google_region(options.region.as_str());
    request = request.query(&[
        ("hl", lang_region.hl.as_str()),
        ("lr", lang_region.lr.as_str()),
        ("cr", lang_region.cr.as_str()),
    ]);

    let timelimit;
    if let Some(value) = options.timelimit {
        timelimit = format!("qdr:{}", value.as_str());
        request = request.query(&[("tbs", timelimit.as_str())]);
    }

    let html = request.send().await?.error_for_status()?.text().await?;
    reject_blocked_response(&html)?;
    let mut results = parse_google_html(&html);
    truncate_results(&mut results, options.max_results);
    Ok(results)
}

pub async fn search_mojeek(
    http: &reqwest::Client,
    query: &str,
    options: &TextOptions,
) -> Result<Vec<TextResult>> {
    let mut request = http
        .get("https://www.mojeek.com/search")
        .query(&[("q", query)]);
    let start;
    if options.page > 1 {
        start = ((options.page - 1) * 10 + 1).to_string();
        request = request.query(&[("s", start.as_str())]);
    }
    if matches!(options.safesearch, SafeSearch::On) {
        request = request.query(&[("safe", "1")]);
    }

    let html = request.send().await?.error_for_status()?.text().await?;
    reject_blocked_response(&html)?;
    let mut results = parse_mojeek_html(&html);
    truncate_results(&mut results, options.max_results);
    Ok(results)
}

pub async fn search_yandex(
    http: &reqwest::Client,
    query: &str,
    options: &TextOptions,
) -> Result<Vec<TextResult>> {
    let searchid = "1000000";
    let mut request = http.get("https://yandex.com/search/site/").query(&[
        ("text", query),
        ("web", "1"),
        ("searchid", searchid),
    ]);
    let page;
    if options.page > 1 {
        page = (options.page - 1).to_string();
        request = request.query(&[("p", page.as_str())]);
    }

    let html = request.send().await?.error_for_status()?.text().await?;
    reject_blocked_response(&html)?;
    let mut results = parse_yandex_html(&html);
    truncate_results(&mut results, options.max_results);
    Ok(results)
}

fn merge_search(
    ranked: &mut HashMap<String, RankedTextResult>,
    result: Result<Vec<TextResult>>,
) -> Result<()> {
    merge_results(ranked, result?);
    Ok(())
}

fn should_continue_auto(options: &TextOptions, ranked: &HashMap<String, RankedTextResult>) -> bool {
    options.backend.as_str() == "all" || !enough_results(ranked, options.max_results)
}

pub async fn search_html(
    http: &reqwest::Client,
    query: &str,
    options: &TextOptions,
) -> Result<Vec<TextResult>> {
    let html = http
        .post("https://html.duckduckgo.com/html/")
        .form(&[
            ("q", query),
            ("b", ""),
            ("l", options.region.as_str()),
            ("s", &offset(options).to_string()),
        ])
        .send()
        .await?
        .error_for_status()?
        .text()
        .await?;

    reject_blocked_response(&html)?;
    let mut results = parse_text_html(&html);
    if let Some(max_results) = options.max_results {
        results.truncate(max_results);
    }

    Ok(results)
}

pub async fn search_lite(
    http: &reqwest::Client,
    query: &str,
    options: &TextOptions,
) -> Result<Vec<TextResult>> {
    let html = http
        .get("https://lite.duckduckgo.com/lite/")
        .query(&[
            ("q", query),
            ("l", options.region.as_str()),
            ("s", &offset(options).to_string()),
        ])
        .send()
        .await?
        .error_for_status()?
        .text()
        .await?;

    reject_blocked_response(&html)?;
    let mut results = parse_text_lite(&html);
    if let Some(max_results) = options.max_results {
        results.truncate(max_results);
    }

    Ok(results)
}

pub async fn search_yahoo(
    http: &reqwest::Client,
    query: &str,
    options: &TextOptions,
) -> Result<Vec<TextResult>> {
    let mut request = http
        .get("https://search.yahoo.com/search")
        .query(&[("p", query)]);

    let page_start;
    if options.page > 1 {
        page_start = ((options.page - 1) * 7 + 1).to_string();
        request = request.query(&[("b", page_start.as_str())]);
    }

    let timelimit;
    if let Some(value) = options.timelimit {
        timelimit = value.as_str();
        request = request.query(&[("btf", timelimit)]);
    }

    let html = request.send().await?.error_for_status()?.text().await?;
    reject_blocked_response(&html)?;
    let mut results = parse_yahoo_html(&html);
    truncate_results(&mut results, options.max_results);
    Ok(results)
}

pub async fn search_startpage(
    http: &reqwest::Client,
    query: &str,
    options: &TextOptions,
) -> Result<Vec<TextResult>> {
    let homepage = http
        .get("https://www.startpage.com/")
        .send()
        .await?
        .error_for_status()?
        .text()
        .await?;
    let sc = first_capture(
        &homepage,
        r#"(?is)<input[^>]+name="sc"[^>]+value="([^"]*)""#,
    )
    .unwrap_or_default();

    let mut form = vec![
        ("query", query.to_string()),
        ("cat", "web".to_string()),
        ("t", "device".to_string()),
        ("sc", sc),
        ("lui", "english".to_string()),
        ("language", "english".to_string()),
        ("abp", "1".to_string()),
        ("abd", "0".to_string()),
        ("abe", "0".to_string()),
        ("qsr", startpage_region(options.region.as_str())),
        ("qadf", startpage_safesearch(options.safesearch).to_string()),
        ("segment", "organic".to_string()),
    ];

    if options.page > 1 {
        form.push(("page", options.page.to_string()));
    }
    if let Some(value) = options.timelimit {
        form.push(("with_date", value.as_str().to_string()));
    }

    let html = http
        .post("https://www.startpage.com/sp/search")
        .header("Referer", "https://www.startpage.com/")
        .form(&form)
        .send()
        .await?
        .error_for_status()?
        .text()
        .await?;

    reject_blocked_response(&html)?;
    let mut results = parse_startpage_html(&html);
    truncate_results(&mut results, options.max_results);
    Ok(results)
}

pub async fn search_brave(
    http: &reqwest::Client,
    query: &str,
    options: &TextOptions,
) -> Result<Vec<TextResult>> {
    let mut request = http
        .get("https://search.brave.com/search")
        .query(&[("q", query), ("source", "web")]);

    let offset;
    if options.page > 1 {
        offset = (options.page - 1).to_string();
        request = request.query(&[("offset", offset.as_str())]);
    }

    let timelimit;
    if let Some(value) = options.timelimit {
        timelimit = match value.as_str() {
            "d" => "pd",
            "w" => "pw",
            "m" => "pm",
            "y" => "py",
            other => other,
        };
        request = request.query(&[("tf", timelimit)]);
    }

    let html = request.send().await?.error_for_status()?.text().await?;
    reject_blocked_response(&html)?;
    let mut results = parse_brave_html(&html);
    truncate_results(&mut results, options.max_results);
    Ok(results)
}

fn reject_blocked_response(html: &str) -> Result<()> {
    if html.contains("anomaly-modal")
        || html.contains("Unfortunately, bots use DuckDuckGo too")
        || html.contains("detected unusual traffic")
        || html.contains("captcha")
    {
        return Err(DdgsError::Blocked(
            "search backend returned an anti-bot challenge".to_string(),
        ));
    }

    Ok(())
}

fn truncate_results(results: &mut Vec<TextResult>, max_results: Option<usize>) {
    if let Some(max_results) = max_results {
        results.truncate(max_results);
    }
}

fn merge_results(ranked: &mut HashMap<String, RankedTextResult>, results: Vec<TextResult>) {
    for result in results {
        if result.href.is_empty() || result.title.is_empty() {
            continue;
        }

        ranked
            .entry(result.href.clone())
            .and_modify(|item| {
                item.count += 1;
                if result.body.len() > item.result.body.len() {
                    item.result = result.clone();
                }
            })
            .or_insert(RankedTextResult { result, count: 1 });
    }
}

fn enough_results(ranked: &HashMap<String, RankedTextResult>, max_results: Option<usize>) -> bool {
    max_results.is_some_and(|max_results| ranked.len() >= max_results)
}

fn rank_results(ranked: HashMap<String, RankedTextResult>, query: &str) -> Vec<TextResult> {
    let tokens = query_tokens(query);
    let mut items: Vec<RankedTextResult> = ranked.into_values().collect();
    items.sort_by(|left, right| {
        score_result(&right.result, &tokens, right.count).cmp(&score_result(
            &left.result,
            &tokens,
            left.count,
        ))
    });
    items.into_iter().map(|item| item.result).collect()
}

fn query_tokens(query: &str) -> HashSet<String> {
    query
        .split(|value: char| !value.is_alphanumeric())
        .map(|value| value.trim().to_lowercase())
        .filter(|value| value.chars().count() >= 2)
        .collect()
}

fn score_result(result: &TextResult, tokens: &HashSet<String>, count: usize) -> usize {
    let title = result.title.to_lowercase();
    let body = result.body.to_lowercase();
    let mut score = count * 100;
    if result.href.contains("wikipedia.org") {
        score += 50;
    }
    for token in tokens {
        if title.contains(token) {
            score += 20;
        }
        if body.contains(token) {
            score += 10;
        }
    }
    score
}

fn offset(options: &TextOptions) -> usize {
    match options.page.max(1) {
        1 => 0,
        page => 10 + (page - 2) * 15,
    }
}

fn collect_topics(topics: &[ApiTopic], out: &mut Vec<TextResult>) {
    for topic in topics {
        if !topic.topics.is_empty() {
            collect_topics(&topic.topics, out);
            continue;
        }

        let Some(href) = topic.first_url.as_deref().map(str::trim) else {
            continue;
        };
        let Some(text) = topic.text.as_deref().map(str::trim) else {
            continue;
        };

        if href.is_empty() || text.is_empty() {
            continue;
        }

        out.push(TextResult {
            title: extract_title(text, topic.result.as_deref()),
            href: href.to_string(),
            body: text.to_string(),
        });
    }
}

fn extract_title(text: &str, result_html: Option<&str>) -> String {
    if let Some(result_html) = result_html {
        let cleaned = clean_html(result_html);
        if !cleaned.is_empty() {
            return cleaned;
        }
    }

    text.split(" - ")
        .next()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or(text)
        .to_string()
}

pub fn parse_text_html(html: &str) -> Vec<TextResult> {
    let result_re = Regex::new(r#"(?is)<div[^>]+class="[^"]*body[^"]*"[^>]*>(.*?)</div>"#)
        .expect("valid result regex");
    let title_re = Regex::new(r#"(?is)<h2[^>]*>(.*?)</h2>"#).expect("valid title regex");
    let link_re =
        Regex::new(r#"(?is)<a(?P<attrs>[^>]*)>(?P<body>.*?)</a>"#).expect("valid link regex");

    result_re
        .captures_iter(html)
        .filter_map(|capture| {
            let block = capture.get(1)?.as_str();
            let link = link_re.captures(block)?;
            let raw_href = first_capture(link.name("attrs")?.as_str(), r#"(?is)\bhref="([^"]+)""#)?;
            let href = resolve_duckduckgo_href(&raw_href)?;
            if href.starts_with("https://duckduckgo.com/y.js?") {
                return None;
            }

            let title = title_re
                .captures(block)
                .and_then(|capture| capture.get(1))
                .map(|value| clean_html(value.as_str()))
                .unwrap_or_default();
            let body = clean_html(link.name("body")?.as_str());
            if title.is_empty() || href.is_empty() {
                return None;
            }

            Some(TextResult { title, href, body })
        })
        .collect()
}

pub fn parse_text_lite(html: &str) -> Vec<TextResult> {
    let row_re = Regex::new(r#"(?is)<tr>\s*<td[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>\s*</td>\s*</tr>\s*<tr>\s*<td[^>]*>(.*?)</td>\s*</tr>"#)
        .expect("valid lite row regex");

    row_re
        .captures_iter(html)
        .filter_map(|capture| {
            let href = resolve_duckduckgo_href(capture.get(1)?.as_str())?;
            let title = clean_html(capture.get(2)?.as_str());
            let body = clean_html(capture.get(3)?.as_str());
            if title.is_empty() || href.is_empty() {
                return None;
            }

            Some(TextResult { title, href, body })
        })
        .collect()
}

pub fn parse_yahoo_html(html: &str) -> Vec<TextResult> {
    let title_re = Regex::new(r#"(?is)<div[^>]+class="[^"]*Title[^"]*"[^>]*>.*?<a[^>]+href="([^"]+)"[^>]*>.*?<h3[^>]*>(.*?)</h3>"#)
        .expect("valid yahoo title regex");
    let body_re = Regex::new(r#"(?is)<div[^>]+class="[^"]*Text[^"]*"[^>]*>(.*?)</div>"#)
        .expect("valid yahoo body regex");

    html_blocks(html, r#"(?is)<div[^>]+class="[^"]*relsrch[^"]*"[^>]*>"#)
        .into_iter()
        .filter_map(|block| {
            let title = title_re.captures(block)?;
            let href = unwrap_yahoo_href(title.get(1)?.as_str());
            let title = clean_html(title.get(2)?.as_str());
            let body = body_re
                .captures(block)
                .and_then(|capture| capture.get(1))
                .map(|value| clean_html(value.as_str()))
                .unwrap_or_default();
            if title.is_empty()
                || href.is_empty()
                || href.starts_with("https://www.bing.com/aclick?")
            {
                return None;
            }
            Some(TextResult { title, href, body })
        })
        .collect()
}

pub fn parse_startpage_html(html: &str) -> Vec<TextResult> {
    let link_re = Regex::new(r#"(?is)<a[^>]+href="([^"]+)"[^>]*>.*?<h2[^>]*>(.*?)</h2>.*?</a>"#)
        .expect("valid startpage link regex");
    let body_re = Regex::new(r#"(?is)<p[^>]*>(.*?)</p>"#).expect("valid startpage body regex");

    html_blocks(html, r#"(?is)<div[^>]+class="[^"]*result[^"]*"[^>]*>"#)
        .into_iter()
        .filter_map(|block| {
            let link = link_re.captures(block)?;
            let href = link.get(1)?.as_str().to_string();
            let title = clean_html(link.get(2)?.as_str());
            let body = body_re
                .captures(block)
                .and_then(|capture| capture.get(1))
                .map(|value| clean_html(value.as_str()))
                .unwrap_or_default();
            if title.is_empty() || href.is_empty() {
                return None;
            }
            Some(TextResult { title, href, body })
        })
        .collect()
}

pub fn parse_brave_html(html: &str) -> Vec<TextResult> {
    let link_re = Regex::new(
        r#"(?is)<a[^>]+href="([^"]+)"[^>]*>.*?<div[^>]+class="[^"]*title[^"]*"[^>]*>(.*?)</div>"#,
    )
    .expect("valid brave link regex");
    let body_re = Regex::new(r#"(?is)<div[^>]+class="[^"]*snippet[^"]*"[^>]*>.*?<div[^>]+class="[^"]*content[^"]*"[^>]*>(.*?)</div>"#)
        .expect("valid brave body regex");

    html_blocks(html, r#"(?is)<div[^>]+data-type="web"[^>]*>"#)
        .into_iter()
        .filter_map(|block| {
            let link = link_re.captures(block)?;
            let href = link.get(1)?.as_str().to_string();
            let title = clean_html(link.get(2)?.as_str());
            let body = body_re
                .captures(block)
                .and_then(|capture| capture.get(1))
                .map(|value| clean_html(value.as_str()))
                .unwrap_or_default();
            if title.is_empty() || href.is_empty() {
                return None;
            }
            Some(TextResult { title, href, body })
        })
        .collect()
}

pub fn parse_google_html(html: &str) -> Vec<TextResult> {
    let link_re = Regex::new(r#"(?is)<a[^>]+href="([^"]+)"[^>]*>.*?<h3[^>]*>(.*?)</h3>"#)
        .expect("valid google link regex");
    let body_re =
        Regex::new(r#"(?is)<div[^>]*>([^<]{20,})</div>"#).expect("valid google body regex");

    html_blocks(html, r#"(?is)<div[^>]+data-hveid="[^"]+"[^>]*>"#)
        .into_iter()
        .filter_map(|block| {
            let link = link_re.captures(block)?;
            let href = unwrap_google_href(link.get(1)?.as_str());
            let title = clean_html(link.get(2)?.as_str());
            let body = body_re
                .captures_iter(block)
                .last()
                .and_then(|capture| capture.get(1))
                .map(|value| clean_html(value.as_str()))
                .unwrap_or_default();
            if title.is_empty() || !href.starts_with("http") {
                return None;
            }
            Some(TextResult { title, href, body })
        })
        .collect()
}

pub fn parse_mojeek_html(html: &str) -> Vec<TextResult> {
    let item_re = Regex::new(r#"(?is)<li[^>]*>(.*?)</li>"#).expect("valid mojeek item regex");
    let link_re = Regex::new(r#"(?is)<h2[^>]*>.*?<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>.*?</h2>"#)
        .expect("valid mojeek link regex");
    let body_re =
        Regex::new(r#"(?is)<p[^>]+class="s"[^>]*>(.*?)</p>"#).expect("valid mojeek body regex");

    item_re
        .captures_iter(html)
        .filter_map(|capture| {
            let block = capture.get(1)?.as_str();
            let link = link_re.captures(block)?;
            let href = link.get(1)?.as_str().to_string();
            let title = clean_html(link.get(2)?.as_str());
            let body = body_re
                .captures(block)
                .and_then(|capture| capture.get(1))
                .map(|value| clean_html(value.as_str()))
                .unwrap_or_default();
            if title.is_empty() || href.is_empty() {
                return None;
            }
            Some(TextResult { title, href, body })
        })
        .collect()
}

pub fn parse_yandex_html(html: &str) -> Vec<TextResult> {
    let item_re = Regex::new(r#"(?is)<li[^>]+class="[^"]*serp-item[^"]*"[^>]*>(.*?)</li>"#)
        .expect("valid yandex item regex");
    let link_re = Regex::new(r#"(?is)<h3[^>]*>.*?<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>.*?</h3>"#)
        .expect("valid yandex link regex");
    let body_re = Regex::new(r#"(?is)<div[^>]+class="[^"]*text[^"]*"[^>]*>(.*?)</div>"#)
        .expect("valid yandex body regex");

    item_re
        .captures_iter(html)
        .filter_map(|capture| {
            let block = capture.get(1)?.as_str();
            let link = link_re.captures(block)?;
            let href = link.get(1)?.as_str().to_string();
            let title = clean_html(link.get(2)?.as_str());
            let body = body_re
                .captures(block)
                .and_then(|capture| capture.get(1))
                .map(|value| clean_html(value.as_str()))
                .unwrap_or_default();
            if title.is_empty() || href.is_empty() {
                return None;
            }
            Some(TextResult { title, href, body })
        })
        .collect()
}

fn html_blocks<'a>(html: &'a str, marker_pattern: &str) -> Vec<&'a str> {
    let marker_re = Regex::new(marker_pattern).expect("valid marker regex");
    let starts: Vec<usize> = marker_re
        .find_iter(html)
        .map(|match_| match_.start())
        .collect();
    starts
        .iter()
        .enumerate()
        .map(|(index, start)| {
            let end = starts.get(index + 1).copied().unwrap_or(html.len());
            &html[*start..end]
        })
        .collect()
}

fn unwrap_yahoo_href(href: &str) -> String {
    if let Some((_, rest)) = href.split_once("/RU=") {
        let encoded = rest
            .split("/RK=")
            .next()
            .unwrap_or(rest)
            .split("/RS=")
            .next()
            .unwrap_or(rest);
        return percent_decode(encoded);
    }
    href.to_string()
}

fn percent_decode(value: &str) -> String {
    let mut output = String::new();
    let mut chars = value.as_bytes().iter().copied().peekable();
    while let Some(byte) = chars.next() {
        if byte == b'%' {
            let hi = chars.next();
            let lo = chars.next();
            if let (Some(hi), Some(lo)) = (hi, lo) {
                if let Ok(decoded) =
                    u8::from_str_radix(&format!("{}{}", hi as char, lo as char), 16)
                {
                    output.push(decoded as char);
                    continue;
                }
            }
            output.push('%');
        } else if byte == b'+' {
            output.push(' ');
        } else {
            output.push(byte as char);
        }
    }
    output
}

fn encode_query(value: &str) -> String {
    byte_serialize(value.as_bytes()).collect()
}

fn unwrap_google_href(href: &str) -> String {
    if let Some(query) = href.strip_prefix("/url?q=") {
        return percent_decode(query.split('&').next().unwrap_or(query));
    }
    href.to_string()
}

struct GoogleRegion {
    hl: String,
    lr: String,
    cr: String,
}

fn google_region(region: &str) -> GoogleRegion {
    let mut parts = region.split('-');
    let country = parts.next().unwrap_or("us");
    let language = parts.next().unwrap_or("en");
    GoogleRegion {
        hl: format!("{language}-{}", country.to_uppercase()),
        lr: format!("lang_{language}"),
        cr: format!("country{}", country.to_uppercase()),
    }
}

fn google_safesearch(value: SafeSearch) -> &'static str {
    match value {
        SafeSearch::On => "2",
        SafeSearch::Moderate => "1",
        SafeSearch::Off => "0",
    }
}

fn startpage_region(region: &str) -> String {
    let mut parts = region.split('-');
    let country = parts.next().unwrap_or("us");
    let language = parts.next().unwrap_or("en");
    format!("{language}_{}", country.to_uppercase())
}

fn startpage_safesearch(value: SafeSearch) -> &'static str {
    match value {
        SafeSearch::On => "heavy",
        SafeSearch::Moderate => "moderate",
        SafeSearch::Off => "none",
    }
}

pub fn resolve_duckduckgo_href(value: &str) -> Option<String> {
    let normalized = if value.starts_with("//") {
        format!("https:{value}")
    } else {
        value.to_string()
    };
    let url = Url::parse(&normalized).ok()?;
    if url.domain() != Some("duckduckgo.com") {
        return Some(normalized);
    }

    for (key, value) in url.query_pairs() {
        if key == "uddg" {
            return Some(value.into_owned());
        }
    }

    None
}

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

    #[test]
    fn parses_nested_api_topics() {
        let response: ApiResponse = serde_json::from_str(
            r#"
            {
              "Results": [],
              "RelatedTopics": [
                {
                  "Name": "Rust",
                  "Topics": [
                    {
                      "FirstURL": "https://www.rust-lang.org/",
                      "Text": "Rust - A language empowering everyone",
                      "Result": "<a href=\"https://www.rust-lang.org/\">Rust</a>"
                    }
                  ]
                }
              ]
            }
            "#,
        )
        .unwrap();

        let mut results = Vec::new();
        collect_topics(&response.results, &mut results);
        collect_topics(&response.related_topics, &mut results);

        assert_eq!(
            results,
            vec![TextResult {
                title: "Rust".to_string(),
                href: "https://www.rust-lang.org/".to_string(),
                body: "Rust - A language empowering everyone".to_string(),
            }]
        );
    }

    #[test]
    fn resolves_duckduckgo_redirect_href() {
        let href = resolve_duckduckgo_href(
            "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fdoc&rut=abc",
        )
        .unwrap();

        assert_eq!(href, "https://example.com/doc");
    }

    #[test]
    fn parses_html_backend_results() {
        let html = r#"
        <div class="result__body links_main links_deep">
          <h2 class="result__title">Rust &amp; Search</h2>
          <a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Frust">Fast systems language.</a>
        </div>
        "#;

        assert_eq!(
            parse_text_html(html),
            vec![TextResult {
                title: "Rust & Search".to_string(),
                href: "https://example.com/rust".to_string(),
                body: "Fast systems language.".to_string(),
            }]
        );
    }

    #[test]
    fn parses_lite_backend_results() {
        let html = r#"
        <tr><td><a rel="nofollow" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fdoc">Example</a></td></tr>
        <tr><td class="result-snippet">A compact result.</td></tr>
        "#;

        assert_eq!(
            parse_text_lite(html),
            vec![TextResult {
                title: "Example".to_string(),
                href: "https://example.com/doc".to_string(),
                body: "A compact result.".to_string(),
            }]
        );
    }

    #[test]
    fn parses_yahoo_backend_results() {
        let html = r#"
        <div class="relsrch">
          <div class="Title">
            <a href="https://r.search.yahoo.com/_ylt=A/RU=https%3A%2F%2Fexample.com%2Fnews/RK=2/RS=abc">
              <h3>山西煤矿事故最新进展</h3>
            </a>
          </div>
          <div class="Text">事故救援正在进行中。</div>
        </div>
        </div>
        "#;

        assert_eq!(
            parse_yahoo_html(html),
            vec![TextResult {
                title: "山西煤矿事故最新进展".to_string(),
                href: "https://example.com/news".to_string(),
                body: "事故救援正在进行中。".to_string(),
            }]
        );
    }

    #[test]
    fn parses_startpage_backend_results() {
        let html = r#"
        <div class="result">
          <a href="https://developers.openai.com/api/docs/guides/realtime">
            <h2>Realtime and audio | OpenAI API</h2>
          </a>
          <p>Realtime sessions are best for live audio.</p>
        </div>
        </div>
        "#;

        assert_eq!(
            parse_startpage_html(html),
            vec![TextResult {
                title: "Realtime and audio | OpenAI API".to_string(),
                href: "https://developers.openai.com/api/docs/guides/realtime".to_string(),
                body: "Realtime sessions are best for live audio.".to_string(),
            }]
        );
    }

    #[test]
    fn parses_brave_backend_results() {
        let html = r#"
        <div data-type="web">
          <a href="https://example.com/rust">
            <div class="title">Rust Search</div>
          </a>
          <div class="snippet"><div class="content">Fast systems language.</div></div>
        </div>
        </div>
        "#;

        assert_eq!(
            parse_brave_html(html),
            vec![TextResult {
                title: "Rust Search".to_string(),
                href: "https://example.com/rust".to_string(),
                body: "Fast systems language.".to_string(),
            }]
        );
    }

    #[test]
    fn parses_google_backend_results() {
        let html = r#"
        <div data-hveid="abc">
          <a href="/url?q=https%3A%2F%2Fexample.com%2Fgoogle&amp;sa=U">
            <h3>Google Result</h3>
          </a>
          <div>Short</div>
          <div>Useful google result body for parsing.</div>
        </div>
        </div>
        "#;

        assert_eq!(
            parse_google_html(html),
            vec![TextResult {
                title: "Google Result".to_string(),
                href: "https://example.com/google".to_string(),
                body: "Useful google result body for parsing.".to_string(),
            }]
        );
    }

    #[test]
    fn parses_mojeek_backend_results() {
        let html = r#"
        <ul class="results">
          <li>
            <h2><a href="https://example.com/mojeek">Mojeek Result</a></h2>
            <p class="s">Independent search result.</p>
          </li>
        </ul>
        "#;

        assert_eq!(
            parse_mojeek_html(html),
            vec![TextResult {
                title: "Mojeek Result".to_string(),
                href: "https://example.com/mojeek".to_string(),
                body: "Independent search result.".to_string(),
            }]
        );
    }

    #[test]
    fn parses_yandex_backend_results() {
        let html = r#"
        <li class="serp-item">
          <h3><a href="https://example.com/yandex">Yandex Result</a></h3>
          <div class="text">Yandex search result body.</div>
        </li>
        "#;

        assert_eq!(
            parse_yandex_html(html),
            vec![TextResult {
                title: "Yandex Result".to_string(),
                href: "https://example.com/yandex".to_string(),
                body: "Yandex search result body.".to_string(),
            }]
        );
    }

    #[test]
    fn maps_grokipedia_typeahead_json() {
        let response: GrokipediaResponse = serde_json::from_str(
            r#"
            {
              "results": [
                {
                  "title": "_OpenAI_",
                  "slug": "openai",
                  "snippet": "Header\n\nOpenAI body"
                }
              ]
            }
            "#,
        )
        .unwrap();

        let results: Vec<TextResult> = response
            .results
            .into_iter()
            .map(|item| TextResult {
                title: item.title.trim_matches('_').to_string(),
                href: format!("https://grokipedia.com/page/{}", item.slug),
                body: item
                    .snippet
                    .split_once("\n\n")
                    .map(|(_, body)| body)
                    .unwrap_or(&item.snippet)
                    .to_string(),
            })
            .collect();

        assert_eq!(
            results,
            vec![TextResult {
                title: "OpenAI".to_string(),
                href: "https://grokipedia.com/page/openai".to_string(),
                body: "OpenAI body".to_string(),
            }]
        );
    }

    #[test]
    fn ranks_duplicate_results_by_frequency_and_body_length() {
        let mut ranked = HashMap::new();
        merge_results(
            &mut ranked,
            vec![TextResult {
                title: "OpenAI Realtime".to_string(),
                href: "https://example.com/realtime".to_string(),
                body: "short".to_string(),
            }],
        );
        merge_results(
            &mut ranked,
            vec![TextResult {
                title: "OpenAI Realtime".to_string(),
                href: "https://example.com/realtime".to_string(),
                body: "longer realtime body".to_string(),
            }],
        );
        let results = rank_results(ranked, "openai realtime");

        assert_eq!(results.len(), 1);
        assert_eq!(results[0].body, "longer realtime body");
    }
}
