//! download_file AI 工具 — URL → 文件(跨平台流式下载,无 shell/PowerShell 陷阱)
//!
//! 设计目标:让 LLM 把远程文件(URL)落到本地磁盘,替代 `run_command` 里的
//! `curl -o` / `wget` / `Invoke-WebRequest`。后者在 Windows 上踩 alias、编码、
//! 跨平台陷阱(PowerShell alias 表不同、GBK 解码、Invoke-WebRequest 不在 PS Core 外可用、
//! curl 在 Win 是 alias = Invoke-WebRequest 而非真 curl)。本工具纯 reqwest + tokio::fs,
//! 跨平台一致,无 shell spawn。
//!
//! ## 与 http_request / fetch_url 的分工
//!
//! - `http_request`:结构化 API 调用,返回 body(截断 50KB),不落盘。
//! - `fetch_url`:网页 → markdown 文档嗅探,返回 markdown,不落盘。
//! - `download_file`:**URL → 落盘文件**(二进制/大文件友好,流式写,不进 prompt)。
//!
//! ## 安全
//!
//! 1. **SSRF 防护**:复用 `commands::ai::http` 的 `validate_url` / `resolve_and_check_host` /
//!    `build_client` / `execute_with_redirects`(协议白名单 + 私网 IP 黑名单 + DNS resolve 后
//!    校验防 rebinding + 重定向 ≤3 跳每跳重校验)。只发 GET,无 body。
//! 2. **路径校验**:复用 `tool_registry::validate_path` + `resolve_workspace_path_with_allowed`
//!    (URL 解码防 %2e%2e 遍历 + 词法层越界兜底 + canonicalize symlink 逃逸拦截 + 授权目录白名单)。
//!    与 write_file 同款,output_path 必须落在授权目录内。
//! 3. **大小上限**:默认 200MB(可调 const),流式边下边累计,超限立即中止 + 删半成品,防磁盘炸。
//! 4. **原子写**:tmp → rename(同目录,不跨卷),写一半崩溃不留半成品文件(对齐 write_file FR-S7)。
//!
//! ## 风险:High
//!
//! 下载文件有副作用(写磁盘 + 触发外发网络 + 可能拿到可执行/敏感内容),统一 High 须人工批准,
//! 与 write_file(Medium)区别在于:download 的来源是外部不可信网络,不可预知内容。

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

use serde_json::{json, Value};
use tokio::io::AsyncWriteExt;

use crate::commands::ai::http::{
    build_client, execute_with_redirects, resolve_and_check_host, validate_url, MAX_REDIRECTS,
};
use crate::commands::ai::tool_registry::{
    DEFAULT_RUN_COMMAND_TIMEOUT_SECS, MAX_RUN_COMMAND_TIMEOUT_SECS,
};

/// 下载文件大小硬上限(字节)。200MB —— 防超大文件(ISO/镜像/视频)撑爆磁盘。
/// 流式边下边累计,超限立即中止 + 删半成品。需更大文件走专门工具/分片,不在此。
const MAX_DOWNLOAD_BYTES: u64 = 200 * 1024 * 1024;

