//! 厂商模型列表拉取 — 纯逻辑(无 IO)。
//!
//! 从 `model_fetch.rs` 抽离的纯函数 / 类型:URL 拼接、噪音过滤、响应反序列化。
//! 这些逻辑无网络依赖,可独立单测;主文件 `model_fetch.rs` 保留有 IO 的拉取入口。
//!
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §5.3 / §5.4。

use serde::Deserialize;

// ────────────────────────────────────────────────────────────
// URL 智能拼接
// ────────────────────────────────────────────────────────────

/// 拼接模型列表端点 URL。
///
/// 规则(设计 §5.3,base_url 先去尾 `/`):
/// - 以 `/chat/completions` 结尾 → 替换为 `/models`(用户把对话端点当 base_url 填了)
/// - 以 `/v1` / `/v4` 结尾 → 直接补 `/models`(避免 `/v1/v1/models`)
/// - 以 `/models` 结尾 → 原样用(用户已填完整端点)
/// - 以 `/api` 结尾 → 补 `/models`(Ollama 风格 base,anthropic_compat 兜底用)
/// - 其余 → 补 `/v1/models`(最常见:用户填 `https://open.bigmodel.cn/api/paas/v4`)
pub fn build_models_url(base_url: &str) -> String {
    let url = base_url.trim_end_matches('/');
    if url.ends_with("/chat/completions") {
        url.replace("/chat/completions", "/models")
    } else if url.ends_with("/v1") || url.ends_with("/v4") {
        format!("{url}/models")
    } else if url.ends_with("/models") {
        url.to_string()
    } else if url.ends_with("/api") {
        format!("{url}/models")
    } else {
        format!("{url}/v1/models")
    }
}

// ────────────────────────────────────────────────────────────
// 噪音过滤(剔除非 chat 模型)
// ────────────────────────────────────────────────────────────

/// 判断是否为非 chat 模型(应从列表中剔除)。
///
/// 规则(设计 §5.4 + 合理扩展,见 issues):
/// - 图片生成:dall-e / midjourney / stable-diffusion / imagen / sensenova-u1 / infographic / image-generation
/// - 语音:tts / whisper / audio / speech / voice(语音合成/识别)
/// - 实时:realtime(OpenAI Realtime API 语音对话,非 chat completions)
/// - 转写:transcribe / transcription
/// - 审查:moderation(内容审查模型)
/// - 搜索:davinci-search / search(旧 GPT 搜索变种)
///
/// embedding 设计 §5.4 注释「保留(知识库需要)」— 此处遵循设计保留 embedding,
/// 不在 `is_non_chat_model` 剔除(知识库 embedding 路由用得着)。
///
/// 注:`sensenova-u1` 用厂商前缀限定而非裸 `u1`,避免误伤未来含 `u1` 词素的 chat 模型
/// (SenseNova U1 系列走 `/v1/images/generations` 端点,非 chat completions)。
pub fn is_non_chat_model(id: &str) -> bool {
    let id = id.to_lowercase();
    // 图片生成
    id.contains("dall-e")
        || id.contains("midjourney")
        || id.contains("stable-diffusion")
        || id.contains("imagen")
        // SenseNova U1 系列(图像生成,如 sensenova-u1-fast,走 /v1/images/generations 非 chat)
        || id.contains("sensenova-u1")
        // 通用图像生成命名(infographic 海报生成 / image-generation 直白命名)
        || id.contains("infographic")
        || id.contains("image-generation")
        // 语音(tts 合成 / whisper 识别 / 通用 audio / speech / voice)
        || id.contains("tts")
        || id.contains("whisper")
        || id.contains("audio")
        || id.contains("speech")
        || id.contains("voice")
        // 实时语音对话(OpenAI Realtime API,走独立协议非 chat completions)
        || id.contains("realtime")
        // 转写
        || id.contains("transcribe")
        || id.contains("transcription")
        // 内容审查
        || id.contains("moderation")
        // 旧 GPT 搜索变种(davinci-search 覆盖旧 GPT 搜索;裸 -search 误伤合法 search-augmented chat
        // 模型如 gpt-4o-search-preview/qwen-search/glm-4-search,已移除)
        || id.contains("davinci-search")
}

