//! Network tools for the assistant: `http_request` and `web_search`.
//!
//! These run **in the host process**, not through the bound substrate — the
//! default sandbox is `--network none`, so (exactly like a coding assistant's
//! web-fetch) files and shell execute inside the container while network access
//! is served from the host. They are wired into the [`GeneralExecutor`] as a
//! delegate ([`car_engine::ToolExecutor`]) so the runtime's validator, policy
//! engine, and event log still wrap every call.
//!
//! [`GeneralExecutor`]: super::executor::GeneralExecutor

use std::time::Duration;

use async_trait::async_trait;
use car_engine::ToolExecutor;
use serde_json::{json, Value};

/// Cap on a fetched body / search payload folded back into model context.
const MAX_BODY_BYTES: usize = 64 * 1024;
/// Default per-request wall-clock budget.
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const MAX_TIMEOUT_SECS: u64 = 120;

/// Keep the first `cap` bytes of `s` on a char boundary, with a marker when
/// truncated. (HTTP bodies are most useful head-first, unlike shell tails.)
fn head(s: &str, cap: usize) -> String {
    if s.len() <= cap {
        return s.to_string();
    }
    let mut end = cap;
    while !s.is_char_boundary(end) {
        end -= 1;
    }
    format!("{}…[truncated]…", &s[..end])
}

/// The two network tool schemas, in the model-facing `{name, description,
/// parameters}` shape. Advertised via `GeneralExecutor::with_delegate`.
pub fn net_tool_defs() -> Vec<Value> {
    vec![
        json!({
            "name": "http_request",
            "description": "Fetch a URL or call an HTTP API. Defaults to GET. \
                            Returns the response status and a (size-capped) body. \
                            Runs from the host, so it works even when the \
                            filesystem/shell are sandboxed offline.",
            "parameters": {
                "type": "object",
                "properties": {
                    "url": { "type": "string", "description": "Absolute http(s) URL." },
                    "method": { "type": "string", "description": "HTTP method (default GET)." },
                    "headers": { "type": "object", "description": "Optional request headers." },
                    "body": { "type": "string", "description": "Optional request body (for POST/PUT/…)." },
                    "timeout_secs": { "type": "integer", "description": "Wall-clock limit (default 30, max 120)." }
                },
                "required": ["url"]
            },
            "mutating": true,
            "tier": "full_access"
        }),
        json!({
            "name": "web_search",
            "description": "Search the web for current facts and return a short list \
                            of results (title, url, snippet). Use when you need \
                            information you don't already have.",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": { "type": "string", "description": "What to search for." },
                    "max_results": { "type": "integer", "description": "How many results to return (default 5)." }
                },
                "required": ["query"]
            },
            "tier": "full_access"
        }),
    ]
}

/// Host-side executor for `http_request` + `web_search`.
pub struct NetTools {
    client: reqwest::Client,
}

impl Default for NetTools {
    fn default() -> Self {
        Self::new()
    }
}

impl NetTools {
    pub fn new() -> Self {
        let client = reqwest::Client::builder()
            .user_agent("car-assistant/1.0")
            .build()
            .unwrap_or_default();
        Self { client }
    }

    async fn http_request(&self, params: &Value) -> Result<Value, String> {
        let url = params
            .get("url")
            .and_then(Value::as_str)
            .ok_or("http_request requires a 'url' string")?;
        if !(url.starts_with("http://") || url.starts_with("https://")) {
            return Err("url must be an absolute http(s) URL".into());
        }
        let method = params
            .get("method")
            .and_then(Value::as_str)
            .unwrap_or("GET")
            .to_uppercase();
        let m = reqwest::Method::from_bytes(method.as_bytes())
            .map_err(|_| format!("invalid HTTP method '{method}'"))?;
        let secs = params
            .get("timeout_secs")
            .and_then(Value::as_u64)
            .unwrap_or(DEFAULT_TIMEOUT_SECS)
            .clamp(1, MAX_TIMEOUT_SECS);

        let mut req = self
            .client
            .request(m, url)
            .timeout(Duration::from_secs(secs));
        if let Some(headers) = params.get("headers").and_then(Value::as_object) {
            for (k, v) in headers {
                if let Some(vs) = v.as_str() {
                    req = req.header(k, vs);
                }
            }
        }
        if let Some(body) = params.get("body").and_then(Value::as_str) {
            req = req.body(body.to_string());
        }

        let resp = req
            .send()
            .await
            .map_err(|e| format!("request failed: {e}"))?;
        let status = resp.status().as_u16();
        let text = resp
            .text()
            .await
            .map_err(|e| format!("failed to read response body: {e}"))?;
        Ok(json!({
            "status": status,
            "body": head(&text, MAX_BODY_BYTES),
        }))
    }

