//! fetch_url AI 工具 — URL → markdown 文档嗅探(GET HTML → markdown,去噪音 + 截断)
//!
//! 设计目标:让 LLM 高效理解网页文档(API 文档/博客/技术资料),替代 `http_request` 拿原始 HTML。
//! 原始 HTML 充满 nav/footer/script/style/广告等噪声,直接灌进 prompt 既爆 token 又稀释信号;
//! 本工具 GET HTML 后用 htmd(turndown.js 移植)转 markdown,剥离非内容节点 + 按 char 截断,
//! 让 LLM 拿到的是干净文本主体。
//!
//! ## 与 http_request 的分工
//!
//! - `http_request`:结构化 API 调用(GET/POST/鉴权/JSON),拿原始响应 body。写方法有副作用=High。
//! - `fetch_url`:**只读 GET** 网页文档嗅探,输出已清洗的 markdown。Low risk(只读,无副作用)。
//!
//! ## 安全(SSRF 防护 — 与 http_request 共享同一套)
//!
//! 复用 `http.rs` 的 `validate_url` / `resolve_and_check_host` / `build_client` / `execute_with_redirects`:
//! ① 协议白名单(仅 http/https)② 私网 IP 黑名单(RFC1918 + 链路本地 + 环回 + 元数据)③ DNS resolve 后
//! 校验 IP(防 rebinding)④ 重定向 ≤3 跳每跳重校验。fetch_url 只发 GET,无 body/写副作用。
//!
//! ## 去噪音策略
//!
//! htmd 本身会把 script/style/noscript 等非可见节点忽略(转空),但转换后可能残留少量空白行、
//! 重复 nav 链接文本。本工具追加一道轻量清理:折叠 ≥3 连续空行为 2 行、trim 行尾空白。
//! 不做激进的 DOM 删节点(易误伤正文,且 htmd 已处理主要噪声)——稳健优先。
//!
//! ## 截断
//!
//! 按 char 截断(非字节,避免切多字节 UTF-8 中间):默认 max_length=8000 chars,clamp [500, 50000]。
//! 截断尾部加 `... [markdown 已截断]` 标记,LLM 可知内容不完整。
//!
//! ## render 模式(obscura 引导,JS 渲染 SPA/反爬文档)
//!
//! `render=false`(默认)走 reqwest + htmd 静态管线,所有用户可用。
//! `render=true` 时检测本地 `obscura`(用户自带 Rust 无头浏览器,h4ckf0r0day/obscura,自带 V8):
//!   ① 已装 → spawn `obscura fetch --dump markdown <URL>` → 拿 stdout → 截断 → 返回(同静态格式)。
//!   ② 未装 / 启动失败 / 超时 → 回退静态管线 + `hint` 字段提示装 obscura 可增强 JS 渲染。
//! DevFlow 不打包 obscura(按需引导),保证 render=true 在任何环境都不致工具失败。

use std::collections::HashMap;
use std::time::{Duration, Instant};

use serde_json::{json, Value};

/// 默认 markdown 截断长度(chars)。LLM 单次 fetch 应足以覆盖一篇中等文档的主体。
const DEFAULT_MAX_LENGTH: usize = 8000;
/// markdown 截断下限(chars)。防 LLM 传极小值拿到无意义片段。
const MIN_MAX_LENGTH: usize = 500;
/// markdown 截断上限(chars)。防 LLM 传极大值仍撑爆 context(50K chars ≈ 12-15K tokens)。
const MAX_MAX_LENGTH: usize = 50_000;
/// 默认请求超时(秒)。与 http_request 一致。
const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// 超时硬上限(秒)。与 http_request 一致。
const MAX_TIMEOUT_SECS: u64 = 60;
/// 响应 body 字节上限。HTML 转 markdown 前先挡超大原始响应,防 OOM(50KB markdown 足够,
/// 但 HTML 可能远大于其 markdown,故放宽到 2MB 原始 HTML 上限)。
const MAX_HTML_BYTES: usize = 2 * 1024 * 1024;
/// obscura 子进程超时(秒)。obscura 默认 page timeout=30s,外加 V8 启动/JIT 余量给到 45s。
/// 超时即杀子进程并回退静态管线(render=true 兜底,不让 SPA 拖垮工具)。
const OBSCURA_TIMEOUT_SECS: u64 = 45;
/// obscura 未安装时的引导提示。回退静态管线后通过返回 JSON 的 `hint` 字段透传给 LLM/用户。
const OBSCURA_HINT: &str = "obscura 未安装,已用静态模式。装 obscura(Rust 无头浏览器,自带 V8)可增强 JS 渲染文档(SPA/反爬):见 github.com/h4ckf0r0day/obscura";

/// 二进制响应(含 \0)拒绝错误消息。引导式:明示改用 download_file 工具下载文件,
/// 让 AI 看到错误即知换工具(治会话 01f05167:fetch_url 拒二进制 + run_command curl 失败 → 卡死)。
const BINARY_REJECT_MSG: &str = "该 URL 返回二进制内容(图片/压缩包/文件等),fetch_url 只读 HTML 文档;\
     如需下载文件请改用 download_file 工具(支持任意文件类型,含图片/二进制/文档)";

