use anyhow::{Context, Result, bail};
use reqwest::blocking::{Client, Response};
use reqwest::header::{ACCEPT_ENCODING, DNT, USER_AGENT};
use reqwest::redirect::Policy;
use scraper::{Html, Selector};
use serde::Serialize;
use std::time::Duration;

const ENDPOINT: &str = "https://html.duckduckgo.com/html/";
const DEFAULT_UA: &str =
    "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";

#[derive(Debug, Serialize, Clone)]
pub struct SearchResult {
    pub index: usize,
    pub title: String,
    pub url: String,
    pub abstract_: String,
}

#[derive(Debug, Default)]
pub struct SearchPage {
    pub results: Vec<SearchResult>,
    pub next_params: Option<String>,
    pub prev_params: Option<String>,
    pub vqd: Option<String>,
}

pub struct SearchOpts<'a> {
    pub query: &'a str,
    pub region: &'a str,
    pub time: Option<&'a str>,
    pub safe: i8,
    pub site: Option<&'a str>,
}

pub struct DdgClient {
    http: Client,
    ua: String,
}

#[derive(Debug)]
pub enum FetchOutcome {
    Ok(String),
    Captcha {
        body: String,
        status: u16,
        url: String,
    },
}

impl DdgClient {
    pub fn new(proxy: Option<&str>, user_agent: Option<&str>) -> Result<Self> {
        let mut builder = Client::builder()
            .cookie_store(true)
            .gzip(true)
            // Don't auto-follow — DDG redirects to a Cloudflare challenge
            // host on anomaly detection (typically 303). We need to see it.
            .redirect(Policy::none())
            .timeout(Duration::from_secs(20));
        if let Some(p) = proxy {
            builder = builder.proxy(reqwest::Proxy::https(p)?);
        }
        Ok(Self {
            http: builder.build()?,
            ua: user_agent.unwrap_or(DEFAULT_UA).to_string(),
        })
    }

    fn post(&self, form: &[(&str, &str)]) -> Result<Response> {
        self.http
            .post(ENDPOINT)
            .header(USER_AGENT, &self.ua)
            .header(ACCEPT_ENCODING, "gzip")
            .header(DNT, "1")
            .form(form)
            .send()
            .context("POST to html.duckduckgo.com failed")
    }

    /// Fetch the first page for a fresh query.
    pub fn fetch_first(&self, opts: &SearchOpts<'_>) -> Result<FetchOutcome> {
        let q = build_query(opts);
        let safe = safe_param(opts.safe);
        let df = opts.time.unwrap_or("");
        let form = vec![
            ("q", q.as_str()),
            ("b", ""),
            ("df", df),
            ("kf", "-1"),
            ("kh", "1"),
            ("kl", opts.region),
            ("kp", safe),
            ("k1", "-1"),
        ];
        self.execute(&form)
    }

    /// Fetch a subsequent page using paging tokens from the previous page.
    pub fn fetch_next(
        &self,
        opts: &SearchOpts<'_>,
        page: usize,
        cur_index: usize,
        next_params: &str,
        vqd: &str,
    ) -> Result<FetchOutcome> {
        let q = build_query(opts);
        let s = (50 * (page.saturating_sub(1)) + 30).to_string();
        let dc = cur_index.to_string();
        let safe = safe_param(opts.safe);
        let df = opts.time.unwrap_or("");
        let form = vec![
            ("q", q.as_str()),
            ("s", s.as_str()),
            ("nextParams", next_params),
            ("v", "l"),
            ("o", "json"),
            ("dc", dc.as_str()),
            ("df", df),
            ("api", "/d.js"),
            ("kf", "-1"),
            ("kh", "1"),
            ("kl", opts.region),
            ("kp", safe),
            ("k1", "-1"),
            ("vqd", vqd),
        ];
        self.execute(&form)
    }