    async fn web_search(&self, params: &Value) -> Result<Value, String> {
        let query = params
            .get("query")
            .and_then(Value::as_str)
            .ok_or("web_search requires a 'query' string")?;
        let max = params
            .get("max_results")
            .and_then(Value::as_u64)
            .unwrap_or(5)
            .clamp(1, 15) as usize;

        // Primary: DuckDuckGo's HTML endpoint returns a real result list
        // (title/url/snippet). No key, no extra deps — we parse the anchors and
        // decode the `uddg` redirect to the destination URL.
        if let Ok(html) = self.fetch_ddg_html(query).await {
            let results = parse_ddg_html(&html, max);
            if !results.is_empty() {
                return Ok(json!({ "query": query, "results": results }));
            }
        }

        // Fallback: the instant-answer JSON API (stable, but sparse — abstracts
        // and related topics only).
        let url = format!(
            "https://api.duckduckgo.com/?q={}&format=json&no_html=1&no_redirect=1&t=car-assistant",
            urlencode(query)
        );
        let v: Value = self
            .client
            .get(&url)
            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
            .send()
            .await
            .map_err(|e| format!("search failed: {e}"))?
            .json()
            .await
            .map_err(|e| format!("failed to parse search response: {e}"))?;

        let mut results = Vec::new();
        if let Some(topics) = v.get("RelatedTopics").and_then(Value::as_array) {
            collect_topics(topics, &mut results, max);
        }
        let abstract_text = v
            .get("AbstractText")
            .and_then(Value::as_str)
            .filter(|s| !s.is_empty())
            .map(String::from);

        Ok(json!({
            "query": query,
            "abstract": abstract_text,
            "abstract_source": v.get("AbstractURL").and_then(Value::as_str),
            "results": results,
            "note": if results.is_empty() && abstract_text.is_none() {
                "No results; consider a direct http_request to a source."
            } else { "" },
        }))
    }

    async fn fetch_ddg_html(&self, query: &str) -> Result<String, String> {
        let url = format!("https://html.duckduckgo.com/html/?q={}", urlencode(query));
        let resp = self
            .client
            .get(&url)
            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
            .send()
            .await
            .map_err(|e| format!("search failed: {e}"))?;
        resp.text().await.map_err(|e| e.to_string())
    }
}

/// Extract up to `max` `{title, url, snippet}` results from a DuckDuckGo HTML
/// page. Deliberately dependency-free string parsing: find each
/// `class="result__a"` anchor, decode its `uddg=` redirect to the destination
/// URL, take the anchor text as the title, and the following `result__snippet`
/// as the snippet. Robust to markup drift in the sense that a missed field just
/// yields fewer/emptier results, never a panic.
fn parse_ddg_html(html: &str, max: usize) -> Vec<Value> {
    let mut out = Vec::new();
    for seg in html.split("class=\"result__a\"").skip(1) {
        if out.len() >= max {
            break;
        }
        let Some(href) = attr_after(seg, "href=\"") else {
            continue;
        };
        let url = decode_uddg(&href);
        if url.is_empty() {
            continue;
        }
        let title = inner_text(seg);
        let snippet = seg
            .split_once("class=\"result__snippet\"")
            .map(|(_, rest)| inner_text(rest))
            .unwrap_or_default();
        out.push(json!({ "title": title, "url": url, "snippet": snippet }));
    }
    out
}

/// The value of the first `href="..."`-style attribute starting at `marker`.
fn attr_after(s: &str, marker: &str) -> Option<String> {
    let start = s.find(marker)? + marker.len();
    let end = s[start..].find('"')? + start;
    Some(s[start..end].to_string())
}