/// fetch_url 工具 handler 入口(供 tools/fetch_url.rs register 调用)。
///
/// 参数:
/// - url: 必填,http/https
/// - max_length: 可选,markdown 截断长度 chars(默认 8000,clamp [500, 50000])
/// - render: 可选,bool,默认 false。true 时用 obscura(JS 渲染 SPA/反爬),未装/失败回退静态。
///
/// 返回 {url, title, markdown, length, truncated, render_mode, ?hint}
pub(crate) async fn execute_fetch_url(args: Value) -> anyhow::Result<Value> {
    // ── 参数解析 ──
    let url_raw = args.get("url").and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("缺少 url 参数"))?
        .trim()
        .to_string();
    if url_raw.is_empty() {
        anyhow::bail!("url 不能为空");
    }
    let max_length = (args.get("max_length").and_then(|v| v.as_u64())
        .unwrap_or(DEFAULT_MAX_LENGTH as u64) as usize)
        .clamp(MIN_MAX_LENGTH, MAX_MAX_LENGTH);
    let timeout_secs = args.get("timeout_secs").and_then(|v| v.as_u64())
        .unwrap_or(DEFAULT_TIMEOUT_SECS)
        .min(MAX_TIMEOUT_SECS)
        .max(1);
    let render = args.get("render").and_then(|v| v.as_bool()).unwrap_or(false);

    // SSRF 校验对所有模式都必做。校验放入口而非 fetch_static 内,确保 render 分支与静态分支
    // 都先过 SSRF(原 fetch_static 内的校验保留作第二道,双重校验对安全是好事,无害)。
    let (_, host, port) = crate::commands::ai::http::validate_url(&url_raw)?;
    crate::commands::ai::http::resolve_and_check_host(&host, port).await?;

    // ── render 分支 ──
    if render {
        match fetch_with_obscura(&url_raw, max_length).await {
            Ok(value) => {
                if value.get("render_mode").and_then(|v| v.as_str()) == Some("static_fallback") {
                    tracing::info!("obscura 回退静态模式:hint={:?}",
                        value.get("hint").and_then(|v| v.as_str()).unwrap_or(""));
                }
                return Ok(value);
            }
            Err(e) => {
                // 不该发生(fetch_with_obscura 内部已把所有错误转成回退+hint),但兜底再保险一道。
                tracing::warn!("obscura 分支异常({}),回退静态", e);
            }
        }
    }

    // ── 静态管线(reqwest + htmd,默认/回退共用) ──
    let mut result = fetch_static(&url_raw, max_length, timeout_secs, &args).await?;
    if render {
        // render=true 但走到了静态管线(未装/失败/异常)→ 标记并塞 hint。
        result["render_mode"] = json!("static_fallback");
        result["hint"] = json!(OBSCURA_HINT);
    } else {
        result["render_mode"] = json!("static");
    }
    Ok(result)
}

/// 静态管线:reqwest GET + htmd 转 markdown + 去噪音 + 截断。
///
/// 从原 execute_fetch_url 抽出,供默认/回退共用。SSRF 校验在 execute_fetch_url 入口已做,
/// 这里保留第二道校验(execute_fetch_url 改动前即有,移除会破坏单元测试触发的早期拒绝路径)。
async fn fetch_static(
    url_raw: &str,
    max_length: usize,
    timeout_secs: u64,
    args: &Value,
) -> anyhow::Result<Value> {
    // headers: 容许 LLM 传自定义头(如 User-Agent,部分站点拒默认 UA),复用 http_request 同款解析。
    let headers: HashMap<String, String> = match args.get("headers") {
        Some(Value::Object(m)) => m.iter()
            .filter_map(|(k, v)| {
                let s = match v {
                    Value::String(s) => s.clone(),
                    other => other.to_string(),
                };
                Some((k.clone(), s))
            })
            .collect(),
        _ => {
            // 默认带一个浏览器 UA:不少站点(如 MDN/GitHub)对非浏览器 UA 返回简化/拒绝页面,
            // 给一个主流 UA 拿到完整渲染 HTML,提升 markdown 质量。LLM 显式传 headers 则不覆盖。
            let mut h = HashMap::new();
            h.insert(
                "User-Agent".to_string(),
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36".to_string(),
            );
            h
        }
    };
    let body: Option<String> = None; // fetch_url 只 GET,无 body

    // ── SSRF 校验(第二道,见函数注释) ──
    let (scheme, host, port) = crate::commands::ai::http::validate_url(url_raw)?;
    crate::commands::ai::http::resolve_and_check_host(&host, port).await?;

    // ── 构建限制 client + 执行(GET,手动重定向循环复用 http.rs) ──
    let client = crate::commands::ai::http::build_client(Duration::from_secs(timeout_secs))?;
    let started = Instant::now();
    let resp = crate::commands::ai::http::execute_with_redirects(
        &client,
        reqwest::Method::GET,
        url_raw.to_string(),
        &headers,
        &body,
        crate::commands::ai::http::MAX_REDIRECTS,
    )
    .await?;
    let elapsed_ms = started.elapsed().as_millis() as u64;

    let final_url = resp.url().to_string();
    let status = resp.status().as_u16();
    if !resp.status().is_success() {
        anyhow::bail!(
            "fetch 失败:HTTP {} {}({})",
            status,
            resp.status().canonical_reason().unwrap_or(""),
            final_url
        );
    }

    // ── 读取 body + 大小挡板 ──
    let bytes = resp.bytes().await
        .map_err(|e| anyhow::anyhow!("读取响应 body 失败: {}", e))?;
    let total_bytes = bytes.len();
    if total_bytes > MAX_HTML_BYTES {
        anyhow::bail!(
            "响应过大:{} 字节超过 {} 上限,fetch_url 不适合超大原始 HTML(改用 http_request 分段)",
            total_bytes, MAX_HTML_BYTES
        );
    }
    // 含 \0 → 二进制(非 HTML),直接拒(PDF/图片等走专用工具,fetch_url 只处理文本网页)。
    // 错误文案引导式:明示改用 download_file(支持任意文件类型,图片/压缩包/二进制均可下载),
    // 让 AI 看到错误即知换工具,避免卡死在 fetch_url / run_command curl 死循环(实测会话 01f05167)。
    // 错误消息抽为 const 便于单元测试断言(无需真实网络/含 \0 响应即可验证引导文案)。
    if bytes.contains(&0u8) {
        anyhow::bail!("{}", BINARY_REJECT_MSG);
    }
    let html = String::from_utf8_lossy(&bytes).into_owned();

    // ── 提取 title(<title>...</title>,正则避免引 HTML 解析重依赖) ──
    let title = extract_title(&html);

    // ── HTML → markdown(htmd:turndown.js 移植,自动忽略 script/style) ──
    // htmd::convert 返回 Result(解析可能失败,如畸形 HTML)。失败时不阻塞 —— 降级用原文 lossy 文本,
    // LLM 仍可读到内容(质量差于 markdown 但不致工具整体失败)。
    let mut markdown = match htmd::convert(&html) {
        Ok(md) => md,
        Err(e) => {
            tracing::warn!("htmd 转 markdown 失败({}),降级用原始 HTML 文本", e);
            html.clone()
        }
    };

    // ── 去噪音:折叠 ≥3 连续空行 → 2 行、trim 行尾空白 ──
    markdown = cleanup_markdown(&markdown);

    // ── 截断(按 char 边界) ──
    let (markdown, truncated) = truncate_chars(&markdown, max_length);

    Ok(json!({
        "url": final_url,
        "scheme": scheme,
        "status": status,
        "title": title,
        "markdown": markdown,
        "length": markdown.chars().count(),
        "html_bytes": total_bytes,
        "truncated": truncated,
        "elapsed_ms": elapsed_ms,
    }))
}

