//! 本地工具代理统一网关(DevFlow 内嵌,一个端口路由到 rust-work 4 个代理)。
//! 仿 mcp.rs spawn_mcp_http:tauri 后台任务 axum 监听 127.0.0.1:<port>,
//! POST /{type}/{path} 前缀路由转发到对应代理端口,body 原样透传。
//! 端口映射收敛到此一张表,rust-work 改端口只改这里,工具零改动。
//!
//! axum 0.7.9 路由语法:匹配 matchit `:param` / `*param`(route.md 文档 `:key` / `/*key`,
//! 非 `{param}` 大括号语法),故 `/{type}/{*path}` 对应注册为 `/:type/*path`。
//! 请求 URL 概念形态仍为 `/{type}/{path}`(如 POST /mysql/query)。
//!
//! 背景:local_proxy.rs 的 11 个工具此前直接 HTTP 调 4 个代理端口
//! (mysql:3307 / ssh:3308 / mongo:3309 / redis:3310),硬编码端口耦合 rust-work 细节。
//! 本网关把「type → 端口」映射收敛为一张表,工具只连网关 127.0.0.1:8899,
//! 端口从工具代码消失。
//!
//! 安全(两层):
//! 1. 监听 127.0.0.1 loopback 固定,不暴露局域网;
//! 2. path 白名单:仅放行业务端点(query/execute/exec/...),白名单外一律拒绝;
//!    connections/add / servers/add 这类「凭据注入」高风险端点显式拒绝(纵深防御,
//!    即便白名单后续误加也不放行)。
//!
//! 端口:env `DF_LOCAL_PROXY_PORT` 覆盖,默认 8899。仅 tracing 记录 bind 结果,
//! 不做 managed 状态管理(mcp.rs 的 running 状态非必需,网关失败不阻断主应用)。
use std::sync::OnceLock;
use std::time::Duration;

use axum::{
    body::Bytes,
    extract::Path,
    http::{HeaderMap, Method, StatusCode},
    response::{IntoResponse, Response},
    routing::get,
    Router,
};
use tauri::AppHandle;

/// 端口映射表:proxy type → rust-work 代理端口。收敛到此一张表,
/// rust-work 改端口只改这里(工具代码零改动)。
const PROXY_PORTS: &[(&str, u16)] = &[
    ("mysql", 3307),
    ("ssh", 3308),
    ("mongo", 3309),
    ("redis", 3310),
];

/// 业务端点白名单(代理 API 契约):白名单外一律拒绝(403)。
/// 对齐 local_proxy.rs 文档契约:query/execute/exec/find/insert/update/delete/
/// get/set/del/keys/run/info/count/aggregate/connections/servers/health。
const ALLOWED_PATHS: &[&str] = &[
    "query", "execute", "exec", "find", "insert", "update", "delete",
    "get", "set", "del", "keys", "run", "info", "count", "aggregate",
    "connections", "servers", "health",
];

/// 凭据注入高风险端点:显式拒绝(即便未来误加白名单也不放行,纵深防御)。
/// connections/add / servers/add 向代理注入连接/服务器凭据,风险高,网关拦截。
const DENIED_PATHS: &[&str] = &["connections/add", "servers/add"];

/// 转发超时(秒)。防代理挂起冻结 AI 会话(本地代理应毫秒级响应)。
const GATEWAY_TIMEOUT_SECS: u64 = 15;

/// 解析网关监听端口:优先 env `DF_LOCAL_PROXY_PORT`,否则默认 8899。
pub fn gateway_port() -> u16 {
    std::env::var("DF_LOCAL_PROXY_PORT")
        .ok()
        .and_then(|p| p.parse::<u16>().ok())
        .unwrap_or(8899)
}

/// 在 tauri 后台任务里启动本地工具代理统一网关。
///
/// bind 127.0.0.1:port → axum Router(`/{type}/{*path}` GET+POST)→ axum::serve 常驻。
/// 仿 mcp.rs spawn_mcp_http:tauri::async_runtime::spawn + tracing 记录 bind 成败;
/// 不 manage 状态(网关失败非阻断,前端无需感知;需要时再仿 McpStatus 扩展)。
/// `_app` 当前未使用(仅对齐 mcp 调用形态,预留状态管理),故前缀下划线。
pub fn spawn_local_proxy_gateway(_app: &AppHandle) {
    let port = gateway_port();
    tauri::async_runtime::spawn(async move {
        let listener = match tokio::net::TcpListener::bind(("127.0.0.1", port)).await {
            Ok(l) => l,
            Err(e) => {
                tracing::error!(port, err = %e, "[local-proxy-gateway] 绑定端口失败,本地工具代理网关不可用(可设 DF_LOCAL_PROXY_PORT 换端口)");
                return;
            }
        };
        tracing::info!(port, "[local-proxy-gateway] 本地工具代理网关 listening on 127.0.0.1:{port}(路由 mysql/ssh/mongo/redis)");
        let router = Router::new().route("/:type/*path", get(proxy).post(proxy));
        if let Err(e) = axum::serve(listener, router).await {
            tracing::error!(err = %e, "[local-proxy-gateway] 网关异常退出");
        }
    });
}

/// 复用转发 HTTP client(连接池复用,避免每次请求新建)。仅设 timeout,本地 http 代理下
/// 构建失败不会发生,expect(对齐 local_proxy.rs::local_client)。
fn gateway_client() -> &'static reqwest::Client {
    static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
    CLIENT.get_or_init(|| {
        reqwest::Client::builder()
            .timeout(Duration::from_secs(GATEWAY_TIMEOUT_SECS))
            .build()
            .expect("构建本地工具代理网关 HTTP client 失败")
    })
}