/// 过滤噪音:剔除非 chat 模型 + 去重(中转站可能返回重复) + 去空名。
pub fn filter_chat_models(ids: Vec<String>) -> Vec<String> {
    let mut seen = std::collections::HashSet::new();
    let mut out = Vec::with_capacity(ids.len());
    for id in ids {
        let trimmed = id.trim();
        if trimmed.is_empty() || is_non_chat_model(trimmed) {
            continue;
        }
        if seen.insert(trimmed.to_string()) {
            out.push(trimmed.to_string());
        }
    }
    out
}

// ────────────────────────────────────────────────────────────
// 响应反序列化
// ────────────────────────────────────────────────────────────

/// 厂商 `/v1/models` 响应统一反序列化。
///
/// 兼容三种结构(用 `#[serde(alias)]`):
/// - OpenAI / Anthropic:`{data:[{id, ...}]}`
/// - Ollama:`{models:[{name, ...}]}`(anthropic_compat 走中转站时兜底)
/// - 单字段别名:`id` / `name` 都认
#[derive(Debug, Deserialize)]
pub(crate) struct ModelsList {
    #[serde(default)]
    data: Vec<ModelEntry>,
    #[serde(default, alias = "models")]
    data_alt: Vec<ModelEntry>,
}

#[derive(Debug, Deserialize)]
struct ModelEntry {
    #[serde(default, alias = "name")]
    id: Option<String>,
}

impl ModelsList {
    /// 合并 `data` 与 `models`(alias)字段,提取所有模型 id/name,丢弃 None。
    pub(crate) fn into_ids(self) -> Vec<String> {
        self.data
            .into_iter()
            .chain(self.data_alt)
            .filter_map(|e| e.id)
            .collect()
    }
}