/// 用 obscura(JS 渲染)抓取 URL 并转 markdown。
///
/// 流程:检测 obscura 在 PATH/已知路径 → spawn `obscura fetch --dump markdown <URL>`(带超时)
/// → 取 stdout → 截断 → 返回与静态管线同构的 JSON(render_mode=obscura)。
///
/// ## 两次尝试的鲁棒性(缺陷 3+4)
///
/// - **缺陷 3**:obscura 非零退出码时不直接回退——先看 stdout 是否仍有实质内容(V8 watchdog
///   kill 时 exit≠0 但 stdout 可能已加载大部分内容),有则用 partial 标记,无才回退。
/// - **缺陷 4**:`fetch --dump markdown` 对 JS 重的 SPA(如 Next.js hydration)可能输出空壳
///   (watchdog kill 在 dump 前)。此时自动 fallback 再试 `scrape --eval "document.body.innerText"`
///   ——后者走不同管线(innerText 纯文本,不等 JS 全执行完),对 SPA 更鲁棒。
///   两次都空壳/无实质内容才回退静态管线。
///
/// 任何环节最终失败(未装 / spawn 失败 / 两次都超时 / 都空壳)都不向上抛,而是回退静态管线:
/// 内部调 fetch_static 拿结果,塞 hint 字段提示装 obscura。这样 render=true 在任何环境
/// 都不会因 obscura 缺失/故障而让工具整体失败(引导式安装的核心保证)。
async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<Value> {
    let started = Instant::now();

    // ── 检测 obscura ──
    let obscura_path = match find_obscura() {
        Some(p) => p,
        None => {
            // 未装:回退静态 + hint。
            let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
            fallback["render_mode"] = json!("obscura_not_installed");
            fallback["hint"] = json!(OBSCURA_HINT);
            fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
            return Ok(fallback);
        }
    };

    // ── 第 1 次尝试:fetch --dump markdown(输出结构 markdown,优先) ──
    // obscura 内部管 page load timeout(默认 30s),我们外加 tokio timeout 兜底 V8 启动/JIT
    // 卡死等 obscura 自身超时管不到的场景。
    match run_obscura(&obscura_path, &["fetch", "--dump", "markdown", url_raw]).await {
        ObscuraRunResult::Markdown(md) => {
            return Ok(build_obscura_json(url_raw, &md, max_length, started, "obscura", None));
        }
        ObscuraRunResult::Partial(md) => {
            // 缺陷 3:非零退出码但 stdout 有实质内容(watchdog 截断)→ 用 partial 结果,不回退。
            return Ok(build_obscura_json(
                url_raw,
                &md,
                max_length,
                started,
                "obscura_partial",
                Some("obscura 非零退出(可能 watchdog 截断),已用部分渲染结果"),
            ));
        }
        ObscuraRunResult::SpawnFailed(e) => {
            tracing::warn!("obscura spawn 失败({}),回退静态", e);
            let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
            fallback["render_mode"] = json!("obscura_failed");
            fallback["hint"] = json!(format!("obscura 启动失败({}),已用静态模式。{}", e, OBSCURA_HINT));
            fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
            return Ok(fallback);
        }
        ObscuraRunResult::Timeout => {
            tracing::warn!("obscura fetch 超时({}s),改试 scrape --eval", OBSCURA_TIMEOUT_SECS);
            // 超时也走 scrape --eval 兜底(scraper 管线不同,可能更快返回)。
        }
        ObscuraRunResult::EmptyShell => {
            // 缺陷 4:fetch markdown 空壳 → fallback scrape --eval。
            tracing::info!("obscura fetch --dump markdown 空壳/无实质内容,改用 scrape --eval(innerText)");
        }
    }

    // ── 第 2 次尝试:scrape --eval(输出 innerText 纯文本,对 SPA 鲁棒,缺陷 4) ──
    match run_obscura(
        &obscura_path,
        &["scrape", "--eval", "document.body.innerText", url_raw],
    )
    .await
    {
        ObscuraRunResult::Markdown(md) | ObscuraRunResult::Partial(md) => {
            // scrape --eval 拿到内容:innerText 纯文本无 markdown 结构,但能拿到正文。
            return Ok(build_obscura_json(
                url_raw,
                &md,
                max_length,
                started,
                "obscura_scrape_eval",
                Some("obscura fetch markdown 空壳,改用 innerText 纯文本"),
            ));
        }
        ObscuraRunResult::SpawnFailed(e) => {
            tracing::warn!("obscura scrape spawn 失败({}),回退静态", e);
            let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
            fallback["render_mode"] = json!("obscura_failed");
            fallback["hint"] = json!(format!("obscura 启动失败({}),已用静态模式。{}", e, OBSCURA_HINT));
            fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
            return Ok(fallback);
        }
        ObscuraRunResult::Timeout => {
            tracing::warn!("obscura scrape --eval 也超时({}s),回退静态", OBSCURA_TIMEOUT_SECS);
        }
        ObscuraRunResult::EmptyShell => {
            tracing::warn!("obscura scrape --eval 也无实质内容,回退静态");
        }
    }

    // ── 两次都失败/空壳:回退静态管线 ──
    let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
    fallback["render_mode"] = json!("static_fallback");
    fallback["hint"] = json!(format!(
        "obscura 渲染失败(fetch markdown 与 scrape --eval 均无实质内容),已用静态模式。{}",
        OBSCURA_HINT
    ));
    fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
    Ok(fallback)
}