/// 网关统一转发 handler:GET/POST `/:type/*path` → 代理端口,body 原样透传。
///
/// 校验顺序:① type 不在 4 代理映射 → 400;② path 命中拒绝名单 → 403;
/// ③ path 不在业务白名单 → 403;④ 通过后转发到 `http://127.0.0.1:{port}/{path}`,
/// method 原样、body 透传、Content-Type 透传(代理按 JSON 解析),响应状态码 + body 原样返回。
/// (路由用 axum 0.7.9 matchit 语法 `:type/*path`;请求 URL 概念形态为 `/{type}/{path}`。)
async fn proxy(
    method: Method,
    Path((proxy_type, path)): Path<(String, String)>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    // ① type 白名单:不在映射表 → 400
    let Some(port) = PROXY_PORTS
        .iter()
        .find(|(t, _)| *t == proxy_type)
        .map(|(_, p)| *p)
    else {
        return (
            StatusCode::BAD_REQUEST,
            format!("未知代理类型: {proxy_type}(仅支持 mysql/ssh/mongo/redis)"),
        )
            .into_response();
    };
    // ② 凭据注入高风险端点显式拒(纵深防御,与白名单独立校验)
    if DENIED_PATHS.contains(&path.as_str()) {
        return (
            StatusCode::FORBIDDEN,
            format!("端点已拒绝(凭据注入高风险): {path}"),
        )
            .into_response();
    }
    // ③ path 白名单:业务端点外一律拒绝(防任意路径打到代理)
    if !ALLOWED_PATHS.contains(&path.as_str()) {
        return (
            StatusCode::FORBIDDEN,
            format!("端点不在白名单,已拒绝: {path}"),
        )
            .into_response();
    }
    // ④ 转发到对应代理端口,method/body/Content-Type 原样透传
    let url = format!("http://127.0.0.1:{port}/{path}");
    let mut req = gateway_client().request(method, &url).body(body);
    if let Some(ct) = headers.get(axum::http::header::CONTENT_TYPE) {
        req = req.header(axum::http::header::CONTENT_TYPE, ct.clone());
    }
    match req.send().await {
        Ok(resp) => {
            let status = resp.status();
            match resp.bytes().await {
                Ok(bytes) => (status, bytes).into_response(),
                Err(e) => (
                    StatusCode::BAD_GATEWAY,
                    format!("读取代理响应失败({url}): {e}"),
                )
                    .into_response(),
            }
        }
        Err(e) => (
            StatusCode::BAD_GATEWAY,
            format!("转发到代理失败({url}): {e}"),
        )
            .into_response(),
    }
}

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

    /// 网关路由冒烟测试:axum 0.7.9 路由语法是 matchit `:type/*path`(非 `{type}/{*path}`),
    /// 本测试守护路由匹配 + type/path 白名单拒绝逻辑。用临时端口 bind + axum::serve,
    /// 再 reqwest 打真实 HTTP,验证:
    /// - 未知 type → 400(路由已匹配但 type 不在映射表)
    /// - 拒绝名单(connections/add)→ 403
    /// - 白名单外 path(drop)→ 403
    /// - 合法 type+path(mysql/query)→ 经路由命中并转发,代理未起(502)/已起(200)
    ///   均非 4xx,证明「路由匹配 + 白名单放行 + 转发」全链路过,非 404 未命中。
    ///
    /// 注:本机有 0xc0000139 环境问题测试无法运行,此测试为回归守卫(修复环境后可跑)。
    #[tokio::test]
    async fn test_gateway_routing_and_whitelist() {
        let router = Router::new().route("/:type/*path", get(proxy).post(proxy));
        let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
            .await
            .expect("bind 临时端口失败");
        let addr = listener.local_addr().expect("取临时端口失败");
        let server = tokio::spawn(async move {
            let _ = axum::serve(listener, router).await;
        });
        // 等 server 就绪(本地毫秒级,50ms 足够)
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        let base = format!("http://127.0.0.1:{}", addr.port());
        let client = reqwest::Client::new();

        // ① 未知 type → 400
        let resp = client
            .post(format!("{base}/bogus/query"))
            .json(&serde_json::json!({"conn": "c"}))
            .send()
            .await
            .expect("bogus 请求失败");
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "未知 type 应 400");

        // ② 凭据注入端点 → 403
        let resp = client
            .post(format!("{base}/mysql/connections/add"))
            .json(&serde_json::json!({}))
            .send()
            .await
            .expect("connections/add 请求失败");
        assert_eq!(resp.status(), StatusCode::FORBIDDEN, "connections/add 应 403");

        // ③ 白名单外 path → 403
        let resp = client
            .post(format!("{base}/mysql/drop"))
            .json(&serde_json::json!({"conn": "c"}))
            .send()
            .await
            .expect("drop 请求失败");
        assert_eq!(resp.status(), StatusCode::FORBIDDEN, "白名单外 path 应 403");

        // ④ 合法 type+path:路由命中并转发。注意本机 3307 可能有真实 mysql-proxy 在跑,
        // 转发后可能返 400(业务错误,如 conn 不存在)——这恰证明「路由匹配 + 白名单放行 +
        // 转发到代理」全链路通(非 404 未命中/非 403 白名单拒)。仅需断言非网关自身的
        // 403(白名单拒)/404(路由未命中)。代理业务 400/200/502 都算链路通。
        let resp = client
            .post(format!("{base}/mysql/query"))
            .json(&serde_json::json!({"conn": "c", "sql": "select 1"}))
            .send()
            .await
            .expect("mysql/query 请求失败");
        let status = resp.status();
        assert!(
            status != StatusCode::NOT_FOUND && status != StatusCode::FORBIDDEN,
            "合法 mysql/query 不应被网关拒绝(403 白名单/404 路由未命中),实际 {status}"
        );

        server.abort();
    }
}