    fn execute(&self, form: &[(&str, &str)]) -> Result<FetchOutcome> {
        let resp = self.post(form)?;
        let status = resp.status();
        let final_url = resp.url().to_string();
        let body = resp.text().context("read response body")?;

        // DDG anomaly is HTTP 202 with the modal form, or sometimes 303 to a
        // Cloudflare-style gate. Body markers catch both.
        if status.as_u16() == 303 || is_anomaly_body(&body) {
            return Ok(FetchOutcome::Captcha {
                body,
                status: status.as_u16(),
                url: final_url,
            });
        }
        if !status.is_success() {
            bail!("DDG returned HTTP {}", status.as_u16());
        }
        Ok(FetchOutcome::Ok(body))
    }

    pub fn user_agent(&self) -> &str {
        &self.ua
    }

    pub fn http(&self) -> &Client {
        &self.http
    }

    /// POST a captcha answer to the form's `action_url`. DDG typically replies
    /// with a 303 redirect on success; we follow that one hop manually because
    /// the client-wide policy is `Policy::none()`.
    pub fn submit_captcha_answer(
        &self,
        action_url: &str,
        form: &[(String, String)],
    ) -> Result<FetchOutcome> {
        let pairs: Vec<(&str, &str)> = form.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
        let resp = self
            .http
            .post(action_url)
            .header(USER_AGENT, &self.ua)
            .header(ACCEPT_ENCODING, "gzip")
            .header(DNT, "1")
            .form(&pairs)
            .send()
            .context("POST captcha answer")?;
        let status = resp.status();

        // Follow up to two redirects manually so we land on the real result page.
        let mut current = resp;
        for _ in 0..2 {
            let s = current.status();
            if s.is_redirection() {
                let loc = current
                    .headers()
                    .get(reqwest::header::LOCATION)
                    .and_then(|h| h.to_str().ok())
                    .map(|s| s.to_string());
                let Some(loc) = loc else { break };
                let next_url = url::Url::parse(current.url().as_str())
                    .and_then(|b| b.join(&loc))
                    .map(|u| u.to_string())
                    .unwrap_or(loc);
                current = self
                    .http
                    .get(&next_url)
                    .header(USER_AGENT, &self.ua)
                    .header(ACCEPT_ENCODING, "gzip")
                    .header(DNT, "1")
                    .send()
                    .context("follow redirect after captcha")?;
                continue;
            }
            break;
        }

        let final_url = current.url().to_string();
        let final_status = current.status();
        let body = current.text().context("read post-captcha body")?;

        if is_anomaly_body(&body) {
            return Ok(FetchOutcome::Captcha {
                body,
                status: status.as_u16(),
                url: final_url,
            });
        }
        if !final_status.is_success() {
            bail!(
                "captcha submit failed: HTTP {} (initial {})",
                final_status.as_u16(),
                status.as_u16()
            );
        }

        // Success interstitial: DDG returns a one-button form that re-POSTs
        // the original query. Submit it transparently to get real results.
        if let Some(success_action) = extract_success_form_action(&body, &final_url) {
            let mut resp = self
                .http
                .post(&success_action)
                .header(USER_AGENT, &self.ua)
                .header(ACCEPT_ENCODING, "gzip")
                .header(DNT, "1")
                .send()
                .context("submit success-form")?;

            // DDG often replies with a 302/303 to the results page; follow it.
            for _ in 0..3 {
                if !resp.status().is_redirection() {
                    break;
                }
                let loc = resp
                    .headers()
                    .get(reqwest::header::LOCATION)
                    .and_then(|h| h.to_str().ok())
                    .map(|s| s.to_string());
                let Some(loc) = loc else { break };
                let next_url = url::Url::parse(resp.url().as_str())
                    .and_then(|b| b.join(&loc))
                    .map(|u| u.to_string())
                    .unwrap_or(loc);
                resp = self
                    .http
                    .get(&next_url)
                    .header(USER_AGENT, &self.ua)
                    .header(ACCEPT_ENCODING, "gzip")
                    .header(DNT, "1")
                    .send()
                    .context("follow success-form redirect")?;
            }

            let results_status = resp.status();
            let results_url = resp.url().to_string();
            let results_body = resp.text().context("read post-success body")?;
            if is_anomaly_body(&results_body) {
                return Ok(FetchOutcome::Captcha {
                    body: results_body,
                    status: results_status.as_u16(),
                    url: results_url,
                });
            }
            if !results_status.is_success() {
                bail!(
                    "success-form POST failed: HTTP {}",
                    results_status.as_u16()
                );
            }
            return Ok(FetchOutcome::Ok(results_body));
        }

        Ok(FetchOutcome::Ok(body))
    }
}