/// obscura 单次执行结果(供 fetch_with_obscura 判定 partial/空壳/失败)。
///
/// - `Markdown`:退出码 0 且 stdout 有实质内容(正常成功)。
/// - `Partial`:非零退出码但 stdout 仍有实质内容(缺陷 3:watchdog kill 截断,内容已部分加载)。
/// - `EmptyShell`:stdout 无实质内容(空 / 纯框架标签 / 极短)。caller 应尝试别的管线或回退。
/// - `Timeout`:超过 OBSCURA_TIMEOUT_SECS 被杀。
/// - `SpawnFailed`:spawn 本身失败(obscura 二进制损坏等)。
enum ObscuraRunResult {
    Markdown(String),
    Partial(String),
    EmptyShell,
    Timeout,
    SpawnFailed(String),
}

/// spawn obscura 带指定子命令参数,带进程级超时,返回 stdout 内容分类。
///
/// fetch --dump markdown 与 scrape --eval 共用此 helper(同样的 spawn 装配 + 超时 + 实质内容判定),
/// 保证两条管线鲁棒性一致、且都带 CREATE_NO_WINDOW(防弹窗)。
async fn run_obscura(obscura_path: &str, args: &[&str]) -> ObscuraRunResult {
    let mut cmd = tokio::process::Command::new(obscura_path);
    cmd.args(args)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());
    // CREATE_NO_WINDOW:防 obscura spawn 弹控制台窗口闪烁(对齐 shell.rs build_command)。
    #[cfg(windows)]
    {
        cmd.creation_flags(0x0800_0000);
    }

    let output = match tokio::time::timeout(
        Duration::from_secs(OBSCURA_TIMEOUT_SECS),
        cmd.output(),
    )
    .await
    {
        Ok(Ok(o)) => o,
        Ok(Err(e)) => return ObscuraRunResult::SpawnFailed(format!("{}", e)),
        Err(_) => return ObscuraRunResult::Timeout,
    };

    let success = output.status.success();
    let stderr = String::from_utf8_lossy(&output.stderr);
    if !success {
        // 保留原 :297 的 stderr warn 日志(诊断 obscura 非零退出原因)。
        tracing::warn!("obscura 非零退出({}),stderr: {}", output.status, stderr.trim());
    }

    // obscura 偶发在 stdout 前后带空白/日志行,trim 首尾;内部仍走 cleanup 折叠空行。
    let markdown = cleanup_markdown(String::from_utf8_lossy(&output.stdout).trim());

    if has_substantial_content(&markdown) {
        if success {
            ObscuraRunResult::Markdown(markdown)
        } else {
            // 缺陷 3:非零退出码但有实质内容(watchdog 截断)。
            ObscuraRunResult::Partial(markdown)
        }
    } else {
        ObscuraRunResult::EmptyShell
    }
}

/// 判定 obscura stdout 是否有实质内容(缺陷 3+4 共用)。
///
/// 用于区分:watchdog kill 时 stdout 已加载大部分内容(可用 partial)vs SPA 空壳(无可读正文)。
/// 判定规则(层层剔除空壳形态):
/// 1. trim 后 <500 chars → 无实质内容(空/极短)。
/// 2. 从原文移除常见空壳骨架(`<div id=root></div>` 等 hydration 占位标签)。
/// 3. 从剩余文本移除占位词(loading / please enable javascript / 404 / not found 等)。
/// 4. 剔除后剩余「非空白字符」<200 → 判空壳(原文虽长但全是占位骨架/占位词)。
/// 真实正文即使在 watchdog 截断后,也会保留大量非占位正文 char → 通过。
fn has_substantial_content(md: &str) -> bool {
    let trimmed = md.trim();
    if trimmed.chars().count() < 500 {
        return false;
    }
    let lower = trimmed.to_lowercase();
    // 第 2 步:移除空壳骨架标签(hydration 占位)。
    let shell_markers = [
        "<div id=\"root\"></div>",
        "<div id=root></div>",
        "<div id=\"app\"></div>",
        "<div id=app></div>",
        "<noscript>",
        "</noscript>",
    ];
    let mut stripped: String = lower.clone();
    for marker in shell_markers {
        stripped = stripped.replace(marker, "");
    }
    // 第 3 步:移除占位词(空壳站点常常只有这类文案)。
    for placeholder in [
        "loading",
        "please wait",
        "please enable javascript",
        "enable javascript",
        "enable js",
        "javascript is required",
        "404",
        "not found",
        "page not found",
    ] {
        stripped = stripped.replace(placeholder, "");
    }
    // 第 4 步:剩余「有效字符」(非空白且非纯标点 .,!?:; 等)<200 → 判空壳。
    // 标点不计入有效内容:Loading... 去掉 loading 后剩 ... 是空壳残留标点,非正文。
    let non_ws_chars: usize = stripped
        .chars()
        .filter(|c| !c.is_whitespace() && !matches!(c, '.' | ',' | '!' | '?' | ':' | ';' | '-' | '_' | '|' | '*' | '#' | '<' | '>' | '/' | '=' | '"' | '\''))
        .count();
    non_ws_chars >= 200
}

