use anyhow::{Context, Result, bail};
use reqwest::blocking::Client;
use scraper::{Html, Selector};
use std::fs;
use std::path::PathBuf;
use url::Url;

/// Parsed DuckDuckGo anomaly challenge form.
#[derive(Debug)]
pub struct Challenge {
    /// Absolute URL to POST the answer to (form action, resolved against `base`).
    pub action_url: String,
    /// `name` of the submit button.
    pub submit_name: String,
    /// `value` of the submit button (the `gk` token).
    pub submit_value: String,
    /// 9 tiles in display order (data-index 0..=8). Each holds the image hash
    /// and the resolved absolute image URL.
    pub tiles: Vec<Tile>,
}

#[derive(Debug, Clone)]
pub struct Tile {
    pub index: usize,
    pub hash: String,
    pub image_url: String,
}

/// Parse the anomaly modal in `body`. `response_url` is the URL the body was
/// served from; we use it as the base for resolving relative links.
pub fn parse_challenge(body: &str, response_url: &str) -> Result<Challenge> {
    let doc = Html::parse_document(body);

    let form_sel = Selector::parse("form#challenge-form").unwrap();
    let form = doc
        .select(&form_sel)
        .next()
        .context("anomaly page has no <form id=\"challenge-form\">")?;

    let action_attr = form
        .value()
        .attr("action")
        .context("challenge form has no `action`")?;
    let base = Url::parse(response_url).context("parse response URL as base")?;
    let action_url = base
        .join(action_attr)
        .context("resolve form action against response URL")?
        .to_string();

    let submit_sel = Selector::parse("button[name=\"challenge-submit\"]").unwrap();
    let submit = form
        .select(&submit_sel)
        .next()
        .context("missing submit button")?;
    let submit_name = submit.value().attr("name").unwrap_or("challenge-submit").to_string();
    let submit_value = submit
        .value()
        .attr("value")
        .context("submit button missing value")?
        .to_string();

    let box_sel = Selector::parse("div.anomaly-modal__box").unwrap();
    let img_sel = Selector::parse("img.anomaly-modal__image").unwrap();
    let mut tiles = Vec::new();
    for b in form.select(&box_sel) {
        let idx = b
            .value()
            .attr("data-index")
            .and_then(|s| s.parse::<usize>().ok())
            .context("tile missing data-index")?;
        let img = b
            .select(&img_sel)
            .next()
            .context("tile missing image")?;
        let src = img.value().attr("src").context("image missing src")?;
        let hash = img
            .value()
            .attr("data-id")
            .map(|s| s.trim_end_matches(".jpg").to_string())
            .or_else(|| {
                img.value()
                    .attr("id")
                    .and_then(|s| s.strip_prefix("image-"))
                    .map(|s| s.to_string())
            })
            .context("could not extract image hash")?;
        let image_url = base.join(src).context("resolve image url")?.to_string();
        tiles.push(Tile {
            index: idx,
            hash,
            image_url,
        });
    }

    if tiles.is_empty() {
        bail!("no tiles found in challenge form");
    }
    tiles.sort_by_key(|t| t.index);

    Ok(Challenge {
        action_url,
        submit_name,
        submit_value,
        tiles,
    })
}

