//! Agentic web tools: `web_search` (keyless DuckDuckGo HTML, or Serper when
//! SERPER_API_KEY is set) and `read_url` (fetch + HTML→text). These exist so
//! the model verifies instead of hallucinating.

use regex::Regex;
use std::sync::OnceLock;
use std::time::Duration;

const UA: &str = "Mozilla/5.0 (compatible; LucidTrain/0.1; +https://github.com/lucid-train)";
const BROWSER_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
pub const MAX_PAGE_CHARS: usize = 8_000;

fn http() -> reqwest::Client {
    reqwest::Client::builder()
        .user_agent(UA)
        .connect_timeout(Duration::from_secs(10))
        .timeout(Duration::from_secs(30))
        .build()
        .expect("reqwest client")
}

pub async fn web_search(query: &str, max_results: usize) -> String {
    // Serper (Google) when a key is available — higher quality.
    if let Ok(key) = std::env::var("SERPER_API_KEY") {
        if !key.is_empty() {
            match serper_search(query, max_results, &key).await {
                Ok(out) => return out,
                Err(e) => return format!("Search failed (serper): {e}"),
            }
        }
    }
    // Keyless chain: DuckDuckGo first, Mojeek when DDG bot-challenges.
    if let Ok(out) = ddg_search(query, max_results).await {
        if !out.is_empty() {
            return out;
        }
    }
    match mojeek_search(query, max_results).await {
        Ok(out) if !out.is_empty() => out,
        Ok(_) => "No results found. Try a different, simpler query.".into(),
        Err(e) => format!("Search failed: {e}. Try read_url on a known documentation page instead."),
    }
}

async fn mojeek_search(query: &str, max_results: usize) -> Result<String, String> {
    let url = format!("https://www.mojeek.com/search?q={}", urlencode(query));
    let body = http()
        .get(&url)
        .header("User-Agent", BROWSER_UA)
        .send()
        .await
        .map_err(|e| e.to_string())?
        .text()
        .await
        .map_err(|e| e.to_string())?;
    Ok(parse_mojeek_results(&body, max_results))
}