/// 组装 obscura 成功路径 JSON(三种 render_mode:obscura / obscura_partial / obscura_scrape_eval)。
///
/// 抽出共用:三种成功路径(正常/partial/scrape_eval)的 JSON 结构除 render_mode 与 hint
/// 外完全一致(同样从 markdown 启发式取 title、截断、算 length/html_bytes_est)。
fn build_obscura_json(
    url_raw: &str,
    markdown_raw: &str,
    max_length: usize,
    started: Instant,
    render_mode: &str,
    hint: Option<&str>,
) -> Value {
    let html_bytes_est = markdown_raw.len(); // obscura 不给原始 HTML 字节数,用 markdown 长度近似(仅信息字段)
    // 截断(按 char 边界,与静态一致)
    let (markdown, truncated) = truncate_chars(markdown_raw, max_length);
    let elapsed_ms = started.elapsed().as_millis() as u64;
    let title = extract_title_from_markdown(&markdown);
    // scheme:obscura 路径下重新 validate_url 拿 scheme(已在入口校验过,这里仅取字段值,失败兜底 https)。
    let scheme = crate::commands::ai::http::validate_url(url_raw)
        .map(|(s, _, _)| s)
        .unwrap_or_else(|_| "https".to_string());

    let mut v = json!({
        "url": url_raw,
        "scheme": scheme,
        "status": 200u16, // obscura 成功路径不暴露 HTTP status(已渲染),用 200 占位
        "title": title,
        "markdown": markdown,
        "length": markdown.chars().count(),
        "html_bytes": html_bytes_est,
        "truncated": truncated,
        "elapsed_ms": elapsed_ms,
        "render_mode": render_mode,
    });
    if let Some(h) = hint {
        v["hint"] = json!(h);
    }
    v
}

/// 检测 obscura 是否已安装且可执行。优先级:PATH > 已知 npm-global 路径。
///
/// 用 `obscura --version` 探活(比 `which` 跨平台:Windows 无 which,且 --version 能确认
/// 二进制可跑而非仅存在)。同步探活带 15s 超时防卡(对齐 module.rs::run_command 的 thread+channel 风格)。
/// 探活结果全程 tracing 日志(debug:每个 candidate 尝试/exit/超时;warn:全部失败列出试过的路径),
/// 让 Tauri 进程 PATH 与 PowerShell 不同导致的间歇性失败可诊断。
fn find_obscura() -> Option<String> {
    let candidates: &[&str] = &[
        "obscura", // PATH 优先(用户 npm i -g 后即在 PATH)
        "/d/NodeJS/npm-global/obscura", // 已知安装路径(用户环境,POSIX 形式 git-bash 友好)
        "/d/NodeJS/npm-global/obscura.exe",
        "D:\\NodeJS\\npm-global\\obscura.exe", // Windows 原生路径(tauri 在 Windows 跑)
    ];

    let mut tried: Vec<&str> = Vec::with_capacity(candidates.len());
    for cand in candidates {
        tracing::debug!("obscura 探测尝试 candidate: {}", cand);
        let (tx, rx) = std::sync::mpsc::channel();
        let cand_owned = cand.to_string();
        std::thread::spawn(move || {
            let mut c = std::process::Command::new(&cand_owned);
            c.arg("--version")
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::null());
            // CREATE_NO_WINDOW:防 obscura --version 探测弹控制台(每次 fetch_url render=true 都探测,不弹窗)。
            #[cfg(windows)]
            {
                use std::os::windows::process::CommandExt;
                c.creation_flags(0x0800_0000);
            }
            let out = c.output();
            let _ = tx.send(out);
        });
        match rx.recv_timeout(Duration::from_secs(15)) {
            Ok(Ok(o)) if o.status.success() => {
                tracing::debug!("obscura 探测命中 candidate: {}", cand);
                return Some(cand.to_string());
            }
            Ok(Ok(o)) => {
                tracing::debug!("obscura 探测失败 candidate={}, exit={}", cand, o.status);
                tried.push(cand);
            }
            Ok(Err(e)) => {
                tracing::debug!("obscura 探测 spawn 失败 candidate={}, err={}", cand, e);
                tried.push(cand);
            }
            Err(_) => {
                tracing::debug!("obscura 探测超时(15s) candidate={}", cand);
                tried.push(cand);
            }
        }
    }
    tracing::warn!("obscura 所有 candidate 探测失败,试过: {:?}", tried);
    None
}

/// 从 markdown 启发式提取 title:首个 `# 一级标题` 或首个非空文本行(≤120 chars)。
/// obscura dump markdown 无独立 title 字段,此为最佳近似(静态管线有 <title>,此函数仅 render 分支用)。
fn extract_title_from_markdown(md: &str) -> Option<String> {
    for line in md.lines() {
        let t = line.trim();
        if t.is_empty() {
            continue;
        }
        // 首个 H1 标题
        if let Some(rest) = t.strip_prefix("# ") {
            let title = rest.trim();
            if !title.is_empty() {
                return Some(title.to_string());
            }
        }
        // 否则取首个非空、非表格/代码块的行(去掉 markdown 强调前缀)
        if !t.starts_with('|') && !t.starts_with("```") {
            let cleaned: String = t.trim_start_matches(|c: char| c == '*' || c == '-').trim().to_string();
            if !cleaned.is_empty() && cleaned.chars().count() <= 120 {
                return Some(cleaned);
            }
        }
    }
    None
}

