use std::collections::HashMap;

use regex::Regex;
use serde::{Deserialize, Serialize};

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

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NewsResult {
    pub date: Option<NewsDate>,
    pub title: String,
    pub body: String,
    pub url: String,
    pub image: Option<String>,
    pub source: Option<String>,
}

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

#[derive(Debug, Deserialize)]
struct NewsItem {
    date: Option<NewsDate>,
    title: Option<String>,
    excerpt: Option<String>,
    body: Option<String>,
    url: Option<String>,
    image: Option<String>,
    source: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum NewsDate {
    Text(String),
    Unix(i64),
}

pub async fn search(
    http: &reqwest::Client,
    query: &str,
    options: &NewsOptions,
) -> Result<Vec<NewsResult>> {
    let page = options.page.max(1);
    let offset = (page - 1) * options.max_results.unwrap_or(30);
    let vqd = fetch_vqd(http, query).await?;
    let mut request = http.get("https://duckduckgo.com/news.js").query(&[
        ("l", options.region.as_str()),
        ("o", "json"),
        ("noamp", "1"),
        ("q", query),
        ("vqd", &vqd),
        ("p", options.safesearch.as_str()),
        ("s", &offset.to_string()),
    ]);

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

    let body = request.send().await?.error_for_status()?.text().await?;
    let response = parse_news_response(&body)?;

    let mut results: Vec<NewsResult> = response
        .results
        .into_iter()
        .filter_map(NewsResult::from_item)
        .collect();

    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: &NewsOptions,
) -> Result<Vec<NewsResult>> {
    let mut merged = HashMap::<String, NewsResult>::new();
    let mut last_error = None;

    if let Err(error) = merge_news(&mut merged, search_bing(http, query, options).await) {
        last_error = Some(error);
    }
    if should_continue_auto(options, &merged) {
        if let Err(error) = merge_news(&mut merged, search(http, query, options).await) {
            last_error = Some(error);
        }
    }
    if should_continue_auto(options, &merged) {
        if let Err(error) = merge_news(&mut merged, search_yahoo(http, query, options).await) {
            last_error = Some(error);
        }
    }

    let mut results: Vec<NewsResult> = merged.into_values().collect();
    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 news results found".to_string()))
        );
    }

    Ok(results)
}