/// Download all tile images to a fresh tmp dir and return its path.
pub fn download_tiles(http: &Client, ua: &str, ch: &Challenge) -> Result<PathBuf> {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    let dir = std::env::temp_dir().join(format!("ddg-rs-captcha-{nanos}"));
    fs::create_dir_all(&dir).context("create captcha tmp dir")?;

    for t in &ch.tiles {
        let resp = http
            .get(&t.image_url)
            .header(reqwest::header::USER_AGENT, ua)
            .send()
            .with_context(|| format!("GET tile {}", t.index))?;
        if !resp.status().is_success() {
            bail!("tile {} returned HTTP {}", t.index, resp.status());
        }
        let bytes = resp.bytes()?;
        let path = dir.join(format!("tile-{}.jpg", t.index));
        fs::write(&path, &bytes).context("write tile")?;
    }

    // Also lay down a contact-sheet HTML so the user can view all 9 in one
    // window (some terminals can't render images natively).
    let mut html = String::from(
        "<!doctype html><meta charset=utf-8><title>DDG CAPTCHA</title>\
         <style>body{font-family:sans-serif;background:#222;color:#eee;padding:20px}\
         .grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;max-width:600px}\
         .tile{position:relative;border:2px solid #444;border-radius:6px;overflow:hidden}\
         .tile img{display:block;width:100%}\
         .tile .num{position:absolute;top:4px;left:6px;background:rgba(0,0,0,.7);\
         padding:2px 6px;border-radius:3px;font-weight:bold;color:#ffd}\
         </style>\
         <h2>Select all squares containing a duck</h2>\
         <p>Type the indices (0..8) into the terminal, space-separated.</p>\
         <div class=grid>",
    );
    for t in &ch.tiles {
        html.push_str(&format!(
            "<div class=tile><span class=num>{}</span><img src=\"tile-{}.jpg\"></div>",
            t.index, t.index
        ));
    }
    html.push_str("</div>");
    fs::write(dir.join("index.html"), html)?;

    Ok(dir)
}

/// Build the form body to POST as the captcha answer.
pub fn build_answer_form<'a>(ch: &'a Challenge, selected: &[usize]) -> Vec<(String, String)> {
    let mut form: Vec<(String, String)> = Vec::new();
    for &idx in selected {
        if let Some(t) = ch.tiles.iter().find(|t| t.index == idx) {
            // Each tile's checkbox is `image-check_<hash>`; checked → value "on"
            form.push((format!("image-check_{}", t.hash), "on".to_string()));
        }
    }
    form.push((ch.submit_name.clone(), ch.submit_value.clone()));
    form
}

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

    const SAMPLE: &str = r#"
<html><body>
<form id="challenge-form" action="//duckduckgo.com/anomaly.js?sv=html&gk=TOKEN123&q=hi" method="POST">
  <div class="anomaly-modal__box" data-index="0">
    <img class="anomaly-modal__image" id="image-aaa111" src="../assets/anomaly/images/challenge/aaa111.jpg" data-id="aaa111.jpg">
  </div>
  <div class="anomaly-modal__box" data-index="1">
    <img class="anomaly-modal__image" id="image-bbb222" src="../assets/anomaly/images/challenge/bbb222.jpg" data-id="bbb222.jpg">
  </div>
  <button name="challenge-submit" value="TOKEN123">Submit</button>
</form>
</body></html>"#;

    #[test]
    fn parses_form() {
        let c = parse_challenge(SAMPLE, "https://html.duckduckgo.com/html/").unwrap();
        assert_eq!(c.tiles.len(), 2);
        assert_eq!(c.tiles[0].hash, "aaa111");
        assert_eq!(c.submit_value, "TOKEN123");
        assert!(c.action_url.starts_with("https://duckduckgo.com/anomaly.js"));
        // src is `../assets/...` resolved against the html.duckduckgo.com base.
        assert_eq!(
            c.tiles[0].image_url,
            "https://html.duckduckgo.com/assets/anomaly/images/challenge/aaa111.jpg"
        );
    }

    #[test]
    fn answer_form_includes_selected_and_token() {
        let c = parse_challenge(SAMPLE, "https://html.duckduckgo.com/html/").unwrap();
        let body = build_answer_form(&c, &[1]);
        assert!(body.iter().any(|(k, v)| k == "image-check_bbb222" && v == "on"));
        assert!(body.iter().any(|(k, v)| k == "challenge-submit" && v == "TOKEN123"));
        assert!(!body.iter().any(|(k, _)| k == "image-check_aaa111"));
    }
}