/// 提取 HTML <title> 内容。case-insensitive,取首个,trim 空白。
/// 用简单字符串扫描而非 regex/HTML 解析:title 标签结构固定且少,正则/解析引依赖不值。
fn extract_title(html: &str) -> Option<String> {
    let lower = html.to_lowercase();
    let open = lower.find("<title")?;
    // 跳过 `<title ...>` 到 '>'(可能含属性,但 <title> 几乎不带属性)
    let after_open_tag = &html[open..];
    let gt = after_open_tag.find('>')?;
    let content_start = open + gt + 1;
    let rest = &html[content_start..];
    // 找 </title>(case-insensitive)
    let rest_lower = &lower[content_start..];
    let close = rest_lower.find("</title>")?;
    let title = &rest[..close];
    let trimmed = title.trim();
    if trimmed.is_empty() {
        None
    } else {
        // 去换行/多空格压缩,防 title 跨行带大量空白
        let squashed: String = trimmed.split_whitespace().collect::<Vec<_>>().join(" ");
        Some(squashed)
    }
}

/// 折叠 ≥3 连续空行 → 恰好 2 行,trim 每行尾空白。htmd 输出常有连续空行(nav 块转空)。
fn cleanup_markdown(md: &str) -> String {
    let mut out = String::with_capacity(md.len());
    let mut blank_run = 0usize;
    for line in md.split_inclusive('\n') {
        // split_inclusive 保留 '\n';末行可能无 '\n'
        let content = line.trim_end_matches('\n');
        let is_blank = content.trim().is_empty();
        if is_blank {
            blank_run += 1;
            if blank_run <= 2 {
                out.push('\n');
            }
            // blank_run > 2:丢弃(折叠)
        } else {
            blank_run = 0;
            // trim 行尾空白,保留行首缩进(markdown 列表/代码块缩进有意义)
            out.push_str(content.trim_end());
            out.push('\n');
        }
    }
    out
}

/// 按 char 边界截断 markdown。返回 (截断后 String, 是否截断)。
/// 不切多字节 UTF-8 中间(char_indices 保证落在 char 边界)。
fn truncate_chars(md: &str, max: usize) -> (String, bool) {
    let char_count = md.chars().count();
    if char_count <= max {
        return (md.to_string(), false);
    }
    // 找到第 max 个 char 的字节位置
    let byte_cutoff = md.char_indices()
        .nth(max)
        .map(|(idx, _)| idx)
        .unwrap_or(md.len());
    let head = &md[..byte_cutoff];
    let total = char_count;
    (
        format!(
            "{}\n\n... [markdown 已截断,原文 {} chars,仅保留前 {} chars]",
            head.trim_end(),
            total,
            max
        ),
        true,
    )
}