/// Text between the first `>` and the next `<`, with HTML tags/entities
/// stripped and whitespace collapsed. Best-effort.
fn inner_text(s: &str) -> String {
    let after = s.find('>').map(|i| &s[i + 1..]).unwrap_or(s);
    let raw = after.split('<').next().unwrap_or("");
    unescape_entities(raw)
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

/// Decode a DuckDuckGo result href into the destination URL. Results wrap the
/// target in `/l/?uddg=<percent-encoded-url>`; other hrefs pass through.
fn decode_uddg(href: &str) -> String {
    let normalized = href.replace("&amp;", "&");
    if let Some(idx) = normalized.find("uddg=") {
        let rest = &normalized[idx + 5..];
        let enc = rest.split('&').next().unwrap_or("");
        return percent_decode(enc);
    }
    if let Some(stripped) = normalized.strip_prefix("//") {
        return format!("https://{stripped}");
    }
    normalized
}

/// Minimal percent-decoding (`%XX` → byte, `+` → space).
fn percent_decode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        match bytes[i] {
            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(hi), Some(lo)) = (hi, lo) {
                    out.push((hi * 16 + lo) as u8);
                    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()
}

/// Unescape the handful of HTML entities DDG emits in titles/snippets.
fn unescape_entities(s: &str) -> String {
    s.replace("&amp;", "&")
        .replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&#x27;", "'")
        .replace("&#39;", "'")
}

/// Flatten DuckDuckGo's (possibly nested) RelatedTopics into `{title, url,
/// snippet}` entries, up to `max`.
fn collect_topics(topics: &[Value], out: &mut Vec<Value>, max: usize) {
    for t in topics {
        if out.len() >= max {
            return;
        }
        if let Some(sub) = t.get("Topics").and_then(Value::as_array) {
            collect_topics(sub, out, max);
            continue;
        }
        let text = t.get("Text").and_then(Value::as_str).unwrap_or("");
        let url = t.get("FirstURL").and_then(Value::as_str).unwrap_or("");
        if text.is_empty() && url.is_empty() {
            continue;
        }
        let title = text.split(" - ").next().unwrap_or(text);
        out.push(json!({ "title": title, "url": url, "snippet": text }));
    }
}

/// Minimal percent-encoding for a query string value (RFC 3986 unreserved set
/// stays literal; everything else is `%XX`). Avoids pulling in a url-encode dep.
fn urlencode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    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)
            }
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

#[async_trait]
impl ToolExecutor for NetTools {
    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
        match tool {
            "http_request" => self.http_request(params).await,
            "web_search" => self.web_search(params).await,
            // The prefix must be exactly "unknown tool" so any runtime dispatch
            // falls through instead of hard-erroring.
            other => Err(format!("unknown tool: '{other}'")),
        }
    }
}

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

    #[test]
    fn head_truncates_on_char_boundary() {
        let s = "ééé"; // 2 bytes each
        let t = head(s, 3);
        assert!(t.starts_with('é') && t.ends_with("…[truncated]…"));
    }

    #[test]
    fn urlencode_escapes_spaces_and_specials() {
        assert_eq!(urlencode("a b&c"), "a%20b%26c");
        assert_eq!(urlencode("plain-text_1.0~"), "plain-text_1.0~");
    }

    #[test]
    fn net_tool_defs_advertises_both() {
        let defs = net_tool_defs();
        let http = defs
            .iter()
            .find(|d| d["name"] == "http_request")
            .expect("http_request def");
        assert_eq!(http["mutating"], true);
        assert_eq!(http["tier"], "full_access");
        let search = defs
            .iter()
            .find(|d| d["name"] == "web_search")
            .expect("web_search def");
        assert_eq!(search["tier"], "full_access");
        assert!(search.get("mutating").is_none());
    }

    #[tokio::test]
    async fn http_request_rejects_non_http_url() {
        let nt = NetTools::new();
        let err = nt
            .execute("http_request", &json!({ "url": "file:///etc/passwd" }))
            .await
            .unwrap_err();
        assert!(err.contains("absolute http(s)"), "{err}");
    }

    #[tokio::test]
    async fn unknown_tool_falls_through() {
        let nt = NetTools::new();
        let err = nt.execute("teleport", &json!({})).await.unwrap_err();
        assert!(err.starts_with("unknown tool"), "{err}");
    }

    #[test]
    fn percent_decode_handles_encoded_urls() {
        assert_eq!(
            percent_decode("https%3A%2F%2Fexample.com%2Fa%20b"),
            "https://example.com/a b"
        );
    }

    #[test]
    fn decode_uddg_extracts_destination() {
        let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Frust-lang.org%2F&amp;rut=abc";
        assert_eq!(decode_uddg(href), "https://rust-lang.org/");
    }

    #[test]
    fn parse_ddg_html_extracts_results() {
        // A trimmed shape of DuckDuckGo's HTML result markup.
        let html = r##"
            <div class="result">
              <a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fstd%2F&amp;rut=x">The Rust Standard Library</a>
              <a class="result__snippet" href="#">Documentation for the Rust standard library.</a>
            </div>
            <div class="result">
              <a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fcrates.io%2F">crates.io</a>
              <a class="result__snippet" href="#">The Rust package registry.</a>
            </div>
        "##;
        let results = parse_ddg_html(html, 5);
        assert_eq!(results.len(), 2);
        assert_eq!(results[0]["url"], "https://doc.rust-lang.org/std/");
        assert_eq!(results[0]["title"], "The Rust Standard Library");
        assert!(results[0]["snippet"]
            .as_str()
            .unwrap()
            .contains("standard library"));
        assert_eq!(results[1]["url"], "https://crates.io/");
        // Respects the max cap.
        assert_eq!(parse_ddg_html(html, 1).len(), 1);
    }
}