/// download_file 工具 handler 入口(供 tools/download_file.rs register 调用)。
///
/// 参数:
/// - url: 必填,http/https(SSRF 防护拒私网/localhost)
/// - output_path: 必填,落盘路径(必须落在授权目录内,复用 write_file 同款路径校验)
/// - timeout_secs: 可选,默认 DEFAULT_RUN_COMMAND_TIMEOUT_SECS(60),clamp ≤MAX(600)
///
/// 返回 {path, bytes_written, status, url}
///
/// 注:handler 不直接拿 allowed_dirs(tools/download_file.rs 闭包捕获并在调用前解析路径)。
/// 本函数接收「已解析的绝对路径字符串」(resolved_path),路径校验在调用方完成(闭包持有 allowed_dirs,
/// 解析后传字符串进来,与 write_file 模式一致 —— resolve_workspace_path_with_allowed 需 allowed_dirs
/// 快照,故解析留在闭包内,本函数专注网络 + 落盘)。
pub(crate) async fn execute_download_file(
    args: Value,
    resolved_output_path: String,
) -> 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 不能为空");
    }
    if resolved_output_path.trim().is_empty() {
        anyhow::bail!("output_path 不能为空");
    }
    let timeout_secs = args.get("timeout_secs").and_then(|v| v.as_u64())
        .unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS)
        .clamp(1, MAX_RUN_COMMAND_TIMEOUT_SECS);

    // ── SSRF 校验(三层:词法 URL → DNS resolve IP → 每跳重定向重复) ──
    let (_scheme, host, port) = validate_url(&url_raw)?;
    resolve_and_check_host(&host, port).await?;

    // ── 构建限制 client + 执行 GET(手动重定向循环复用 http.rs) ──
    let client = build_client(Duration::from_secs(timeout_secs))?;
    let started = Instant::now();
    let resp = execute_with_redirects(
        &client,
        reqwest::Method::GET,
        url_raw.clone(),
        &HashMap::new(), // download_file 不带自定义头(纯 GET 文件)
        &None,           // 无 body
        MAX_REDIRECTS,
    )
    .await?;

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

    // ── 流式写文件(避免大文件 OOM) ──
    // response.bytes_stream() 需 reqwest "stream" feature(src-tauri/Cargo.toml 已声明)。
    // 边下边写 + 累计字节,超 MAX_DOWNLOAD_BYTES 立即中止 + 删半成品。
    let target = std::path::Path::new(&resolved_output_path);

    // 父目录必须存在且在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。
    // 路径白名单校验已在调用方(resolve_workspace_path_with_allowed)完成,这里只补 parent 授权 +
    // create_dir_all(对齐 write_file FR-S8)。parent 授权复检由调用方解析时已覆盖(resolved 已过白名单),
    // create_dir_all 仅针对尚不存在的 parent 目录创建。
    if let Some(parent) = target.parent() {
        tokio::fs::create_dir_all(parent).await
            .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
    }

    // 原子写:tmp → rename(同目录,不跨卷)。写一半崩溃/超限中止均删 tmp,不留半成品。
    let tmp = format!("{}.tmp-download", resolved_output_path);
    let mut file = tokio::fs::File::create(&tmp).await
        .map_err(|e| anyhow::anyhow!("创建临时文件失败 ({}): {}", tmp, e))?;

    let mut stream = resp.bytes_stream();
    let mut written: u64 = 0;
    let mut exceeded = false;
    use futures::StreamExt;
    while let Some(chunk) = stream.next().await {
        let chunk = chunk.map_err(|e| anyhow::anyhow!("读取响应流失败: {}", e))?;
        written += chunk.len() as u64;
        if written > MAX_DOWNLOAD_BYTES {
            exceeded = true;
            break;
        }
        file.write_all(&chunk).await
            .map_err(|e| anyhow::anyhow!("写入文件失败: {}", e))?;
    }
    // flush + 关闭(file drop 前 flush,确保数据落盘再 rename)
    file.flush().await
        .map_err(|e| anyhow::anyhow!("flush 文件失败: {}", e))?;
    drop(file);

    if exceeded {
        let _ = tokio::fs::remove_file(&tmp).await; // 删半成品
        anyhow::bail!(
            "下载超过 {} 字节上限,已中止并清理临时文件",
            MAX_DOWNLOAD_BYTES
        );
    }

    // tmp → 目标(原子替换,同目录不跨卷)。覆盖既有文件时旧文件被替换。
    if let Err(e) = tokio::fs::rename(&tmp, &resolved_output_path).await {
        let _ = tokio::fs::remove_file(&tmp).await;
        return Err(anyhow::anyhow!("原子替换失败: {}", e));
    }

    let elapsed_ms = started.elapsed().as_millis() as u64;

    Ok(json!({
        "path": resolved_output_path,
        "bytes_written": written,
        "status": status,
        "url": final_url,
        "elapsed_ms": elapsed_ms,
    }))
}