// ============================================================
// 单元测试
//
// 覆盖纯函数(extract_title / cleanup_markdown / truncate_chars),确定性零网络。
// handler 集成层走真实网络 #[ignore](对齐 http.rs 测试策略)。
// ============================================================

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

    // ── 二进制拒绝错误文案(引导式:含 download_file 提示) ──
    // 治会话 01f05167:fetch_url 拒二进制后 AI 不知换工具,卡死在 run_command curl。
    // 错误消息必须明示 download_file,让 AI 看到错误即换工具。纯 const 断言零网络零依赖。

    #[test]
    fn test_binary_reject_msg_guides_to_download_file() {
        // 错误消息必须含 download_file 工具名(引导换工具的核心)
        assert!(BINARY_REJECT_MSG.contains("download_file"));
        // 必须明示 fetch_url 只读 HTML(让 AI 理解为何被拒)
        assert!(BINARY_REJECT_MSG.contains("HTML"));
        // 必须提及图片/二进制等关键词(匹配 AI 触发场景:用户给 .png URL)
        assert!(BINARY_REJECT_MSG.contains("二进制"));
        assert!(BINARY_REJECT_MSG.contains("图片"));
    }

    // ── extract_title ──

    #[test]
    fn test_extract_title_basic() {
        let html = "<html><head><title>Hello World</title></head><body>x</body></html>";
        assert_eq!(extract_title(html).as_deref(), Some("Hello World"));
    }

    #[test]
    fn test_extract_title_case_insensitive() {
        let html = "<TITLE>Case Test</TITLE>";
        assert_eq!(extract_title(html).as_deref(), Some("Case Test"));
    }

    #[test]
    fn test_extract_title_multiline_squashed() {
        let html = "<title>\n  Multi\n  Line  Title  \n</title>";
        assert_eq!(extract_title(html).as_deref(), Some("Multi Line Title"));
    }

    #[test]
    fn test_extract_title_missing_returns_none() {
        let html = "<html><body>no title here</body></html>";
        assert_eq!(extract_title(html), None);
    }

    #[test]
    fn test_extract_title_empty_returns_none() {
        let html = "<title>   </title>";
        assert_eq!(extract_title(html), None);
    }

    // ── cleanup_markdown ──

    #[test]
    fn test_cleanup_collapses_many_blank_lines() {
        let md = "para1\n\n\n\n\npara2";
        let out = cleanup_markdown(md);
        // 4 连续空行折叠为 2
        assert_eq!(out, "para1\n\n\npara2\n");
    }

    #[test]
    fn test_cleanup_trims_trailing_whitespace() {
        let md = "line with trailing spaces   \nnext";
        let out = cleanup_markdown(md);
        assert_eq!(out, "line with trailing spaces\nnext\n");
    }

    #[test]
    fn test_cleanup_preserves_leading_indent() {
        // markdown 代码块/列表缩进有意义,不能 trim 行首
        let md = "    code line\n- list item";
        let out = cleanup_markdown(md);
        assert_eq!(out, "    code line\n- list item\n");
    }

    #[test]
    fn test_cleanup_keeps_two_blank_lines() {
        let md = "a\n\n\nb";
        let out = cleanup_markdown(md);
        assert_eq!(out, "a\n\n\nb\n");
    }

    // ── extract_title_from_markdown(仅 render=obscura 分支用) ──

    #[test]
    fn test_md_title_from_h1() {
        let md = "# Rust 异步编程指南\n\n正文内容";
        assert_eq!(extract_title_from_markdown(md).as_deref(), Some("Rust 异步编程指南"));
    }

    #[test]
    fn test_md_title_fallback_first_line() {
        // 无 H1,取首个非空行(去掉强调前缀)
        let md = "**Welcome** to my site\n\n更多内容";
        assert_eq!(extract_title_from_markdown(md).as_deref(), Some("Welcome** to my site"));
    }

    #[test]
    fn test_md_title_skips_blank_and_code() {
        let md = "\n\n```\ncode block\n```\n# Real Title\nbody";
        assert_eq!(extract_title_from_markdown(md).as_deref(), Some("Real Title"));
    }

    #[test]
    fn test_md_title_empty_returns_none() {
        assert_eq!(extract_title_from_markdown(""), None);
        assert_eq!(extract_title_from_markdown("   \n\n  "), None);
    }

    // ── truncate_chars ──

    #[test]
    fn test_truncate_under_limit_unchanged() {
        let md = "short content";
        let (out, trunc) = truncate_chars(md, 1000);
        assert!(!trunc);
        assert_eq!(out, "short content");
    }

    #[test]
    fn test_truncate_over_limit_marks_truncated() {
        let md = "abcdefghij".repeat(1000); // 10000 chars
        let (out, trunc) = truncate_chars(&md, 100);
        assert!(trunc);
        assert!(out.contains("[markdown 已截断"));
        assert!(out.contains("原文 10000 chars"));
        assert!(out.contains("仅保留前 100 chars"));
    }

    #[test]
    fn test_truncate_respects_char_boundary_unicode() {
        // 中文每字 3 字节,截 5 char 应得 15 字节(不在中间切)
        let md = "你好世界测试内容"; // 8 chars,每字 3 字节
        let (out, trunc) = truncate_chars(md, 5);
        assert!(trunc);
        // 截断点应在 "你好世界测" 后(5 chars = 15 bytes),无半个字
        assert!(out.starts_with("你好世界测\n\n"));
        // 不应出现 U+FFFD(乱码替换符)
        assert!(!out.contains('\u{FFFD}'));
    }

    // ── has_substantial_content(缺陷 3+4 共用判定) ──

    #[test]
    fn test_substantial_content_real_markdown_passes() {
        // 真实正文:600 chars,无空壳标记,应判有实质内容
        let md = "# 异步编程指南\n\n这是一篇关于 Rust 异步编程的详细指南。\n".repeat(15);
        assert!(has_substantial_content(&md));
    }

    #[test]
    fn test_substantial_content_short_returns_false() {
        // <500 chars → 无实质内容
        assert!(!has_substantial_content("short content only"));
        assert!(!has_substantial_content(""));
        assert!(!has_substantial_content("   \n\n  "));
    }

    #[test]
    fn test_substantial_content_empty_shell_root_div_rejected() {
        // SPA hydration 空壳典型:仅 <div id="root"></div> + 大量空白填充到 >500 chars
        let padding = " ".repeat(600);
        let shell = format!("<div id=\"root\"></div>\n{}", padding);
        assert!(!has_substantial_content(&shell));
    }

    #[test]
    fn test_substantial_content_empty_shell_app_div_rejected() {
        // 仅 <div id="app"></div> 空壳
        let padding = "\n".repeat(600);
        let shell = format!("<div id=\"app\"></div>{}", padding);
        assert!(!has_substantial_content(&shell));
    }

    #[test]
    fn test_substantial_content_loading_placeholder_rejected() {
        // 纯 "Loading..." 占位符重复(去掉 "loading" 后仅剩标点/空白)→ 空壳
        let placeholder = "Loading...\n".repeat(120);
        assert!(!has_substantial_content(&placeholder));
    }

    #[test]
    fn test_substantial_content_enable_js_rejected() {
        // 纯 "Please enable JavaScript" 占位(去掉占位词后仅剩空白)→ 空壳
        let placeholder = "Please enable JavaScript\n".repeat(40);
        assert!(!has_substantial_content(&placeholder));
    }

    #[test]
    fn test_substantial_content_mixed_real_content_passes() {
        // 含真实正文(loading 标题但有正文)→ 应判有实质内容
        let md = "# Loading data\n\n".to_string()
            + &"这是真实的正文内容,描述了一篇技术文章的核心要点。".repeat(20);
        assert!(has_substantial_content(&md));
    }

    // ── ObscuraRunResult 分类逻辑(缺陷 3:partial 分支) ──
    //
    // 注:run_obscura 依赖真实 obscura 二进制 + 网络,此处不直接测 spawn,而是间接验证
    // has_substantial_content 的判定正确性(它是 run_obscura 内 partial vs emptyshell 的核心)。
    // 上面 has_substantial_content 测试已覆盖:有实质内容 → Markdown/Partial 分支可命中;
    // 空壳 → EmptyShell 分支可命中。

    #[test]
    fn test_obscura_partial_classification_via_substantial_check() {
        // 模拟 watchdog 截断:obscura 非零退出但 stdout 已加载部分真实内容(>500 chars 正文)。
        // run_obscura 会判 success=false + has_substantial_content=true → ObscuraRunResult::Partial。
        let partial_stdout = "# 文章标题\n\n".to_string()
            + &"这是 watchdog 截断前已加载的部分正文内容,包含真实信息。".repeat(20);
        // has_substantial_content 是 Partial 分支的唯一判据
        assert!(has_substantial_content(&partial_stdout));
    }

    #[test]
    fn test_obscura_emptyshell_classification_via_substantial_check() {
        // 模拟 SPA 空壳:obscura 输出仅 hydration 占位 → EmptyShell → 触发 scrape --eval fallback
        let empty_stdout = format!("<div id=\"root\"></div>\n{}", " ".repeat(600));
        assert!(!has_substantial_content(&empty_stdout));
    }

    // ── build_obscura_json(render_mode 三态 + hint) ──

    #[test]
    fn test_build_obscura_json_normal_mode_no_hint() {
        let md = "# 标题\n\n正文内容".to_string();
        let started = Instant::now();
        let v = build_obscura_json("https://example.com/", &md, 1000, started, "obscura", None);
        assert_eq!(v["render_mode"].as_str().unwrap(), "obscura");
        assert_eq!(v["status"].as_u64().unwrap(), 200);
        assert_eq!(v["url"].as_str().unwrap(), "https://example.com/");
        assert_eq!(v["title"].as_str().unwrap(), "标题");
        assert_eq!(v["scheme"].as_str().unwrap(), "https");
        // hint 字段不存在
        assert!(v.get("hint").is_none());
    }

    #[test]
    fn test_build_obscura_json_partial_mode_with_hint() {
        // 缺陷 3:obscura_partial render_mode + hint 说明 watchdog 截断
        let md = "# 截断的标题\n\n".to_string() + &"部分正文。".repeat(50);
        let started = Instant::now();
        let v = build_obscura_json(
            "https://example.com/",
            &md,
            10000,
            started,
            "obscura_partial",
            Some("obscura 非零退出(可能 watchdog 截断),已用部分渲染结果"),
        );
        assert_eq!(v["render_mode"].as_str().unwrap(), "obscura_partial");
        let hint = v["hint"].as_str().unwrap();
        assert!(hint.contains("watchdog"));
        assert!(hint.contains("部分渲染"));
    }

    #[test]
    fn test_build_obscura_json_scrape_eval_mode() {
        // 缺陷 4:obscura_scrape_eval render_mode + hint 说明改用 innerText
        let md = "这是 innerText 纯文本正文,无 markdown 结构。".repeat(20);
        let started = Instant::now();
        let v = build_obscura_json(
            "https://example.com/",
            &md,
            10000,
            started,
            "obscura_scrape_eval",
            Some("obscura fetch markdown 空壳,改用 innerText 纯文本"),
        );
        assert_eq!(v["render_mode"].as_str().unwrap(), "obscura_scrape_eval");
        let hint = v["hint"].as_str().unwrap();
        assert!(hint.contains("innerText"));
        // innerText 无 markdown 结构 → extract_title_from_markdown 走首行 fallback
        assert!(v["title"].as_str().is_some());
    }

    #[test]
    fn test_build_obscura_json_truncation_applies() {
        // build_obscura_json 内部应走 truncate_chars:超长 markdown 截断标记
        let md: String = "abcdefghij".repeat(2000); // 20000 chars
        let started = Instant::now();
        let v = build_obscura_json("https://example.com/", &md, 1000, started, "obscura", None);
        assert_eq!(v["truncated"], true);
        assert!(v["markdown"].as_str().unwrap().contains("[markdown 已截断"));
    }

    // ── handler 参数边界 ──

    #[tokio::test]
    async fn test_handler_missing_url_errors() {
        let args = json!({ "max_length": 1000 });
        let err = execute_fetch_url(args).await.unwrap_err();
        assert!(format!("{}", err).contains("缺少 url"));
    }

    #[tokio::test]
    async fn test_handler_empty_url_errors() {
        let args = json!({ "url": "   " });
        let err = execute_fetch_url(args).await.unwrap_err();
        assert!(format!("{}", err).contains("url 不能为空"));
    }

    #[tokio::test]
    async fn test_handler_rejects_localhost() {
        let args = json!({ "url": "http://localhost:8080/" });
        let err = execute_fetch_url(args).await.unwrap_err();
        assert!(format!("{}", err).contains("localhost") || format!("{}", err).contains("SSRF"));
    }

    #[tokio::test]
    async fn test_handler_rejects_private_ip() {
        let args = json!({ "url": "http://169.254.169.254/latest/" });
        let err = execute_fetch_url(args).await.unwrap_err();
        assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF"));
    }

    #[tokio::test]
    async fn test_handler_rejects_non_http_scheme() {
        let args = json!({ "url": "file:///etc/passwd" });
        let err = execute_fetch_url(args).await.unwrap_err();
        assert!(format!("{}", err).contains("协议"));
    }

    #[tokio::test]
    async fn test_handler_max_length_clamped_low() {
        // max_length=1 应 clamp 到 500,不发请求只验证参数解析不 panic(用 localhost 触发 SSRF 早期拒绝)
        let args = json!({ "url": "http://localhost/", "max_length": 1 });
        let _ = execute_fetch_url(args).await; // 不 panic 即通过
    }

    #[tokio::test]
    async fn test_handler_max_length_clamped_high() {
        let args = json!({ "url": "http://localhost/", "max_length": 999999 });
        let _ = execute_fetch_url(args).await; // 不 panic 即通过
    }

    // ── 真实网络集成(#[ignore]:CI 无网时跳过) ──

    #[tokio::test]
    #[ignore = "需真实网络(example.com),CI 无网时跳过:cargo test -- --ignored"]
    async fn integration_fetch_example_com_markdown() {
        let args = json!({ "url": "https://example.com/" });
        let result = execute_fetch_url(args).await.expect("fetch example.com 应成功");
        assert_eq!(result["status"].as_u64().unwrap(), 200);
        assert_eq!(result["title"].as_str().unwrap_or(""), "Example Domain");
        let md = result["markdown"].as_str().unwrap_or("");
        assert!(md.contains("Example Domain"));
        assert!(!md.contains("<html")); // 应是 markdown 非 HTML
        assert_eq!(result["truncated"], false);
    }
}