pub async fn search_bing(
    http: &reqwest::Client,
    query: &str,
    options: &NewsOptions,
) -> Result<Vec<NewsResult>> {
    let page = options.page.max(1);
    let first = (page * 10 + 1).to_string();
    let sfx = page.to_string();
    let mut request = http
        .get("https://www.bing.com/news/infinitescrollajax")
        .query(&[
            ("q", query),
            ("InfiniteScroll", "1"),
            ("first", first.as_str()),
            ("SFX", sfx.as_str()),
        ]);
    let region = region_parts(options.region.as_str());
    request = request.query(&[
        ("cc", region.country.as_str()),
        ("setlang", region.language.as_str()),
    ]);
    let qft;
    if let Some(value) = options.timelimit {
        qft = match value.as_str() {
            "d" => r#"interval="4""#.to_string(),
            "w" => r#"interval="7""#.to_string(),
            "m" | "y" => r#"interval="9""#.to_string(),
            other => other.to_string(),
        };
        request = request.query(&[("qft", qft.as_str())]);
    }

    let html = request.send().await?.error_for_status()?.text().await?;
    let mut results = parse_bing_news_html(&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: &NewsOptions,
) -> Result<Vec<NewsResult>> {
    let mut request = http
        .get("https://news.search.yahoo.com/search")
        .query(&[("p", query)]);
    let page_start;
    if options.page > 1 {
        page_start = ((options.page - 1) * 10 + 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?;
    let mut results = parse_yahoo_news_html(&html);
    if let Some(max_results) = options.max_results {
        results.truncate(max_results);
    }
    Ok(results)
}

fn merge_news(
    target: &mut HashMap<String, NewsResult>,
    result: Result<Vec<NewsResult>>,
) -> Result<()> {
    for item in result? {
        if item.url.is_empty() || item.title.is_empty() {
            continue;
        }
        target.entry(item.url.clone()).or_insert(item);
    }
    Ok(())
}

fn should_continue_auto(options: &NewsOptions, results: &HashMap<String, NewsResult>) -> bool {
    options.backend.as_str() == "all"
        || options
            .max_results
            .is_none_or(|max_results| results.len() < max_results)
}

fn parse_news_response(body: &str) -> Result<NewsResponse> {
    serde_json::from_str(body).map_err(|error| {
        if body.contains("anomaly") || body.contains("ops@duckduckgo.com") {
            DdgsError::Blocked("DuckDuckGo returned an anti-bot response".to_string())
        } else {
            DdgsError::Parse(format!("invalid news JSON: {error}"))
        }
    })
}

async fn fetch_vqd(http: &reqwest::Client, query: &str) -> Result<String> {
    let html = http
        .get("https://duckduckgo.com/")
        .query(&[("q", query)])
        .send()
        .await?
        .error_for_status()?
        .text()
        .await?;

    extract_vqd(&html).ok_or_else(|| DdgsError::Parse("missing DuckDuckGo vqd token".to_string()))
}

fn extract_vqd(html: &str) -> Option<String> {
    first_capture(html, r#"vqd=['"]([^'"]+)['"]"#)
        .or_else(|| first_capture(html, r#"vqd=([^&"'\\]+)"#))
}

pub fn parse_bing_news_html(html: &str) -> Vec<NewsResult> {
    let item_re = Regex::new(r#"(?is)<div[^>]+class="[^"]*newsitem[^"]*"[^>]*>"#)
        .expect("valid bing news marker regex");
    let blocks = html_blocks(html, &item_re);
    blocks
        .into_iter()
        .filter_map(|block| {
            let title = first_capture(block, r#"(?is)\bdata-title="([^"]+)""#)?;
            let url = first_capture(block, r#"(?is)\burl="([^"]+)""#)?;
            let body = first_capture(block, r#"(?is)<div[^>]+class="snippet"[^>]*>(.*?)</div>"#)
                .map(|value| clean_html(&value))
                .unwrap_or_default();
            let date =
                first_capture(block, r#"(?is)<span[^>]+aria-label="([^"]+)""#).map(NewsDate::Text);
            let image = first_capture(
                block,
                r#"(?is)<a[^>]+class="[^"]*image[^"]*"[^>]+src="([^"]+)""#,
            )
            .map(|value| {
                format!(
                    "https://www.bing.com{}",
                    value.split('&').next().unwrap_or(&value)
                )
            });
            let source = first_capture(block, r#"(?is)\bdata-author="([^"]+)""#);

            Some(NewsResult {
                date,
                title,
                body,
                url,
                image,
                source,
            })
        })
        .collect()
}

pub fn parse_yahoo_news_html(html: &str) -> Vec<NewsResult> {
    let item_re =
        Regex::new(r#"(?is)<li[^>]*>\s*<a[^>]*>"#).expect("valid yahoo news marker regex");
    let blocks = html_blocks(html, &item_re);
    blocks
        .into_iter()
        .filter_map(|block| {
            let title = first_capture(block, r#"(?is)<h4[^>]*>(.*?)</h4>"#)
                .map(|value| clean_html(&value))?;
            let raw_url = first_capture(block, r#"(?is)<h4[^>]*>.*?<a[^>]+href="([^"]+)""#)?;
            let url = unwrap_yahoo_url(&raw_url);
            let body = first_capture(block, r#"(?is)<p[^>]*>(.*?)</p>"#)
                .map(|value| clean_html(&value))
                .unwrap_or_default();
            let date = first_capture(
                block,
                r#"(?is)<span[^>]+class="[^"]*time[^"]*"[^>]*>(.*?)</span>"#,
            )
            .map(|value| NewsDate::Text(clean_html(&value)));
            let source = first_capture(
                block,
                r#"(?is)<span[^>]+class="[^"]*source[^"]*"[^>]*>(.*?)</span>"#,
            )
            .map(|value| clean_html(&value).replace(" · via Yahoo", ""));
            let image = first_capture(block, r#"(?is)<img[^>]+(?:data-src|src)="([^"]+)""#)
                .map(|value| value.split("-/").last().unwrap_or(&value).to_string());

            Some(NewsResult {
                date,
                title,
                body,
                url,
                image,
                source,
            })
        })
        .collect()
}

fn unwrap_yahoo_url(value: &str) -> String {
    if let Some((_, rest)) = value.split_once("/RU=") {
        let encoded = rest
            .split("/RK=")
            .next()
            .unwrap_or(rest)
            .split('?')
            .next()
            .unwrap_or(rest);
        return percent_decode(encoded);
    }
    value.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 html_blocks<'a>(html: &'a str, marker_re: &Regex) -> Vec<&'a str> {
    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()
}

struct RegionParts {
    country: String,
    language: String,
}

fn region_parts(region: &str) -> RegionParts {
    let mut parts = region.split('-');
    RegionParts {
        country: parts.next().unwrap_or("us").to_string(),
        language: parts.next().unwrap_or("en").to_string(),
    }
}

impl NewsResult {
    fn from_item(item: NewsItem) -> Option<Self> {
        let title = item.title?.trim().to_string();
        let url = item.url?.trim().to_string();
        if title.is_empty() || url.is_empty() {
            return None;
        }

        Some(Self {
            date: item.date.filter(NewsDate::is_present),
            title,
            body: item
                .excerpt
                .or(item.body)
                .unwrap_or_default()
                .trim()
                .to_string(),
            url,
            image: item.image.filter(|value| !value.trim().is_empty()),
            source: item.source.filter(|value| !value.trim().is_empty()),
        })
    }
}

impl NewsDate {
    fn is_present(&self) -> bool {
        match self {
            Self::Text(value) => !value.trim().is_empty(),
            Self::Unix(_) => true,
        }
    }
}

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

    #[test]
    fn maps_news_json_items() {
        let response: NewsResponse = serde_json::from_str(
            r#"
            {
              "results": [
                {
                  "date": 1753384500,
                  "title": "DuckDuckGo ships feature",
                  "excerpt": "A short summary",
                  "url": "https://example.com/news",
                  "image": "https://example.com/image.jpg",
                  "source": "Example"
                }
              ]
            }
            "#,
        )
        .unwrap();

        let results: Vec<NewsResult> = response
            .results
            .into_iter()
            .filter_map(NewsResult::from_item)
            .collect();

        assert_eq!(
            results,
            vec![NewsResult {
                date: Some(NewsDate::Unix(1753384500)),
                title: "DuckDuckGo ships feature".to_string(),
                body: "A short summary".to_string(),
                url: "https://example.com/news".to_string(),
                image: Some("https://example.com/image.jpg".to_string()),
                source: Some("Example".to_string()),
            }]
        );
    }

    #[test]
    fn classifies_news_antibot_response() {
        let error =
            parse_news_response("If this error persists, please let us know: ops@duckduckgo.com")
                .unwrap_err();

        assert!(matches!(error, DdgsError::Blocked(_)));
    }

    #[test]
    fn extracts_vqd_token() {
        assert_eq!(
            extract_vqd(r#"<script>var vqd="4-123456789";</script>"#),
            Some("4-123456789".to_string())
        );
    }

    #[test]
    fn parses_bing_news_html() {
        let html = r#"
        <div class="newsitem" data-title="Bing News" url="https://example.com/bing" data-author="Example">
          <span aria-label="1 day ago"></span>
          <div class="snippet">Bing news body.</div>
          <a class="image" src="/img.jpg&amp;w=120"></a>
        </div>
        "#;

        assert_eq!(
            parse_bing_news_html(html),
            vec![NewsResult {
                date: Some(NewsDate::Text("1 day ago".to_string())),
                title: "Bing News".to_string(),
                body: "Bing news body.".to_string(),
                url: "https://example.com/bing".to_string(),
                image: Some("https://www.bing.com/img.jpg".to_string()),
                source: Some("Example".to_string()),
            }]
        );
    }

    #[test]
    fn parses_yahoo_news_html() {
        let html = r#"
        <li>
          <a></a>
          <h4><a href="https://news.search.yahoo.com/RU=https%3A%2F%2Fexample.com%2Fyahoo/RK=1">Yahoo News</a></h4>
          <p>Yahoo news body.</p>
          <span class="time">2 hours ago</span>
          <span class="source">Example · via Yahoo</span>
          <img data-src="https://img.example/-/thumb.jpg">
        </li>
        "#;

        assert_eq!(
            parse_yahoo_news_html(html),
            vec![NewsResult {
                date: Some(NewsDate::Text("2 hours ago".to_string())),
                title: "Yahoo News".to_string(),
                body: "Yahoo news body.".to_string(),
                url: "https://example.com/yahoo".to_string(),
                image: Some("thumb.jpg".to_string()),
                source: Some("Example".to_string()),
            }]
        );
    }
}