/// Parse Mojeek's results page: `<a class="title" ... href="URL">Title</a>`
/// followed by `<p class="s">snippet</p>`.
pub fn parse_mojeek_results(html: &str, max_results: usize) -> String {
    static LINK_RE: OnceLock<Regex> = OnceLock::new();
    static SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
    let link_re = LINK_RE.get_or_init(|| {
        Regex::new(r#"(?s)<a class="title"[^>]*href="([^"]+)"[^>]*>(.*?)</a>"#).unwrap()
    });
    let snippet_re =
        SNIPPET_RE.get_or_init(|| Regex::new(r#"(?s)<p class="s">(.*?)</p>"#).unwrap());

    let snippets: Vec<String> = snippet_re
        .captures_iter(html)
        .map(|c| strip_tags(&c[1]))
        .collect();

    let mut out = String::new();
    for (i, cap) in link_re.captures_iter(html).take(max_results).enumerate() {
        let title = strip_tags(&cap[2]);
        out.push_str(&format!("{}. {title}\n   {}\n", i + 1, &cap[1]));
        if let Some(snip) = snippets.get(i) {
            if !snip.is_empty() {
                out.push_str(&format!("   {snip}\n"));
            }
        }
    }
    out
}

async fn serper_search(query: &str, max_results: usize, key: &str) -> Result<String, String> {
    let resp = http()
        .post("https://google.serper.dev/search")
        .header("X-API-KEY", key)
        .json(&serde_json::json!({ "q": query, "num": max_results }))
        .send()
        .await
        .map_err(|e| e.to_string())?;
    let v: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
    let mut out = String::new();
    if let Some(organic) = v["organic"].as_array() {
        for (i, r) in organic.iter().take(max_results).enumerate() {
            out.push_str(&format!(
                "{}. {}\n   {}\n   {}\n",
                i + 1,
                r["title"].as_str().unwrap_or(""),
                r["link"].as_str().unwrap_or(""),
                r["snippet"].as_str().unwrap_or("")
            ));
        }
    }
    Ok(out)
}

async fn ddg_search(query: &str, max_results: usize) -> Result<String, String> {
    let url = format!(
        "https://html.duckduckgo.com/html/?q={}",
        urlencode(query)
    );
    let body = http()
        .get(&url)
        .send()
        .await
        .map_err(|e| e.to_string())?
        .text()
        .await
        .map_err(|e| e.to_string())?;
    Ok(parse_ddg_results(&body, max_results))
}

/// Parse DuckDuckGo's HTML results page (testable without network).
pub fn parse_ddg_results(html: &str, max_results: usize) -> String {
    static LINK_RE: OnceLock<Regex> = OnceLock::new();
    static SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
    let link_re = LINK_RE.get_or_init(|| {
        Regex::new(r#"(?s)<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>"#).unwrap()
    });
    let snippet_re = SNIPPET_RE.get_or_init(|| {
        Regex::new(r#"(?s)<a[^>]*class="result__snippet"[^>]*>(.*?)</a>"#).unwrap()
    });

    let snippets: Vec<String> = snippet_re
        .captures_iter(html)
        .map(|c| strip_tags(&c[1]))
        .collect();

    let mut out = String::new();
    for (i, cap) in link_re.captures_iter(html).take(max_results).enumerate() {
        let href = resolve_ddg_href(&cap[1]);
        let title = strip_tags(&cap[2]);
        out.push_str(&format!("{}. {title}\n   {href}\n", i + 1));
        if let Some(snip) = snippets.get(i) {
            out.push_str(&format!("   {snip}\n"));
        }
    }
    out
}

/// DDG wraps result URLs as //duckduckgo.com/l/?uddg=<encoded>&rut=…
fn resolve_ddg_href(href: &str) -> String {
    if let Some(idx) = href.find("uddg=") {
        let enc = &href[idx + 5..];
        let enc = enc.split('&').next().unwrap_or(enc);
        return urldecode(enc);
    }
    href.to_string()
}

pub async fn read_url(url: &str) -> String {
    if !url.starts_with("http://") && !url.starts_with("https://") {
        return "Error: url must start with http:// or https://".into();
    }
    match http().get(url).send().await {
        Ok(resp) => {
            let status = resp.status();
            let content_type = resp
                .headers()
                .get("content-type")
                .and_then(|v| v.to_str().ok())
                .unwrap_or("")
                .to_string();
            match resp.text().await {
                Ok(body) => {
                    let text = if content_type.contains("html") {
                        html_to_text(&body)
                    } else {
                        body
                    };
                    let mut out: String = text.chars().take(MAX_PAGE_CHARS).collect();
                    if text.len() > MAX_PAGE_CHARS {
                        out.push_str("\n[...page truncated...]");
                    }
                    if !status.is_success() {
                        format!("HTTP {status}\n{out}")
                    } else {
                        out
                    }
                }
                Err(e) => format!("Error reading body: {e}"),
            }
        }
        Err(e) => format!("Error fetching {url}: {e}"),
    }
}

/// Crude but dependency-free HTML→text: drop script/style, strip tags,
/// decode common entities, collapse blank lines.
pub fn html_to_text(html: &str) -> String {
    static SCRIPT_RE: OnceLock<Regex> = OnceLock::new();
    static STYLE_RE: OnceLock<Regex> = OnceLock::new();
    static HEAD_RE: OnceLock<Regex> = OnceLock::new();
    static SVG_RE: OnceLock<Regex> = OnceLock::new();
    static TAG_RE: OnceLock<Regex> = OnceLock::new();
    let script_re = SCRIPT_RE.get_or_init(|| Regex::new(r"(?si)<script[^>]*>.*?</script>").unwrap());
    let style_re = STYLE_RE.get_or_init(|| Regex::new(r"(?si)<style[^>]*>.*?</style>").unwrap());
    let head_re = HEAD_RE.get_or_init(|| Regex::new(r"(?si)<head[^>]*>.*?</head>").unwrap());
    let svg_re = SVG_RE.get_or_init(|| Regex::new(r"(?si)<svg[^>]*>.*?</svg>").unwrap());
    let tag_re = TAG_RE.get_or_init(|| Regex::new(r"(?s)<[^>]+>").unwrap());

    let no_scripts = script_re.replace_all(html, " ");
    let no_scripts = style_re.replace_all(&no_scripts, " ");
    let no_scripts = head_re.replace_all(&no_scripts, " ");
    let no_scripts = svg_re.replace_all(&no_scripts, " ");
    let block_breaks = no_scripts
        .replace("</p>", "\n")
        .replace("</div>", "\n")
        .replace("</li>", "\n")
        .replace("<br>", "\n")
        .replace("<br/>", "\n")
        .replace("<br />", "\n");
    let text = tag_re.replace_all(&block_breaks, " ");
    let decoded = decode_entities(&text);

    let mut out = String::new();
    let mut blank_run = 0;
    for line in decoded.lines() {
        let collapsed = line.split_whitespace().collect::<Vec<_>>().join(" ");
        if collapsed.is_empty() {
            blank_run += 1;
            if blank_run <= 1 {
                out.push('\n');
            }
        } else {
            blank_run = 0;
            out.push_str(&collapsed);
            out.push('\n');
        }
    }
    out.trim().to_string()
}

fn strip_tags(s: &str) -> String {
    static TAG_RE: OnceLock<Regex> = OnceLock::new();
    let re = TAG_RE.get_or_init(|| Regex::new(r"(?s)<[^>]+>").unwrap());
    decode_entities(&re.replace_all(s, ""))
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

fn decode_entities(s: &str) -> String {
    s.replace("&amp;", "&")
        .replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&#x27;", "'")
        .replace("&#39;", "'")
        .replace("&nbsp;", " ")
}

fn urlencode(s: &str) -> String {
    let mut out = String::new();
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(b as char)
            }
            b' ' => out.push('+'),
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

fn urldecode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::new();
    let mut i = 0;
    while i < bytes.len() {
        match bytes[i] {
            b'%' if i + 2 < bytes.len() => {
                if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
                    out.push(v);
                    i += 3;
                    continue;
                }
                out.push(bytes[i]);
                i += 1;
            }
            b'+' => {
                out.push(b' ');
                i += 1;
            }
            b => {
                out.push(b);
                i += 1;
            }
        }
    }
    String::from_utf8_lossy(&out).into_owned()
}

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

    #[test]
    fn parses_ddg_results_page() {
        let html = r##"
        <a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fdocs&amp;rut=abc">Example <b>Docs</b></a>
        <a class="result__snippet" href="#">The official documentation for Example.</a>
        <a rel="nofollow" class="result__a" href="https://direct.example.org/page">Direct Result</a>
        "##;
        let out = parse_ddg_results(html, 5);
        assert!(out.contains("1. Example Docs"), "{out}");
        assert!(out.contains("https://example.com/docs"), "{out}");
        assert!(out.contains("official documentation"), "{out}");
        assert!(out.contains("2. Direct Result"), "{out}");
    }

    #[test]
    fn parses_mojeek_results_page() {
        let html = r#"
        <a class="title" title="https://rust-lang.org/" href="https://rust-lang.org/tools/install/">Install <b>Rust</b></a>
        <p class="s">In the latest stable release...</p>
        "#;
        let out = parse_mojeek_results(html, 5);
        assert!(out.contains("1. Install Rust"), "{out}");
        assert!(out.contains("https://rust-lang.org/tools/install/"), "{out}");
        assert!(out.contains("latest stable release"), "{out}");
    }

    #[test]
    fn html_to_text_strips_scripts_and_tags() {
        let html = "<html><head><title>x</title></head><body><script>var a=1;</script><p>Hello <b>world</b></p><div>line&nbsp;two &amp; three</div></body></html>";
        let text = html_to_text(html);
        assert!(text.contains("Hello world"), "{text}");
        assert!(text.contains("line two & three"), "{text}");
        assert!(!text.contains("var a"), "{text}");
    }

    #[test]
    fn url_codec_roundtrip() {
        assert_eq!(urlencode("rust async traits 2024"), "rust+async+traits+2024");
        assert_eq!(urldecode("https%3A%2F%2Fx.com%2Fa+b"), "https://x.com/a b");
    }
}