fn build_query(opts: &SearchOpts<'_>) -> String {
    match opts.site {
        Some(site) if !site.is_empty() => format!("site:{} {}", site, opts.query),
        _ => opts.query.to_string(),
    }
}

fn safe_param(safe: i8) -> &'static str {
    match safe {
        s if s > 0 => "1",
        0 => "-1",
        _ => "-2",
    }
}

/// `<form id="challenge-form">` is the canonical "you must solve a CAPTCHA"
/// signal. Looser markers like `anomaly-modal` would also match the success
/// interstitial that DDG serves *after* a correct answer, so we don't use
/// those.
fn is_anomaly_body(body: &str) -> bool {
    body.contains(r#"id="challenge-form""#)
        || body.contains("DDG.deep.anomalyDetectionBlock")
}

/// `<form id="success-form" action="…">` is the post-CAPTCHA success
/// interstitial: it contains a single "Load Search Results" button that
/// re-POSTs the original query. We submit it transparently.
pub fn extract_success_form_action(body: &str, base_url: &str) -> Option<String> {
    let needle = r#"id="success-form""#;
    let i = body.find(needle)?;
    // Find action="..." within ~512 bytes of the form open tag.
    let window = &body[i..body.len().min(i + 512)];
    let act_idx = window.find("action=\"")?;
    let after = &window[act_idx + "action=\"".len()..];
    let end = after.find('"')?;
    let action = &after[..end];
    url::Url::parse(base_url).ok()?.join(action).ok().map(|u| u.to_string())
}

/// Parse a results page returned by html.duckduckgo.com.
pub fn parse_results(html: &str, offset: usize) -> SearchPage {
    let doc = Html::parse_document(html);

    let result_sel = Selector::parse("div.links_main").unwrap();
    let title_link_sel = Selector::parse("h2.result__title a").unwrap();
    let snippet_sel = Selector::parse("a.result__snippet, .result__snippet").unwrap();

    let mut page = SearchPage::default();
    let mut idx = offset;

    for node in doc.select(&result_sel) {
        let Some(link) = node.select(&title_link_sel).next() else {
            continue;
        };
        let raw_href = link.value().attr("href").unwrap_or("").trim();
        if raw_href.is_empty() {
            continue;
        }
        let url = clean_url(raw_href);
        if url.is_empty() {
            continue;
        }

        let title = collapse_ws(&link.text().collect::<String>());

        let abstract_ = node
            .select(&snippet_sel)
            .next()
            .map(|n| collapse_ws(&n.text().collect::<String>()))
            .unwrap_or_default();

        idx += 1;
        page.results.push(SearchResult {
            index: idx,
            title,
            url,
            abstract_,
        });
    }

    // Hidden inputs in the prev/next nav forms.
    let nav_form_sel = Selector::parse("div.nav-link form").unwrap();
    let input_sel = Selector::parse("input").unwrap();
    let mut np_iter = Vec::new();
    for form in doc.select(&nav_form_sel) {
        let mut np = None;
        for inp in form.select(&input_sel) {
            let attrs = inp.value();
            let name = attrs.attr("name").unwrap_or("");
            let value = attrs.attr("value").unwrap_or("");
            match name {
                "nextParams" if !value.is_empty() => np = Some(value.to_string()),
                "vqd" if !value.is_empty() && page.vqd.is_none() => {
                    page.vqd = Some(value.to_string())
                }
                _ => {}
            }
        }
        np_iter.push(np);
    }
    // ddgr convention: "previous button always shows before next button; if
    // there's only one button (page 1), it's the next button."
    let nps: Vec<String> = np_iter.into_iter().flatten().collect();
    match nps.len() {
        0 => {}
        1 => page.next_params = Some(nps.into_iter().next().unwrap()),
        _ => {
            let mut it = nps.into_iter();
            page.prev_params = it.next();
            page.next_params = it.next();
        }
    }

    page
}

/// DDG result hrefs come in a few shapes:
///   //duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com&rut=...
///   /l/?uddg=https%3A%2F%2Fexample.com
///   https://example.com   (rare, direct)
fn clean_url(href: &str) -> String {
    let normalized = if let Some(rest) = href.strip_prefix("//") {
        format!("https://{rest}")
    } else if href.starts_with('/') {
        format!("https://duckduckgo.com{href}")
    } else {
        href.to_string()
    };

    if let Ok(parsed) = url::Url::parse(&normalized) {
        if parsed.path() == "/l/" {
            for (k, v) in parsed.query_pairs() {
                if k == "uddg" {
                    return v.into_owned();
                }
            }
        }
    }
    // Fallback: legacy ?q=…&sa= shape (older ddgr handles this).
    if let Some(q_idx) = href.find("?q=") {
        let after = &href[q_idx + 3..];
        let end = after.find("&sa=").unwrap_or(after.len());
        if let Ok(decoded) = urlencoding_decode(&after[..end]) {
            return decoded;
        }
    }
    normalized
}

fn urlencoding_decode(s: &str) -> std::result::Result<String, std::string::FromUtf8Error> {
    // Minimal percent-decoder with '+' → ' '.
    let bytes = s.as_bytes();
    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        match bytes[i] {
            b'+' => {
                out.push(b' ');
                i += 1;
            }
            b'%' if i + 2 < bytes.len() => {
                let hi = (bytes[i + 1] as char).to_digit(16);
                let lo = (bytes[i + 2] as char).to_digit(16);
                if let (Some(h), Some(l)) = (hi, lo) {
                    out.push(((h << 4) | l) as u8);
                    i += 3;
                } else {
                    out.push(bytes[i]);
                    i += 1;
                }
            }
            b => {
                out.push(b);
                i += 1;
            }
        }
    }
    String::from_utf8(out)
}