// ============================================================
// 单元测试
//
// 覆盖:
// ① SSRF 拦截私网(validate_url 拒 127.0.0.1/10.x/169.254):走 handler 入口早期拒绝,
//    断言错误信息含关键词(对齐 http.rs / fetch_url.rs 测试策略)。
// ② 路径越界拒:validate_path 纯函数,断言 .. / 敏感目录被拒。
// 真实下载集成走 #[ignore](CI 无网时跳过,本地手跑)。
// ============================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::ai::tool_registry::validate_path;

    // ── validate_path 纯函数:路径越界 / 敏感目录拒(可单测,零依赖) ──

    #[test]
    fn test_validate_path_rejects_traversal() {
        // .. 段被拒(分段归一化,不误伤 my..file)
        assert!(validate_path("../etc/passwd").is_err());
        assert!(validate_path("foo/../../bar").is_err());
        assert!(validate_path("a/..").is_err());
        // URL 编码绕过被解码后拦截(%2e%2e = ..)
        assert!(validate_path("%2e%2e/secret").is_err());
        assert!(validate_path("%2e%2e%2fetc").is_err());
    }

    #[test]
    fn test_validate_path_accepts_normal_relative() {
        // 正常相对路径通过(my..file 不误伤)
        assert!(validate_path("downloads/file.zip").is_ok());
        assert!(validate_path("my..file.txt").is_ok());
        assert!(validate_path("a/b/c").is_ok());
    }

    #[test]
    fn test_validate_path_rejects_sensitive_system_dirs() {
        // .ssh / .aws / .gnupg 等敏感目录被拒(走 is_in_system_blacklist)
        assert!(validate_path("/root/.ssh/id_rsa").is_err());
        assert!(validate_path("C:\\Users\\x\\.aws\\credentials").is_err());
    }

    // ── handler 参数边界 ──

    #[tokio::test]
    async fn test_handler_missing_url_errors() {
        let args = json!({ "output_path": "/tmp/x" });
        let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err();
        assert!(format!("{}", err).contains("缺少 url"));
    }

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

    #[tokio::test]
    async fn test_handler_empty_output_path_errors() {
        let args = json!({ "url": "https://example.com/", "output_path": "  " });
        let err = execute_download_file(args, "  ".to_string()).await.unwrap_err();
        assert!(format!("{}", err).contains("output_path 不能为空"));
    }

    // ── SSRF 拦截:handler 入口 validate_url 拒私网/localhost/非 http 协议(不发请求) ──

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

    #[tokio::test]
    async fn test_handler_rejects_private_ip_loopback() {
        // 127.0.0.1 环回
        let args = json!({ "url": "http://127.0.0.1/admin", "output_path": "/tmp/x" });
        let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err();
        assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF"));
    }

    #[tokio::test]
    async fn test_handler_rejects_private_ip_rfc1918_10() {
        // 10.x RFC1918 A 类私网
        let args = json!({ "url": "http://10.0.0.1/", "output_path": "/tmp/x" });
        let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err();
        assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF"));
    }

    #[tokio::test]
    async fn test_handler_rejects_metadata_endpoint() {
        // 169.254.169.254 云元数据服务(SSRF 头号目标)
        let args = json!({ "url": "http://169.254.169.254/latest/meta-data/", "output_path": "/tmp/x" });
        let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err();
        assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF"));
    }

    #[tokio::test]
    async fn test_handler_rejects_non_http_scheme() {
        // file/ftp/data 等非 http 协议被拒
        let args = json!({ "url": "file:///etc/passwd", "output_path": "/tmp/x" });
        let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err();
        assert!(format!("{}", err).contains("协议"));
    }

    #[tokio::test]
    async fn test_handler_timeout_clamped_no_panic() {
        // timeout_secs 超大值应 clamp 到 MAX,不 panic(用 localhost 触发 SSRF 早期拒绝,
        // 验证 clamp 在拒绝前不 panic)
        let args = json!({ "url": "http://localhost/", "output_path": "/tmp/x", "timeout_secs": 999999 });
        let _ = execute_download_file(args, "/tmp/x".to_string()).await; // 不 panic 即通过
    }

    // ── 真实下载集成(#[ignore]:CI 无网时跳过,本地手跑) ──

    #[tokio::test]
    #[ignore = "需真实网络 + 临时目录,CI 无网时跳过:cargo test -- --ignored"]
    async fn integration_download_example_com() {
        // 下载 example.com 首页到临时文件,验证 happy path。
        let tmp_dir = std::env::temp_dir().join("devflow-download-test");
        tokio::fs::create_dir_all(&tmp_dir).await.ok();
        let out = tmp_dir.join("example.html");
        let out_str = out.to_string_lossy().to_string();
        let args = json!({ "url": "https://example.com/", "output_path": out_str });
        let result = execute_download_file(args, out_str.clone()).await.expect("下载应成功");
        assert_eq!(result["status"].as_u64().unwrap(), 200);
        assert!(result["bytes_written"].as_u64().unwrap() > 0);
        assert!(result["url"].as_str().unwrap().contains("example.com"));
        // 文件确实落盘
        let meta = tokio::fs::metadata(&out_str).await.expect("文件应存在");
        assert!(meta.len() > 0);
        // 清理
        let _ = tokio::fs::remove_file(&out_str).await;
        let _ = tokio::fs::remove_dir(&tmp_dir).await;
    }
}