// ────────────────────────────────────────────────────────────
// 测试(纯逻辑/字符串处理,不真实调 API)
// ────────────────────────────────────────────────────────────

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

    // ── is_non_chat_model:search-augmented chat 模型不应误剔(裸 -search 已移除) ──
    #[test]
    fn is_non_chat_model_keeps_search_augmented_chat() {
        // search-augmented chat 模型(对话端点可调)不应被 -search 子串误剔
        assert!(!is_non_chat_model("gpt-4o-search-preview"));
        assert!(!is_non_chat_model("qwen-search"));
        assert!(!is_non_chat_model("glm-4-search-preview"));
        // 旧 GPT 搜索变种仍剔(davinci-search 覆盖)
        assert!(is_non_chat_model("text-davinci-search-001"));
    }

    // ── build_models_url:尾 / 处理 ──

    #[test]
    fn build_url_strips_trailing_slash() {
        // 用户填 `https://api.x.com/v1/`(尾 /)→ 去 / 后命中 /v1 分支 → 补 /models
        assert_eq!(
            build_models_url("https://api.x.com/v1/"),
            "https://api.x.com/v1/models"
        );
        // 多个尾 /
        assert_eq!(
            build_models_url("https://api.x.com/v4//"),
            "https://api.x.com/v4/models"
        );
    }

    #[test]
    fn build_url_v1_suffix_appends_models() {
        assert_eq!(
            build_models_url("https://api.openai.com/v1"),
            "https://api.openai.com/v1/models"
        );
        // 不应产生 /v1/v1/models
        assert!(!build_models_url("https://api.openai.com/v1").contains("/v1/v1"));
    }

    #[test]
    fn build_url_v4_suffix_appends_models() {
        // GLM:`https://open.bigmodel.cn/api/paas/v4`
        assert_eq!(
            build_models_url("https://open.bigmodel.cn/api/paas/v4"),
            "https://open.bigmodel.cn/api/paas/v4/models"
        );
    }

    #[test]
    fn build_url_bare_base_appends_v1_models() {
        // 用户只填根:`https://api.deepseek.com` → 补 /v1/models
        assert_eq!(
            build_models_url("https://api.deepseek.com"),
            "https://api.deepseek.com/v1/models"
        );
    }

    #[test]
    fn build_url_chat_completions_replaced() {
        // 用户误把对话端点当 base_url
        assert_eq!(
            build_models_url("https://api.x.com/v1/chat/completions"),
            "https://api.x.com/v1/models"
        );
    }

    #[test]
    fn build_url_already_models_kept() {
        // 用户已填完整端点 → 原样用
        assert_eq!(
            build_models_url("https://api.x.com/v1/models"),
            "https://api.x.com/v1/models"
        );
    }

    #[test]
    fn build_url_api_suffix_ollama_style() {
        // Ollama 风格 base(anthropic_compat 兜底路径)
        assert_eq!(
            build_models_url("http://localhost:11434/api"),
            "http://localhost:11434/api/models"
        );
    }

    // ── is_non_chat_model:噪音过滤规则 ──

    #[test]
    fn non_chat_image_generation_filtered() {
        assert!(is_non_chat_model("dall-e-3"));
        assert!(is_non_chat_model("dall-e-2"));
        assert!(is_non_chat_model("midjourney-v6"));
        assert!(is_non_chat_model("stable-diffusion-xl"));
    }

    #[test]
    fn non_chat_sensenova_u1_image_filtered() {
        // SenseNova U1 系列是图像生成模型(走 /v1/images/generations,非 chat completions),
        // /v1/models 会返回但不应留为 chat 模型,否则用户选它对话会失败。
        assert!(is_non_chat_model("sensenova-u1-fast"));
        assert!(is_non_chat_model("SenseNova-U1-Fast")); // 大小写无关
        assert!(is_non_chat_model("sensenova-u1-pro"));
        // 通用图像生成命名也剔
        assert!(is_non_chat_model("infographic-v1"));
        assert!(is_non_chat_model("image-generation-latest"));
        // 对照组:同厂商的 chat 模型 sensenova-6.7-flash-lite 不应被误剔
        // (它是原生多模态 chat,见 model_probe.rs 预设表)
        assert!(
            !is_non_chat_model("sensenova-6.7-flash-lite"),
            "sensenova-6.7-flash-lite 是 chat 模型,不应被当图像生成剔除"
        );
    }

    #[test]
    fn non_chat_speech_filtered() {
        assert!(is_non_chat_model("tts-1"));
        assert!(is_non_chat_model("tts-1-hd"));
        assert!(is_non_chat_model("whisper-1"));
        assert!(is_non_chat_model("audio-transcribe"));
        assert!(is_non_chat_model("speech-synth"));
    }

    #[test]
    fn non_chat_realtime_filtered() {
        assert!(is_non_chat_model("gpt-4o-realtime"));
        assert!(is_non_chat_model("gpt-4o-mini-realtime-preview"));
    }

    #[test]
    fn non_chat_moderation_filtered() {
        assert!(is_non_chat_model("text-moderation-stable"));
        assert!(is_non_chat_model("omni-moderation-latest"));
    }

    #[test]
    fn non_chat_search_variants_filtered() {
        assert!(is_non_chat_model("text-davinci-search-001"));
    }

    #[test]
    fn chat_models_kept() {
        // 主流 chat 模型不应被误剔
        for ok in [
            "glm-4",
            "glm-4-flash",
            "glm-4v",
            "glm-4-air",
            "glm-4-plus",
            "deepseek-chat",
            "deepseek-coder",
            "gpt-4o",
            "gpt-4o-mini",
            "claude-3-5-sonnet-20241022",
            "claude-3-haiku-20240307",
            "qwen-plus",
            "kimi-k2",
        ] {
            assert!(!is_non_chat_model(ok), "{ok} 不应被当噪音剔除");
        }
    }

    #[test]
    fn embedding_kept_per_design() {
        // 设计 §5.4 明确「embedding 保留(知识库需要)」
        for emb in ["embedding-3", "text-embedding-3-large", "bge-m3"] {
            assert!(!is_non_chat_model(emb), "{emb} 按 §5.4 应保留");
        }
    }

    // ── filter_chat_models:过滤 + 去重 + 去空 ──

    #[test]
    fn filter_removes_noise_and_keeps_chat() {
        let raw = vec![
            "glm-4-flash".to_string(),
            "dall-e-3".to_string(), // 噪音
            "tts-1".to_string(),    // 噪音
            "whisper-1".to_string(), // 噪音
            "text-moderation-stable".to_string(), // 噪音
            "glm-4v".to_string(),
            "gpt-4o".to_string(),
        ];
        let got = filter_chat_models(raw);
        assert_eq!(got, vec!["glm-4-flash", "glm-4v", "gpt-4o"]);
    }

    #[test]
    fn filter_dedupes() {
        // 中转站可能返回重复模型名
        let raw = vec![
            "glm-4".to_string(),
            "glm-4".to_string(),
            "glm-4-flash".to_string(),
        ];
        let got = filter_chat_models(raw);
        assert_eq!(got, vec!["glm-4", "glm-4-flash"]);
    }

    #[test]
    fn filter_drops_empty_and_whitespace_only() {
        let raw = vec![
            "".to_string(),
            "   ".to_string(),
            "glm-4".to_string(),
            "  glm-4v  ".to_string(), // 带空格 → trim 后保留
        ];
        let got = filter_chat_models(raw);
        assert_eq!(got, vec!["glm-4", "glm-4v"]);
    }

    #[test]
    fn filter_empty_input() {
        assert!(filter_chat_models(vec![]).is_empty());
    }

    // ── ModelsList 反序列化:兼容 OpenAI / Anthropic / Ollama 三种结构 ──

    #[test]
    fn parse_openai_format() {
        // OpenAI:`{data:[{id, ...}]}`
        let json = r#"{
            "data": [
                {"id": "gpt-4o", "owned_by": "openai"},
                {"id": "gpt-4o-mini", "owned_by": "openai"}
            ]
        }"#;
        let m: ModelsList = serde_json::from_str(json).unwrap();
        let ids = m.into_ids();
        assert_eq!(ids, vec!["gpt-4o", "gpt-4o-mini"]);
    }

    #[test]
    fn parse_anthropic_format() {
        // Anthropic:`{data:[{id, display_name, type}], has_more:false}`
        let json = r#"{
            "data": [
                {"id": "claude-3-5-sonnet-20241022", "display_name": "Claude 3.5 Sonnet", "type": "model"},
                {"id": "claude-3-haiku-20240307", "display_name": "Claude 3 Haiku", "type": "model"}
            ],
            "has_more": false,
            "first_id": "claude-3-5-sonnet-20241022",
            "last_id": "claude-3-haiku-20240307"
        }"#;
        let m: ModelsList = serde_json::from_str(json).unwrap();
        let ids = m.into_ids();
        assert_eq!(ids, vec!["claude-3-5-sonnet-20241022", "claude-3-haiku-20240307"]);
    }

    #[test]
    fn parse_ollama_format_via_alias() {
        // Ollama:`{models:[{name, size}]}` — 走 anthropic_compat 中转站兜底
        let json = r#"{
            "models": [
                {"name": "llama3:8b", "size": 4661214673},
                {"name": "qwen2:7b", "size": 4436122400}
            ]
        }"#;
        let m: ModelsList = serde_json::from_str(json).unwrap();
        let ids = m.into_ids();
        assert_eq!(ids, vec!["llama3:8b", "qwen2:7b"]);
    }

    #[test]
    fn parse_missing_data_field_yields_empty() {
        // 厂商返回 `{}` 或缺 data 字段 → 空列表,不 panic
        let json = r#"{}"#;
        let m: ModelsList = serde_json::from_str(json).unwrap();
        assert!(m.into_ids().is_empty());
    }

    #[test]
    fn parse_entry_missing_id_skipped() {
        // data 数组项缺 id → 该项跳过
        let json = r#"{
            "data": [
                {"id": "glm-4"},
                {"owned_by": "x"},
                {"id": "glm-4v"}
            ]
        }"#;
        let m: ModelsList = serde_json::from_str(json).unwrap();
        let ids = m.into_ids();
        assert_eq!(ids, vec!["glm-4", "glm-4v"]);
    }
}