fn collapse_ws(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut last_space = true;
    for ch in s.chars() {
        if ch.is_whitespace() {
            if !last_space {
                out.push(' ');
                last_space = true;
            }
        } else {
            out.push(ch);
            last_space = false;
        }
    }
    out.trim().to_string()
}

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

    #[test]
    fn clean_url_extracts_uddg() {
        let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpath&rut=abc";
        assert_eq!(clean_url(href), "https://example.com/path");
    }

    #[test]
    fn clean_url_passes_direct() {
        assert_eq!(clean_url("https://example.com"), "https://example.com");
    }

    #[test]
    fn collapse_collapses_runs() {
        assert_eq!(collapse_ws("  hello \n world\t"), "hello world");
    }

    #[test]
    fn anomaly_detect_marker() {
        assert!(is_anomaly_body(
            r#"<form id="challenge-form" action="/cdn-cgi/...">"#
        ));
        assert!(!is_anomaly_body("<div class=\"links_main\">"));
        // The success interstitial reuses the `anomaly-modal` class but does
        // NOT contain a challenge form. It must NOT be classified as captcha.
        let success_page = r#"<form id="success-form" action="/html/?q=foo">
            <div class="anomaly-modal__success-modal">Thanks for confirming you're human!</div>
            </form>"#;
        assert!(!is_anomaly_body(success_page));
    }

    #[test]
    fn extract_success_form() {
        let html = r#"<html><body>
            <form id="success-form" action="/html/?q=hello+world" method="POST">
              <button>Load Search Results</button>
            </form></body></html>"#;
        let action = extract_success_form_action(html, "https://html.duckduckgo.com/").unwrap();
        assert_eq!(action, "https://html.duckduckgo.com/html/?q=hello+world");
    }
}
