//! 会话意图识别层（Intent Recognition）
//!
//! 论证依据：`docs/02-架构设计/构想审查/意图识别层论证-2026-06-19.md`
//!
//! ## 定位
//! 纯函数模块，**不接入 agentic loop**（待 Phase B+C 完成 + 模型模态管理后接入）。
//! 提供三个能力：
//! 1. `IntentRecognizer::recognize(message)` —— 规则/关键词匹配（方式 A，零延迟零成本）
//!    返回 `(Intent, f32)`，置信度 0.0–1.0。低置信 → 上游 fallback 全量工具。
//! 2. `tool_subset_for(intent)` —— 硬编码工具名→domain 映射，工具名子集（空 = 全量 fallback）。
//! 3. `suggested_model_tier(intent)` —— 模型模态档位建议(意图 → `ModelTier` 映射):
//!    Code/Debug/Http → Heavy,File/Search/Task/Idea/Project/Conversation → Standard,
//!    Chat → Fast,Unknown → None(不强加偏好)。router 同 weight tiebreak + 模型路由用。
//!
//! ## 设计原则
//! - **不碰** `tool_registry`：domain 映射在本文件内硬编码工具名常量，运行期不读 registry。
//! - **不碰** agentic loop / src-tauri：本 crate 内独立编译，单测自洽。
//! - 关键词表对齐 devflow 实际工具语义（read_file/write_file/patch_file/http_request 等）。
//!
//! ## 优先级（匹配命中后取舍）
//! 具体（Code/File/Http/Search）> 实体（Project/Task/Idea）> 通用（Chat/Unknown）。
//! 同级别内按命中关键词权重/数量决定置信度。

// ---- Intent 枚举 ------------------------------------------------------------

/// 会话意图分类。覆盖 devflow 当前 AI 工具域 + 通用对话。
///
/// `Unknown` 表示未命中任何关键词，上游应走全量工具 fallback。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Intent {
    /// 代码相关：实现/重构/编译错误/bug
    Code,
    /// 调试相关：复现/排查/堆栈
    Debug,
    /// 文件操作：读写/patch/列目录
    File,
    /// 项目管理：创建/绑定/列出项目
    Project,
    /// 任务管理：推进/状态/创建任务
    Task,
    /// 灵感：捕获/评估/晋升
    Idea,
    /// 对话：历史会话/总结
    Conversation,
    /// 搜索：grep/查找
    Search,
    /// HTTP 请求：调外部 API
    Http,
    /// 闲聊：问候/感谢
    Chat,
    /// 未识别：fallback 全量工具
    Unknown,
}

impl Intent {
    /// 短标签（日志/调试用）。
    pub fn as_str(self) -> &'static str {
        match self {
            Intent::Code => "code",
            Intent::Debug => "debug",
            Intent::File => "file",
            Intent::Project => "project",
            Intent::Task => "task",
            Intent::Idea => "idea",
            Intent::Conversation => "conversation",
            Intent::Search => "search",
            Intent::Http => "http",
            Intent::Chat => "chat",
            Intent::Unknown => "unknown",
        }
    }
}

// ---- ModelTier 档位 ---------------------------------------------------------

/// 模型模态档位。
///
/// `suggested_model_tier` 据 Intent 映射到此档位,供 router 同 weight tiebreak
/// (重档位优先)或后续 provider/model 路由(待模型模态管理 Phase 接入)使用。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelTier {
    /// 轻量快速（简单意图/闲聊）
    Fast,
    /// 标准（默认复杂度）
    Standard,
    /// 重型（复杂推理/重构）
    Heavy,
}

// ---- 关键词表 + 权重 --------------------------------------------------------

/// 单条关键词规则：关键词 + 命中权重。权重高者对置信度贡献大。
struct Rule {
    kw: &'static str,
    weight: f32,
}

// 优先级分组（具体 > 实体 > 通用）。组内关键词线性求和。
// 关键词覆盖中英文，对齐 devflow 工具语义与用户自然表达。

const CODE_RULES: &[Rule] = &[
    Rule { kw: "代码", weight: 1.0 },
    Rule { kw: "函数", weight: 1.0 },
    Rule { kw: "方法", weight: 0.8 },
    Rule { kw: "bug", weight: 1.0 },
    Rule { kw: "编译", weight: 0.9 },
    Rule { kw: "error", weight: 0.9 },
    Rule { kw: "impl", weight: 0.7 },
    Rule { kw: "refactor", weight: 0.9 },
    Rule { kw: "重构", weight: 1.0 },
    Rule { kw: "实现", weight: 0.7 },
];

const DEBUG_RULES: &[Rule] = &[
    Rule { kw: "调试", weight: 1.0 },
    Rule { kw: "debug", weight: 1.0 },
    Rule { kw: "排查", weight: 0.9 },
    Rule { kw: "复现", weight: 0.9 },
    Rule { kw: "堆栈", weight: 0.8 },
    Rule { kw: "栈", weight: 0.5 },
    Rule { kw: "panic", weight: 0.9 },
    Rule { kw: "traceback", weight: 0.9 },
    // 命令执行场景关键词（run_command 入口引导）:用户明确"运行/执行/构建/测试/跑"
    // 才该走 Exec domain(run_command)。中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。
    Rule { kw: "运行", weight: 0.7 },
    Rule { kw: "执行", weight: 0.7 },
    Rule { kw: "构建", weight: 0.7 },
    Rule { kw: "测试", weight: 0.7 },
    Rule { kw: "跑", weight: 0.6 },
];

const FILE_RULES: &[Rule] = &[
    Rule { kw: "文件", weight: 1.0 },
    Rule { kw: "读取", weight: 0.9 },
    Rule { kw: "写入", weight: 0.9 },
    Rule { kw: "修改", weight: 0.7 },
    Rule { kw: "read", weight: 0.6 },
    Rule { kw: "write", weight: 0.6 },
    Rule { kw: "file", weight: 0.5 },
    Rule { kw: "patch", weight: 0.9 },
];

const PROJECT_RULES: &[Rule] = &[
    Rule { kw: "项目", weight: 1.0 },
    Rule { kw: "project", weight: 0.9 },
    Rule { kw: "创建项目", weight: 1.0 },
    Rule { kw: "绑定", weight: 0.8 },
    Rule { kw: "目录", weight: 0.5 },
];

const TASK_RULES: &[Rule] = &[
    Rule { kw: "任务", weight: 1.0 },
    Rule { kw: "task", weight: 0.9 },
    Rule { kw: "推进", weight: 0.9 },
    Rule { kw: "状态", weight: 0.7 },
    Rule { kw: "todo", weight: 0.8 },
];

const IDEA_RULES: &[Rule] = &[
    Rule { kw: "灵感", weight: 1.0 },
    Rule { kw: "idea", weight: 0.9 },
    Rule { kw: "评估", weight: 0.8 },
    Rule { kw: "创意", weight: 0.8 },
];

const CONVERSATION_RULES: &[Rule] = &[
    Rule { kw: "会话", weight: 0.9 },
    Rule { kw: "历史", weight: 0.7 },
    Rule { kw: "总结", weight: 0.6 },
    Rule { kw: "conversation", weight: 0.8 },
];

const SEARCH_RULES: &[Rule] = &[
    Rule { kw: "搜索", weight: 1.0 },
    Rule { kw: "search", weight: 0.9 },
    Rule { kw: "查找", weight: 0.8 },
    Rule { kw: "grep", weight: 1.0 },
    Rule { kw: "find", weight: 0.6 },
];

const HTTP_RULES: &[Rule] = &[
    Rule { kw: "请求", weight: 0.9 },
    Rule { kw: "http", weight: 1.0 },
    Rule { kw: "api", weight: 0.8 },
    Rule { kw: "url", weight: 0.7 },
    Rule { kw: "fetch", weight: 0.8 },
    Rule { kw: "调用接口", weight: 1.0 },
];

const CHAT_RULES: &[Rule] = &[
    Rule { kw: "你好", weight: 1.0 },
    Rule { kw: "hello", weight: 1.0 },
    Rule { kw: "hi", weight: 0.9 },
    Rule { kw: "谢谢", weight: 1.0 },
    Rule { kw: "thanks", weight: 1.0 },
    Rule { kw: "帮助", weight: 0.7 },
    Rule { kw: "闲聊", weight: 1.0 },
];

/// 优先级分组（高 → 低）。命中高分组的意图直接胜出，不向下累积。
/// 分组内的候选按 (置信度, Intent 顺序) 取胜者。
struct IntentGroup {
    intent: Intent,
    rules: &'static [Rule],
}

const SPECIFIC_GROUP: &[IntentGroup] = &[
    IntentGroup { intent: Intent::Code, rules: CODE_RULES },
    IntentGroup { intent: Intent::Debug, rules: DEBUG_RULES },
    IntentGroup { intent: Intent::Http, rules: HTTP_RULES },
    IntentGroup { intent: Intent::Search, rules: SEARCH_RULES },
];

const ENTITY_GROUP: &[IntentGroup] = &[
    IntentGroup { intent: Intent::File, rules: FILE_RULES },
    IntentGroup { intent: Intent::Project, rules: PROJECT_RULES },
    IntentGroup { intent: Intent::Task, rules: TASK_RULES },
    IntentGroup { intent: Intent::Idea, rules: IDEA_RULES },
    IntentGroup { intent: Intent::Conversation, rules: CONVERSATION_RULES },
];

const GENERIC_GROUP: &[IntentGroup] = &[
    IntentGroup { intent: Intent::Chat, rules: CHAT_RULES },
];

// ---- 工具名 → domain 硬编码表 ----------------------------------------------

/// 工具 domain 分类。`tool_subset_for` 按 Intent 选 domain，再回该 domain 的工具名列表。
///
/// **来源**：核验 `src-tauri/src/commands/ai/tool_registry.rs`（实际注册名）。
/// 本表硬编码，不读 registry 运行期状态（保持模块独立可单测）。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ToolDomain {
    /// 数据/业务：项目/任务/灵感/工作流/回收站（全量,含写工具）
    Data,
    /// 数据只读子集：list_*/get_*_count(不含 create/update/delete/advance/run_workflow/restore/purge/bind_directory)
    ///
    /// 用途:`Intent::Code` 收敛工具时,既保留编码场景用户常需要的"看一下项目/任务结构"只读
    /// 探索工具(list_projects/list_tasks/list_ideas/list_trash),又不放大暴露面(不暴露写工具,
    /// 对齐 `Intent::Code` 不含 `Intent::Debug` 才有的 run_command 那类收紧设计)。
    DataReadOnly,
    /// 文件：读写/patch/列目录/搜索（不含命令执行）
    File,
    /// 命令执行：run_command（shell 命令，独立 domain 防止被泛 File 意图带出）
    Exec,
    /// 网络：HTTP
    Http,
}

impl ToolDomain {
    /// 该 domain 下的工具名常量表（与 tool_registry 注册名一一对应）。
    fn tools(self) -> &'static [&'static str] {
        match self {
            ToolDomain::Data => &[
                "list_projects",
                "create_project",
                "update_project",
                "delete_project",
                "bind_directory",
                "list_tasks",
                "create_task",
                "update_task",
                "advance_task",
                "delete_task",
                "list_ideas",
                "create_idea",
                "run_workflow",
                "list_trash",
                "restore_project",
                "purge_project",
                "get_project_count",
                "get_task_count",
            ],
            ToolDomain::File => &[
                "read_file",
                "write_file",
                "patch_file",
                "list_directory",
                "search_files",
                "file_info",
                "append_file",
                "delete_file",
                "rename_file",
            ],
            // Exec domain 独立:run_command 从 File 移出。
            // 意图侧仅 Debug 默认带 Exec（用户明确"运行/测试/构建"），
            // Code/File/Search 不带 → 减少 LLM 对 run_command 的偏好暴露。
            ToolDomain::Exec => &["run_command"],
            ToolDomain::Http => &["http_request"],
            // DataReadOnly:Data domain 的只读子集。
            // 源于 Code 意图收敛需求:用户在编码场景说"先 list_projects 看下项目结构"
            // "create_task 记一下"时,意图识别可能命中 Code(SPECIFIC > ENTITY),原 Code subset
            // 不含 Data domain → list_projects/create_task 对 LLM 不可见,agent 被迫反复 read_file。
            // 加 Data 全 domain 会暴露 create/update/delete 等写工具(放大暴露面),
            // 故取只读子集:list_*/get_*_count(纯查询,无副作用)。
            ToolDomain::DataReadOnly => &[
                "list_projects",
                "list_tasks",
                "list_ideas",
                "list_trash",
                "get_project_count",
                "get_task_count",
            ],
        }
    }
}

// ---- 识别器 -----------------------------------------------------------------

/// 意图识别 L0 去噪：剥离 mention 结构化前缀（`[项目: DevFlow]` / `[任务: xxx]` 等）。
///
/// mention 前缀是系统注入的结构标签，语义已由 augmentation @ 通道单独 resolve
/// 注入 system prompt；content 里残留的前缀只污染关键词识别——实测会话
/// `[项目: DevFlow] 这个错误是项目里的 miniapp 报错` 被误判 `Intent::Project`，
/// 收敛砍掉 File domain 致 `read_file` 对 LLM 不可见，agent 被迫反复 `list_tasks`。
///
/// 剥离规则：扫描 `[`，若紧跟已知 mention kind（项目/任务/想法/技能，
/// project/task/idea/skill，大小写不敏感）+ 冒号（全/半角 `:`/`：`），则连同到
/// 首个 `]` 整段移除；未闭合或非已知 kind 的 `[...]` 原样保留（代码片段/错误码）。
///
/// kind 表与 `MentionRef`（project/task/idea/skill）对齐——单一真相源，新增 mention
/// kind 时此处同步。不折叠空白：关键词 `contains` 匹配对空白不敏感，剥离即可。
fn strip_mention_tags(message: &str) -> String {
    const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"];
    let chars: Vec<char> = message.chars().collect();
    let n = chars.len();
    let mut out = String::with_capacity(message.len());
    let mut i = 0;
    while i < n {
        if chars[i] == '[' {
            if let Some(close) = try_match_mention(&chars, i, KINDS) {
                i = close + 1; // 跳过整段 mention（含 `]`）
                continue;
            }
        }
        out.push(chars[i]);
        i += 1;
    }
    out
}

/// 探测 `chars[open] == '['` 起的 mention 段：`[kind: ...]`。
///
/// 返回匹配 `]` 的索引（`open` 指向 `[`）。kind 大小写不敏感（ASCII）+ 中文直比；
/// 冒号接受全角 `：`/半角 `:`；kind 后允许空白；未闭合 `]` 返 `None`（不当 mention，保留原样）。
fn try_match_mention(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
    let after = open + 1;
    for k in kinds {
        let kc: Vec<char> = k.chars().collect();
        if chars.len() < after + kc.len() {
            continue;
        }
        let slice = &chars[after..after + kc.len()];
        if !kc.iter().zip(slice.iter()).all(|(a, b)| a.eq_ignore_ascii_case(b)) {
            continue;
        }
        let mut j = after + kc.len();
        while j < chars.len() && chars[j].is_whitespace() {
            j += 1;
        }
        if j >= chars.len() || (chars[j] != ':' && chars[j] != '：') {
            continue;
        }
        j += 1;
        while j < chars.len() {
            if chars[j] == ']' {
                return Some(j);
            }
            j += 1;
        }
        // 未闭合 `]` → 不当 mention，保留原样
        return None;
    }
    None
}

/// 意图识别器（无状态，所有方法均为纯函数，可 `Default` 构造）。
#[derive(Debug, Clone, Default)]
pub struct IntentRecognizer;

impl IntentRecognizer {
    /// 识别会话意图。
    ///
    /// 返回 `(Intent, f32)`，置信度 0.0–1.0：
    /// - 命中关键词：`min(1.0, sum(命中权重))`，至少命中一条即胜出本组。
    /// - 未命中：`(Intent::Unknown, 0.0)`。
    ///
    /// 优先级：SPECIFIC > ENTITY > GENERIC。高分组的胜者直接返回，不累积低分组。
    pub fn recognize(message: &str) -> (Intent, f32) {
        // L0 去噪：先剥离 mention 结构标签（防 `[项目: x]` 把代码任务误判 Project）。
        let cleaned = strip_mention_tags(message);
        // 依次按优先级组求胜者。高分组有命中即提前返回。
        if let Some(hit) = best_in_group(&cleaned, SPECIFIC_GROUP) {
            return hit;
        }
        if let Some(hit) = best_in_group(&cleaned, ENTITY_GROUP) {
            return hit;
        }
        if let Some(hit) = best_in_group(&cleaned, GENERIC_GROUP) {
            return hit;
        }
        (Intent::Unknown, 0.0)
    }
}

/// 在一组候选里求置信度最高的（命中权重和截断到 1.0）。无任何命中返 `None`。
fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)> {
    let lower = message.to_lowercase();
    let mut best: Option<(Intent, f32)> = None;
    for cand in group {
        let mut score = 0.0f32;
        let mut matched = false;
        for rule in cand.rules.iter() {
            // 英文关键词用小写匹配；中文不受 to_lowercase 影响（no-op）。
            let needle = rule.kw.to_lowercase();
            if lower.contains(&needle) {
                score += rule.weight;
                matched = true;
            }
        }
        if !matched {
            continue;
        }
        let conf = score.min(1.0);
        match best {
            Some((_, b)) if b >= conf => {}
            _ => best = Some((cand.intent, conf)),
        }
    }
    best
}

// ---- Intent → 工具子集映射 --------------------------------------------------

/// 按 Intent 返回工具名子集。
///
/// 返回空 `Vec` 表示该意图**无工具收敛**（Chat）或**未识别**（Unknown），
/// 上游应走**全量 fallback**（即不过滤工具，交全量给 LLM）。
///
/// 设计：Code → [file, http, data_readonly]（加 data_readonly:编码场景用户常需"先 list_projects
/// 看下结构/list_tasks 记一下",Code 命中优先级高于 Project/Task(SPECIFIC > ENTITY)会砍 Data domain,
/// 取只读子集防"断手"又不放大写工具暴露面,见 ToolDomain::DataReadOnly）；
/// File → [file]；Project/Task/Idea → [data, file]（加 file 防「提项目/任务 → 误判 → 砍只读探索」，见 L1）；
/// Http → [http]；Search → [file]（含 search_files）；Conversation → [];
/// Chat → []；Debug → [file, exec, http, data]（调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具）；
/// **仅 Debug 含 Exec**（用户明确"运行/测试/构建/调试"才暴露 run_command）,
/// Code/File/Search 不含 Exec → 收紧 run_command 暴露面；Unknown → []（全量）。
pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
    let domains: &[ToolDomain] = match intent {
        Intent::Code => &[ToolDomain::File, ToolDomain::Http, ToolDomain::DataReadOnly],
        Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data],
        Intent::File => &[ToolDomain::File],
        // Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查
        // (实测会话 [项目:x] 报错 → Project → 砍 File 致 read_file 对 LLM 不可见)。
        // 保留只读探索;写工具有 RiskLevel/审批兜底,"分心"远好于"断手"。
        Intent::Project => &[ToolDomain::Data, ToolDomain::File],
        Intent::Task => &[ToolDomain::Data, ToolDomain::File],
        Intent::Idea => &[ToolDomain::Data, ToolDomain::File],
        Intent::Conversation => &[],
        Intent::Search => &[ToolDomain::File],
        Intent::Http => &[ToolDomain::Http],
        Intent::Chat => &[],
        Intent::Unknown => &[],
    };
    let mut out: Vec<&'static str> = Vec::new();
    for d in domains {
        for name in d.tools() {
            if !out.contains(name) {
                out.push(name);
            }
        }
    }
    out
}

// ---- 模态建议(意图 → 模型 tier 映射) ----------------------------------------

/// 按 Intent 建议模型模态档位(意图 → 模型 tier 的语义映射)。
///
/// **映射分组**(对齐 router 同 weight tiebreak 缺语义的根因修复):
/// - `Code`/`Debug` → `Heavy`(复杂推理/重构/排查,需重模型)
/// - `Http` → `Heavy`(多跳外部调用,链长易错,需重模型把关)
/// - `File`/`Search`/`Task`/`Idea`/`Project`/`Conversation` → `Standard`(默认复杂度)
/// - `Chat` → `Fast`(简单闲聊,轻量即可)
/// - `Unknown` → `None`(未识别,fallback 全量工具时不强加 tier 偏好,
///   上游走默认档位)
///
/// 返回 `Some(ModelTier)` 时上游可作为同 weight 候选间的 tiebreak 依据
/// (重档位优先),或据此路由到不同 provider/model(待模型模态管理 Phase 接入)。
pub fn suggested_model_tier(intent: &Intent) -> Option<ModelTier> {
    match intent {
        // 重型:复杂推理/重构/排查/多跳调用
        Intent::Code | Intent::Debug | Intent::Http => Some(ModelTier::Heavy),
        // 标准:默认复杂度
        Intent::File
        | Intent::Search
        | Intent::Task
        | Intent::Idea
        | Intent::Project
        | Intent::Conversation => Some(ModelTier::Standard),
        // 轻量:简单闲聊
        Intent::Chat => Some(ModelTier::Fast),
        // 未识别:不强加 tier 偏好,fallback 上游默认
        Intent::Unknown => None,
    }
}

// ---- 工具子集过滤（agentic loop 接入用，改进2 A） -----------------------------

/// 按意图过滤 `tool_defs`：subset 非空只留命中，空返回全量（低置信/Unknown/Chat fallback）。
///
/// **接入语义**（改进2 B）：agentic loop 在调用 LLM 前用本函数收敛 LLM 可见工具集，
/// 减少跑题（如纯闲聊不暴露文件操作工具）。三重 fallback 保证安全：
/// 1. `tool_subset_for(intent)` 空（Chat/Unknown/Conversation）→ 返回 `all_defs` 全量。
/// 2. subset 工具名在 `all_defs` 找不到 → 跳过该名（防 registry 漂移导致过滤后为空）。
/// 3. 调用方再做「过滤后 < 3 条 → 回全量」的兜底（见 agentic loop）。
///
/// **关键安全**：filter 仅影响 LLM 可见 tool_defs，**不影响执行**（audit 走 tools_arc
/// get/execute 完整 registry，LLM 即使幻觉一个被滤掉的工具名，audit 也能查到/拒绝）。
pub fn filter_tool_defs(
    all_defs: &[df_ai_core::types::ToolDefinition],
    intent: &Intent,
) -> Vec<df_ai_core::types::ToolDefinition> {
    let subset = tool_subset_for(intent);
    if subset.is_empty() {
        return all_defs.to_vec();
    }
    let allowed: std::collections::HashSet<&str> = subset.iter().copied().collect();
    all_defs
        .iter()
        .filter(|d| allowed.contains(d.function.name.as_str()))
        .cloned()
        .collect()
}

// ---- 工具子集过滤 + plan_hint 编排(B 路线 Phase 1 接入主 loop) --------------

/// 在 `filter_tool_defs` 收敛的扁平子集之上,叠加 `plan_hint` 的编排关系
/// (并行组同批聚拢 / 顺序依赖源在前),供 LLM 看到一份**按编排意图排序**的工具列表。
///
/// **Phase 1 接入语义**(依据
/// `docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md` §五 Phase 1 +
/// `单对话并行多轮-Phase0落地路线图-2026-06-20.md` §八 Phase 1 硬前置):
/// - **纯函数零延迟**(与 intent/plan_hint 一致,无 IO 无 LLM 调用)。
/// - **不真正多轮并行**(Phase 1 仅提示层;真正子流并行 execution 留 Phase 3)。
/// - 行为 = 先 `filter_tool_defs` 收敛 → 再按 `plan_hint` 产出排序。
///
/// **三重 fallback(与 filter_tool_defs 一致的安全语义)**:
/// 1. `plan_hint` 返空 PlanHint(无编排信号/单工具/闲聊)→ 退 `filter_tool_defs` 扁平结果。
/// 2. `plan_hint::validate` 判非法(环/空组/自环/重复边)→ 丢弃 hint 退扁平结果
///    (主 loop 不能据非法 hint 调度,否则死锁)。
/// 3. hint 引用的工具名不在 filtered 子集(registry 漂移)→ 该名跳过,剩余照常排序。
///
/// **排序规则**(确定性,便于单测固定行为):
/// - 收集 hint 所有并行组里的工具名,按「组序 → 组内顺序」展开成有序列表 `ordered`。
/// - 输出 = `ordered` 中能在 filtered 找到的(保 ordered 顺序) +
///   filtered 中未被 ordered 命中的(保 filtered 原序,放尾部兜底,防丢工具)。
/// - 顺序依赖(`sequence` 边)的「from」节点天然被并行组顺序吸收(组在前 = from 先出现),
///   Phase 1 不额外重排依赖边(真正 DAG 调度留 Phase 3 Kahn 分层)。
///
/// **关键安全**(与 filter_tool_defs 同):本函数**只改 LLM 可见 tool_defs 的顺序/可见性**,
/// 不改执行路径(audit 走 tools_arc 完整 registry)。LLM 即使幻觉被滤掉的顺序,
/// audit 仍能查到/拒绝。
///
/// `intent`:主 loop 已识别的意图;其 `as_str()` 标签内部派生传给 plan_hint。
/// `intent_label`:**冗余参数**(等价于 `intent.as_str()`),仅为调用方签名兼容保留,
/// 内部已忽略——以 `intent.as_str()` 为权威源,防调用方传错标签与实际 intent 不一致。
/// `context`:用户末条消息原文(plan_hint 关键词检测用)。
pub fn filter_tool_defs_planned(
    all_defs: &[df_ai_core::types::ToolDefinition],
    intent: &Intent,
    intent_label: &str,
    context: &str,
) -> Vec<df_ai_core::types::ToolDefinition> {
    // 第一步:先得扁平收敛子集(复用 filter_tool_defs 的全部 fallback 语义)。
    let filtered = filter_tool_defs(all_defs, intent);

    // 第二步:产 PlanHint。PLAN_HINT_ENABLED 关时 plan_hint 内部返空 → 退扁平。
    // intent_label 内部由 intent.as_str() 派生(冗余参数权威性低于 intent 本身)。
    let label = intent.as_str();
    let _ = intent_label; // 冗余:签名兼容保留,内部以 intent.as_str() 为准。
    let hint = crate::plan_hint::plan_hint(label, context);

    // 空 hint(无编排信号)→ 直接返扁平收敛结果(零行为变更 vs filter_tool_defs)。
    if hint.is_empty() {
        return filtered;
    }

    // 非法 hint(环/空组/自环/重复边)→ 丢弃,退扁平(主 loop 不能据非法 hint 调度)。
    if !matches!(crate::plan_hint::validate(&hint), crate::plan_hint::HintValidity::Ok) {
        return filtered;
    }

    // 第三步:按 hint 编排关系重排 filtered。先展开「组序 → 组内序」的有序工具名表。
    let mut ordered_names: Vec<&'static str> = Vec::new();
    for g in &hint.groups {
        for t in &g.tools {
            if !ordered_names.contains(t) {
                ordered_names.push(*t);
            }
        }
    }

    // 第四步:用 HashMap 索引(filtered 名 → 其在该 Vec 中的位置)替代「ordered × filtered」
    // 嵌套线性扫描。原版对每个 ordered 名做一次 O(filtered) 内层扫描,总 O(ordered × filtered);
    // 索引化后 O(filtered) 建索引 + 每 ordered 名 O(1) 查(filtered 通常 5-20 个工具,
    // 索引开销极小,但消除最坏 O(n²) 当 ordered/filtered 同时增长)。
    use std::collections::HashMap;
    // name_to_idx:工具名 → filtered 中首次出现位置(filtered 内工具名唯一,无二义)。
    let mut name_to_idx: HashMap<&str, usize> = HashMap::with_capacity(filtered.len());
    for (i, d) in filtered.iter().enumerate() {
        // filtered 名唯一(同 domain 不会重复),with_capacity 后直接 insert 不冲突。
        name_to_idx.entry(d.function.name.as_str()).or_insert(i);
    }

    // 按 ordered 顺序挑 filtered 里的(命中编排顺序的工具置前)。
    let mut result: Vec<df_ai_core::types::ToolDefinition> = Vec::with_capacity(filtered.len());
    let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
    for name in &ordered_names {
        if let Some(&idx) = name_to_idx.get(*name) {
            if consumed.insert(idx) {
                result.push(filtered[idx].clone());
            }
        }
    }
    // 未被 ordered 命中的(filtered 里有但 hint 没标)→ 保 filtered 原序追加尾部兜底(防丢工具)。
    for (i, d) in filtered.iter().enumerate() {
        if consumed.insert(i) {
            result.push(d.clone());
        }
    }

    result
}

// ---- 单测 -------------------------------------------------------------------

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

    // --- Intent 枚举 ---

    #[test]
    fn intent_as_str_covers_all_variants() {
        assert_eq!(Intent::Code.as_str(), "code");
        assert_eq!(Intent::Debug.as_str(), "debug");
        assert_eq!(Intent::File.as_str(), "file");
        assert_eq!(Intent::Project.as_str(), "project");
        assert_eq!(Intent::Task.as_str(), "task");
        assert_eq!(Intent::Idea.as_str(), "idea");
        assert_eq!(Intent::Conversation.as_str(), "conversation");
        assert_eq!(Intent::Search.as_str(), "search");
        assert_eq!(Intent::Http.as_str(), "http");
        assert_eq!(Intent::Chat.as_str(), "chat");
        assert_eq!(Intent::Unknown.as_str(), "unknown");
    }

    // --- recognize 各意图（中文） ---

    #[test]
    fn recognize_code_zh() {
        let (i, c) = IntentRecognizer::recognize("帮我重构这段代码里的函数");
        assert_eq!(i, Intent::Code);
        assert!(c > 0.0 && c <= 1.0);
        // 命中"重构"+"代码"+"函数"，置信度应封顶 1.0
        assert!((c - 1.0).abs() < f32::EPSILON);
    }

    #[test]
    fn recognize_code_wins_over_debug_in_specific_group() {
        let (i, c) = IntentRecognizer::recognize("这个 bug 怎么复现，帮我调试排查一下");
        // "bug" 在 Code 组，"复现/调试/排查" 在 Debug 组 —— SPECIFIC 组内 Code 命中先于 Debug
        // Code 命中(bug=1.0) 即胜出本组，不再看 Debug。断言优先级正确：Code 胜出。
        assert_eq!(i, Intent::Code);
        assert!(c >= 1.0 - f32::EPSILON);
    }

    #[test]
    fn recognize_debug_pure() {
        let (i, _) = IntentRecognizer::recognize("帮我调试这个 panic 堆栈");
        assert_eq!(i, Intent::Debug);
    }

    #[test]
    fn recognize_file_zh() {
        let (i, _) = IntentRecognizer::recognize("读取一下这个文件再写入");
        assert_eq!(i, Intent::File);
    }

    #[test]
    fn recognize_project_zh() {
        let (i, _) = IntentRecognizer::recognize("创建项目并绑定目录");
        assert_eq!(i, Intent::Project);
    }

    #[test]
    fn recognize_task_zh() {
        let (i, _) = IntentRecognizer::recognize("把这个任务推进到下一个状态");
        assert_eq!(i, Intent::Task);
    }

    #[test]
    fn recognize_idea_zh() {
        let (i, _) = IntentRecognizer::recognize("我有个灵感想评估一下");
        assert_eq!(i, Intent::Idea);
    }

    #[test]
    fn recognize_search_zh() {
        let (i, _) = IntentRecognizer::recognize("搜索一下代码里哪里用了 grep");
        // "搜索" Search(SPECIFIC) 优先于 "代码" Code(SPECIFIC)：同组取高分。
        // "搜索"=1.0 vs "代码"=1.0 平局，按表顺序 Code 在前会胜出 —— 调整用例避免歧义。
        // 这里只断言落在 SPECIFIC 组之一即可（实际设计具体组互斥，单消息取高分）。
        assert!(matches!(i, Intent::Search | Intent::Code));
    }

    #[test]
    fn recognize_search_pure() {
        let (i, _) = IntentRecognizer::recognize("grep 查找关键字");
        assert_eq!(i, Intent::Search);
    }

    // --- strip_mention_tags（L0 去噪）---

    #[test]
    fn strip_removes_all_mention_kinds() {
        assert_eq!(strip_mention_tags("[项目: DevFlow] 重构代码"), " 重构代码");
        assert_eq!(strip_mention_tags("[任务: 修复bug] 看看"), " 看看");
        assert_eq!(strip_mention_tags("[想法: x][技能: y] 闲聊"), " 闲聊");
        // 英文 kind + 大小写不敏感
        assert_eq!(strip_mention_tags("[Project: x] fix this"), " fix this");
        assert_eq!(strip_mention_tags("[TASK: y] do it"), " do it");
        // 全角冒号
        assert_eq!(strip_mention_tags("[项目：DevFlow] 阅读"), " 阅读");
    }

    #[test]
    fn strip_preserves_non_mention_brackets() {
        // 非 mention 的 [...] 原样保留(代码片段/错误码)
        assert_eq!(strip_mention_tags("数组 [1,2,3] 求和"), "数组 [1,2,3] 求和");
        assert_eq!(strip_mention_tags("[error] 致命"), "[error] 致命"); // 'error' 非 kind
        assert_eq!(strip_mention_tags("[项目报错] 看"), "[项目报错] 看"); // kind 后无冒号
        // 未闭合的 mention-like 不剥离(防误吞)
        assert_eq!(strip_mention_tags("[项目: 未闭合"), "[项目: 未闭合");
    }

    #[test]
    fn recognize_mention_prefix_not_misread_as_project() {
        // 核心修复:[项目: DevFlow] 帮我重构代码 → Code(非 Project)
        // 原 bug:mention 前缀的"项目"命中 PROJECT(1.0)→ 收敛砍 File → read_file 不可见。
        let (i, c) = IntentRecognizer::recognize("[项目: DevFlow] 帮我重构这段代码");
        assert_eq!(i, Intent::Code);
        assert!(c >= 0.7, "重构+代码 应达 Code 阈值, 实际 conf={}", c);
    }

    #[test]
    fn recognize_real_project_word_still_hits_project() {
        // 用户口语真"项目"(非 mention 前缀)仍命中 Project——预处理只去系统噪声。
        // 正是 L1(tool_subset_for 加 File)不可省的佐证:口语误判靠映射兜底。
        let (i, _) = IntentRecognizer::recognize("创建项目并绑定目录");
        assert_eq!(i, Intent::Project);
    }

    #[test]
    fn recognize_http_zh() {
        let (i, _) = IntentRecognizer::recognize("调用接口请求这个 api");
        assert_eq!(i, Intent::Http);
    }

    #[test]
    fn recognize_http_en() {
        let (i, _) = IntentRecognizer::recognize("fetch the url via http");
        assert_eq!(i, Intent::Http);
    }

    #[test]
    fn recognize_chat_zh() {
        let (i, _) = IntentRecognizer::recognize("你好，谢谢你的帮助");
        assert_eq!(i, Intent::Chat);
    }

    #[test]
    fn recognize_chat_en() {
        let (i, _) = IntentRecognizer::recognize("hello, thanks!");
        assert_eq!(i, Intent::Chat);
    }

    #[test]
    fn recognize_conversation() {
        let (i, _) = IntentRecognizer::recognize("总结一下这段会话历史");
        assert_eq!(i, Intent::Conversation);
    }

    // --- recognize 边界 ---

    #[test]
    fn recognize_empty_is_unknown() {
        let (i, c) = IntentRecognizer::recognize("");
        assert_eq!(i, Intent::Unknown);
        assert_eq!(c, 0.0);
    }

    #[test]
    fn recognize_whitespace_is_unknown() {
        let (i, c) = IntentRecognizer::recognize("   \n\t  ");
        assert_eq!(i, Intent::Unknown);
        assert_eq!(c, 0.0);
    }

    #[test]
    fn recognize_no_keyword_is_unknown() {
        let (i, c) = IntentRecognizer::recognize("今天的天气不错啊");
        assert_eq!(i, Intent::Unknown);
        assert_eq!(c, 0.0);
    }

    #[test]
    fn recognize_confidence_bounded() {
        // 大量命中也应封顶 1.0
        let (_, c) = IntentRecognizer::recognize("代码 函数 方法 编译 error impl 重构");
        assert!(c >= 0.0 && c <= 1.0);
        assert!((c - 1.0).abs() < f32::EPSILON);
    }

    // --- 优先级 ---

    #[test]
    fn priority_specific_over_entity() {
        // "代码"(Code/SPECIFIC) + "文件"(File/ENTITY) → SPECIFIC 胜出
        let (i, _) = IntentRecognizer::recognize("看看这段代码对应的文件");
        assert_eq!(i, Intent::Code);
    }

    #[test]
    fn priority_entity_over_generic() {
        // "任务"(Task/ENTITY) + "帮助"(Chat/GENERIC) → ENTITY 胜出
        let (i, _) = IntentRecognizer::recognize("帮我处理这个任务，谢谢帮助");
        assert_eq!(i, Intent::Task);
    }

    #[test]
    fn case_insensitive_english() {
        let (i, _) = IntentRecognizer::recognize("HTTP REQUEST to API");
        assert_eq!(i, Intent::Http);
    }

    // --- tool_subset_for ---

    #[test]
    fn subset_code_has_file_and_http() {
        let s = tool_subset_for(&Intent::Code);
        assert!(s.contains(&"read_file"));
        assert!(s.contains(&"write_file"));
        assert!(s.contains(&"patch_file"));
        assert!(s.contains(&"http_request"));
        // Code 现含 DataReadOnly → list_projects 等只读工具保留(不再断言"不含")
        assert!(s.contains(&"list_projects"));
    }

    #[test]
    fn subset_code_keeps_data_readonly_tools() {
        // 根因修复(Code 命中砍 Data domain 致 list_*/get_*_count 不可见):
        // Code subset 应含 DataReadOnly 全部 6 个只读工具,让"先 list_projects 看下结构"
        // "list_tasks 记一下"这类编码场景的口语不被 Code 意图砍工具。
        let s = tool_subset_for(&Intent::Code);
        for read_only in [
            "list_projects",
            "list_tasks",
            "list_ideas",
            "list_trash",
            "get_project_count",
            "get_task_count",
        ] {
            assert!(
                s.contains(&read_only),
                "Code subset 应含只读工具 {}(DataReadOnly domain)",
                read_only
            );
        }
    }

    #[test]
    fn subset_code_no_data_write_tools() {
        // 防回归:Code subset 只加 Data 的"只读子集",不得暴露写工具。
        // create_task 不应在 Code subset(只读 list_* 在,写 create_* 不在)。
        // create_project/update_project/delete_project/advance_task/run_workflow 同理。
        let s = tool_subset_for(&Intent::Code);
        for write_tool in [
            "create_task",
            "update_task",
            "delete_task",
            "create_project",
            "update_project",
            "delete_project",
            "advance_task",
            "run_workflow",
        ] {
            assert!(
                !s.contains(&write_tool),
                "Code subset 不应含写工具 {}(只读子集,防放大暴露面), 实际 subset: {:?}",
                write_tool,
                s
            );
        }
    }

    #[test]
    fn subset_file_only_file_domain() {
        let s = tool_subset_for(&Intent::File);
        assert!(s.contains(&"read_file"));
        assert!(s.contains(&"search_files"));
        assert!(!s.contains(&"http_request"));
        assert!(!s.contains(&"list_projects"));
    }

    #[test]
    fn subset_project_is_data_and_file() {
        // L1 修复:Project 加 File domain(防"提项目 → 误判 Project → 砍只读探索")。
        // Data(create_project/bind_directory)+ File 只读(read_file)共存。
        let s = tool_subset_for(&Intent::Project);
        assert!(s.contains(&"create_project"));
        assert!(s.contains(&"bind_directory"));
        assert!(s.contains(&"read_file"), "Project 应保留 read_file(L1)");
    }

    #[test]
    fn subset_task_is_data() {
        let s = tool_subset_for(&Intent::Task);
        assert!(s.contains(&"advance_task"));
        assert!(s.contains(&"create_task"));
    }

    #[test]
    fn subset_idea_is_data() {
        let s = tool_subset_for(&Intent::Idea);
        assert!(s.contains(&"list_ideas"));
        assert!(s.contains(&"create_idea"));
    }

    #[test]
    fn subset_http_only_http() {
        let s = tool_subset_for(&Intent::Http);
        assert_eq!(s, vec!["http_request"]);
    }

    #[test]
    fn subset_search_has_search_files() {
        let s = tool_subset_for(&Intent::Search);
        assert!(s.contains(&"search_files"));
        // Search 复用 File domain,read_file 等也在
        assert!(s.contains(&"read_file"));
    }

    #[test]
    fn subset_chat_empty_means_full_fallback() {
        let s = tool_subset_for(&Intent::Chat);
        assert!(s.is_empty(), "Chat 空=全量 fallback 约定");
    }

    #[test]
    fn subset_unknown_empty_means_full_fallback() {
        let s = tool_subset_for(&Intent::Unknown);
        assert!(s.is_empty(), "Unknown 空=全量 fallback 约定");
    }

    #[test]
    fn subset_debug_has_file_and_http() {
        let s = tool_subset_for(&Intent::Debug);
        assert!(s.contains(&"read_file"));
        assert!(s.contains(&"http_request"));
    }

    #[test]
    fn subset_no_duplicates_across_domains() {
        // Code/Debug 跨 file+http domain,确保去重
        let s = tool_subset_for(&Intent::Code);
        let mut seen = std::collections::HashSet::new();
        for n in &s {
            assert!(seen.insert(*n), "重复工具名: {}", n);
        }
    }

    // --- ToolDomain 工具名与 registry 对齐（核验） ---

    #[test]
    fn tool_domain_names_match_registry() {
        // 抽样核对与 src-tauri tool_registry 实际注册名一致
        assert!(ToolDomain::File.tools().contains(&"patch_file"));
        // run_command 已从 File 移出 Exec domain(意图收紧:仅 Debug 默认带 run_command)
        assert!(!ToolDomain::File.tools().contains(&"run_command"));
        assert_eq!(ToolDomain::Exec.tools(), &["run_command"]);
        assert!(ToolDomain::Data.tools().contains(&"advance_task"));
        assert!(ToolDomain::Data.tools().contains(&"run_workflow"));
        assert!(ToolDomain::Data.tools().contains(&"list_trash"));
        assert_eq!(ToolDomain::Http.tools(), &["http_request"]);
    }

    // --- suggested_model_tier 意图 → 档位映射 ---

    #[test]
    fn model_tier_mapping_now_returns_actual() {
        // Code/Debug/Http → Heavy(复杂推理/重构/排查/多跳调用)
        for i in [Intent::Code, Intent::Debug, Intent::Http] {
            assert_eq!(
                suggested_model_tier(&i),
                Some(ModelTier::Heavy),
                "intent {:?} 应映射 Heavy",
                i
            );
        }
        // File/Search/Task/Idea/Project/Conversation → Standard(默认复杂度)
        for i in [
            Intent::File,
            Intent::Search,
            Intent::Task,
            Intent::Idea,
            Intent::Project,
            Intent::Conversation,
        ] {
            assert_eq!(
                suggested_model_tier(&i),
                Some(ModelTier::Standard),
                "intent {:?} 应映射 Standard",
                i
            );
        }
        // Chat → Fast(简单闲聊)
        assert_eq!(suggested_model_tier(&Intent::Chat), Some(ModelTier::Fast));
        // Unknown → None(未识别,不强加 tier 偏好,fallback 上游默认)
        assert_eq!(suggested_model_tier(&Intent::Unknown), None);
    }

    // --- IntentRecognizer Default ---

    #[test]
    fn recognizer_is_default_constructible() {
        let _r = IntentRecognizer::default();
        // 纯静态方法,实例仅占位
        let (i, _) = IntentRecognizer::recognize("hi");
        assert_eq!(i, Intent::Chat);
    }

    // --- filter_tool_defs（改进2 A） ---

    /// 构造测试用 ToolDefinition（仅 name 有意义，description/parameters 填占位）。
    fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
        df_ai_core::types::ToolDefinition {
            tool_type: "function".into(),
            function: df_ai_core::types::ToolFunction {
                name: name.to_string(),
                description: String::new(),
                parameters: serde_json::json!({}),
            },
        }
    }

    #[test]
    fn filter_subset_empty_returns_all_for_unknown() {
        // Unknown → subset 空 → fallback 全量
        let all = vec![tool_def("read_file"), tool_def("create_project")];
        let out = filter_tool_defs(&all, &Intent::Unknown);
        assert_eq!(out.len(), all.len(), "Unknown subset 空应回全量");
    }

    #[test]
    fn filter_subset_empty_returns_all_for_chat() {
        // Chat → subset 空 → fallback 全量（低意图/闲聊不收敛）
        let all = vec![tool_def("read_file"), tool_def("write_file")];
        let out = filter_tool_defs(&all, &Intent::Chat);
        assert_eq!(out.len(), all.len(), "Chat subset 空应回全量");
    }

    #[test]
    fn filter_subset_empty_returns_all_for_conversation() {
        let all = vec![tool_def("read_file")];
        let out = filter_tool_defs(&all, &Intent::Conversation);
        assert_eq!(out.len(), 1, "Conversation subset 空应回全量");
    }

    #[test]
    fn filter_subset_hit_filters_to_matching() {
        // File 意图 subset = File domain(read_file/write_file/...);Http 工具应被滤掉
        let all = vec![
            tool_def("read_file"),
            tool_def("write_file"),
            tool_def("http_request"), // 不在 File domain
        ];
        let out = filter_tool_defs(&all, &Intent::File);
        assert!(out.iter().any(|d| d.function.name == "read_file"));
        assert!(out.iter().any(|d| d.function.name == "write_file"));
        assert!(
            !out.iter().any(|d| d.function.name == "http_request"),
            "File 意图不应含 http_request"
        );
        assert_eq!(out.len(), 2);
    }

    #[test]
    fn filter_subset_drift_skips_missing_names() {
        // subset 命中但 all_defs 里没有对应工具(registry 漂移)→ 跳过,不 panic
        // Project 意图 subset = Data domain(create_project/...),all 里只放了一个 Data 工具 + 一个无关工具
        let all = vec![
            tool_def("create_project"),  // 命中
            tool_def("http_request"),    // 不在 Data domain,滤掉
            // 其余 Data domain 工具名在 subset 里但 all 没有 → 跳过
        ];
        let out = filter_tool_defs(&all, &Intent::Project);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].function.name, "create_project");
    }

    #[test]
    fn filter_all_drift_returns_empty_not_panic() {
        // 极端漂移:subset 命中但 all_defs 完全不交集 → 返回空(调用方做 <3 回全量兜底)
        let all = vec![tool_def("totally_unknown_tool")];
        let out = filter_tool_defs(&all, &Intent::File);
        assert!(out.is_empty(), "全部漂移应返空(交调用方兜底)");
    }

    #[test]
    fn filter_preserves_input_order_for_matching() {
        // 过滤后顺序应跟 all_defs 一致(filter 保留原序)
        let all = vec![
            tool_def("write_file"),
            tool_def("read_file"),
            tool_def("patch_file"),
            tool_def("http_request"),
        ];
        let out = filter_tool_defs(&all, &Intent::File);
        let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
        assert_eq!(names, vec!["write_file", "read_file", "patch_file"]);
    }

    // ===== 苛刻测:对抗 + 边界 + 极端(filter_tool_defs / tool_subset_for) =====

    #[test]
    fn filter_total_drift_subset_names_none_in_registry_returns_empty_no_panic() {
        // 对抗:registry 全改名(File subset 工具名一个不在 all_defs)
        // → filter 返空 Vec(调用方 <3 回全量兜底)。证不 panic。
        let all = vec![
            tool_def("renamed_read_file_v2"),
            tool_def("totally_other_tool"),
            tool_def("weird_name_xyz"),
        ];
        let out = filter_tool_defs(&all, &Intent::File);
        assert!(out.is_empty(), "全漂移应返空(交调用方兜底), 实际: {}", out.len());
    }

    #[test]
    fn filter_partial_drift_keeps_only_intersecting() {
        // 对抗:subset 含 N 个工具名,all_defs 只命中其中 2 个,其余跳过(不 panic,不补全)
        let all = vec![
            tool_def("read_file"),
            tool_def("patch_file"),
            tool_def("unrelated_thing"),
        ];
        let out = filter_tool_defs(&all, &Intent::File);
        let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
        assert_eq!(names, vec!["read_file", "patch_file"], "部分漂移只留交集, 顺序跟 all");
    }

    #[test]
    fn filter_empty_all_defs_no_panic() {
        // 极端:all_defs 空(无工具注册)。无论 intent 怎样都不 panic,返空 Vec。
        let empty: Vec<_> = vec![];
        let out_unknown = filter_tool_defs(&empty, &Intent::Unknown);
        let out_file = filter_tool_defs(&empty, &Intent::File);
        assert!(out_unknown.is_empty());
        assert!(out_file.is_empty(), "空 registry + File 意图应返空不 panic");
    }

    #[test]
    fn filter_debug_subset_spans_four_domains_complete() {
        // 对抗(多 domain):Debug subset 跨 File + Exec + Http + Data 四 domain,
        // 必须同条消息里能同时命中四个 domain 的工具(防 domain 漏挂)
        let all = vec![
            // Data domain
            tool_def("list_tasks"),
            tool_def("create_project"),
            tool_def("run_workflow"),
            // File domain
            tool_def("read_file"),
            tool_def("patch_file"),
            // Exec domain
            tool_def("run_command"),
            // Http domain
            tool_def("http_request"),
        ];
        let out = filter_tool_defs(&all, &Intent::Debug);
        let names: Vec<String> = out.iter().map(|d| d.function.name.clone()).collect();
        assert!(names.contains(&"list_tasks".to_string()), "Debug 必含 Data(list_tasks), 防 CR-25 🟡-1 丢 Data");
        assert!(names.contains(&"read_file".to_string()), "Debug 必含 File");
        assert!(names.contains(&"run_command".to_string()), "Debug 必含 Exec(run_command)");
        assert!(names.contains(&"http_request".to_string()), "Debug 必含 Http");
        assert_eq!(names.len(), 7, "四 domain 工具全保留, 实际: {:?}", names);
    }

    #[test]
    fn run_command_only_exposed_for_debug_intent() {
        // 核心修复断言:run_command 仅 Debug 暴露。
        // Code/File/Search 意图 subset 不含 Exec domain → 不含 run_command。
        for intent in [Intent::Code, Intent::File, Intent::Search] {
            let s = tool_subset_for(&intent);
            assert!(
                !s.contains(&"run_command"),
                "Intent {:?} subset 不应含 run_command(仅 Debug 暴露), 实际: {:?}",
                intent,
                s
            );
        }
        // Debug 必含 run_command(Exec domain)
        let dbg = tool_subset_for(&Intent::Debug);
        assert!(dbg.contains(&"run_command"), "Debug 必含 run_command");
    }

    #[test]
    fn filter_low_confidence_unknown_subset_empty_falls_back_full() {
        // 对抗(低置信):"帮我看看这个"无任何关键词 → recognize 返 Unknown/0.0
        // → tool_subset_for(Unknown) 空 → filter 返全量(fallback 链完整)
        let (intent, conf) = IntentRecognizer::recognize("帮我看看这个");
        assert_eq!(intent, Intent::Unknown, "无关键词应 Unknown");
        assert_eq!(conf, 0.0, "置信度应 0.0");
        assert!(tool_subset_for(&intent).is_empty(), "Unknown subset 应空");

        let all = vec![tool_def("read_file"), tool_def("write_file"), tool_def("http_request")];
        let out = filter_tool_defs(&all, &intent);
        assert_eq!(out.len(), all.len(), "Unknown/低置信应回全量 fallback");
    }

    #[test]
    fn subset_all_intents_covered_names_in_registry() {
        // 对抗(完整性):遍历所有 Intent 的 subset,每个工具名都能在对应 ToolDomain.tools() 找到。
        // 防 subset 表写错工具名(如 read_filez 笔误)。subset 工具名必须真存在于其声明 domain。
        let all_intents = [
            Intent::Code,
            Intent::Debug,
            Intent::File,
            Intent::Project,
            Intent::Task,
            Intent::Idea,
            Intent::Conversation,
            Intent::Search,
            Intent::Http,
            Intent::Chat,
            Intent::Unknown,
        ];
        for intent in all_intents {
            let subset = tool_subset_for(&intent);
            // 全 registry 工具名(五 domain 并集:Data + DataReadOnly + File + Exec + Http)
            // 注:DataReadOnly 工具名是 Data 的子集,chain 它仅为语义显式(并集去重无副作用),
            // 防 Code subset 里 list_projects 等 DataReadOnly 工具被判"不在 registry"。
            let registry: std::collections::HashSet<&str> = ToolDomain::Data
                .tools()
                .iter()
                .chain(ToolDomain::DataReadOnly.tools().iter())
                .chain(ToolDomain::File.tools().iter())
                .chain(ToolDomain::Exec.tools().iter())
                .chain(ToolDomain::Http.tools().iter())
                .copied()
                .collect();
            for name in &subset {
                assert!(
                    registry.contains(*name),
                    "Intent {:?} subset 含工具名 {} 不在任何 domain 注册表",
                    intent,
                    name
                );
            }
        }
    }

    #[test]
    fn filter_subset_correct_intent_for_multi_domain_message() {
        // 对抗(跨 domain 完整):"调试任务并读文件"意图多 domain,
        // 但 recognize 按优先级定单一 intent,filter 据此收敛。
        // 关键:无论 recognize 落哪个 intent,该 intent 的 subset 必须覆盖任务+文件相关工具。
        let (intent, conf) = IntentRecognizer::recognize("调试这个任务");
        // "调试"(Debug/SPECIFIC, 1.0) 优先于 "任务"(Task/ENTITY)
        assert_eq!(intent, Intent::Debug);
        assert!(conf >= 0.7);
        let subset = tool_subset_for(&intent);
        // Debug subset 必含 list_tasks(Data) + read_file(File) 防"调试任务"丢工具
        assert!(subset.contains(&"list_tasks"), "Debug 应含 list_tasks(Data domain)");
        assert!(subset.contains(&"read_file"), "Debug 应含 read_file(File domain)");
    }

    #[test]
    fn subset_code_and_debug_dedup_http_across_domains() {
        // 边界:Code = [File, Http], Debug = [File, Http, Data]
        // File + Http 都含 http_request,必须去重(subset 内不重复)
        for intent in [Intent::Code, Intent::Debug] {
            let s = tool_subset_for(&intent);
            let http_count = s.iter().filter(|n| **n == "http_request").count();
            assert_eq!(http_count, 1, "Intent {:?} subset http_request 应去重为 1", intent);
        }
    }

    // ===== filter_tool_defs_planned(B 路线 Phase 1 接入主 loop 用) =====
    //
    // 单测覆盖 Phase 1 接入语义:flag 关(filter_tool_defs 旧行为)、
    // plan_hint 空 hint 退扁平、非法 hint 退扁平、registry 漂移跳过、有序重排。
    // PLANNING_ENABLED(planner.rs)门控主 loop 接入点;PLAN_HINT_ENABLED(plan_hint.rs)
    // 门控 plan_hint 函数内部产出。本组单测直接验证 filter_tool_defs_planned
    // 纯函数行为(两层 flag 均为当前默认值时 plan_hint 函数产非空 hint)。

    #[test]
    fn planned_empty_intent_subset_falls_back_to_full() {
        // Unknown subset 空 → filter_tool_defs 回全量 → plan_hint(无编排信号或闲聊)→ 扁平全量。
        // 校验 fallback 链完整:不会因 plan_hint 误丢工具。
        let all = vec![tool_def("read_file"), tool_def("write_file"), tool_def("http_request")];
        let out = filter_tool_defs_planned(&all, &Intent::Unknown, "unknown", "你好谢谢");
        assert_eq!(out.len(), all.len(), "Unknown + 闲聊应回全量(双 fallback)");
    }

    #[test]
    fn planned_plain_chat_yields_filter_result_unordered() {
        // Chat subset 空 → filter_tool_defs 回全量;context 无编排信号 → plan_hint 空 → 退扁平。
        // 关键:即使 PLANNING_ENABLED(true 时)走 planned 路径,无编排信号时输出与
        // filter_tool_defs 一致(顺序 + 内容),零行为变更。
        let all = vec![
            tool_def("read_file"),
            tool_def("write_file"),
            tool_def("http_request"),
        ];
        let planned = filter_tool_defs_planned(&all, &Intent::Chat, "chat", "你好谢谢");
        let flat = filter_tool_defs(&all, &Intent::Chat);
        let planned_names: Vec<&str> = planned.iter().map(|d| d.function.name.as_str()).collect();
        let flat_names: Vec<&str> = flat.iter().map(|d| d.function.name.as_str()).collect();
        assert_eq!(planned_names, flat_names, "无编排信号 planned 应与 filter 扁平一致");
    }

    #[test]
    fn planned_read_then_modify_orders_read_first() {
        // 读后写编排信号 → plan_hint 产 read_file→write_file 依赖边 → planned 把 read_file 置前。
        // File intent subset 含 read_file/write_file/patch_file 等全 File domain。
        let all = vec![
            // 故意把 write_file 放前,验证 planned 把 read_file 重排到 write_file 之前
            tool_def("write_file"),
            tool_def("patch_file"),
            tool_def("read_file"),
            tool_def("list_directory"),
            tool_def("search_files"),
            tool_def("file_info"),
            tool_def("append_file"),
            tool_def("delete_file"),
            tool_def("rename_file"),
            tool_def("http_request"), // 不在 File domain,应被滤掉
        ];
        let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 这个文件再修改它");
        let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
        // http_request 应被滤掉(intent 收敛)
        assert!(!names.contains(&"http_request"), "File 意图应滤掉 http_request");
        // read_file 应出现在 write_file 之前(plan_hint 编排:读组在前)
        let read_idx = names.iter().position(|n| *n == "read_file").expect("应含 read_file");
        let write_idx = names.iter().position(|n| *n == "write_file").expect("应含 write_file");
        assert!(read_idx < write_idx, "plan_hint 应把 read_file 排到 write_file 前, 实际顺序: {:?}", names);
        // 不丢工具:http_request 滤掉后剩 9 个(全 File domain 9 工具)
        assert_eq!(out.len(), 9, "planned 不应丢工具(除被 intent 滤掉), 实际: {:?}", names);
    }

    #[test]
    fn planned_preserves_unordered_tail_for_hint_missing_tools() {
        // plan_hint 标了 read_file/write_file,但 filtered 里还有 hint 没标的工具(list_directory 等)
        // → 未被 hint 命中的按 filtered 原序追加尾部兜底(防丢工具)。
        let all = vec![
            tool_def("list_directory"),
            tool_def("search_files"),
            tool_def("write_file"),
            tool_def("read_file"),
            tool_def("patch_file"),
            tool_def("file_info"),
            tool_def("append_file"),
            tool_def("delete_file"),
            tool_def("rename_file"),
        ];
        let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 文件再修改 write_file");
        let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
        // 全部 9 个工具都应保留(hint 未标的不丢)
        assert_eq!(out.len(), 9, "未命中 hint 的工具应追加尾部兜底, 不丢, 实际: {:?}", names);
        // read_file 仍置前(命中 hint 编排)
        let read_idx = names.iter().position(|n| *n == "read_file").unwrap();
        let write_idx = names.iter().position(|n| *n == "write_file").unwrap();
        assert!(read_idx < write_idx);
    }

    #[test]
    fn planned_registry_drift_skips_missing_names() {
        // registry 漂移:hint 标 read_file/write_file 但 all_defs 没这俩(改了名)。
        // → ordered 命中 0 个,全部走「未被 ordered 命中」追加 = filtered 原序(退 filter_tool_defs 行为)。
        let all = vec![
            tool_def("list_directory"),
            tool_def("search_files"),
            tool_def("file_info"),
            tool_def("append_file"),
            tool_def("delete_file"),
            tool_def("rename_file"),
        ];
        // 这条消息会产 read→write hint,但 all_defs 里没 read_file/write_file
        let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 再修改 write_file");
        let flat = filter_tool_defs(&all, &Intent::File);
        // 漂移:hint 工具名都不在 → 退扁平(filtered 原序),不 panic,不丢不增。
        assert_eq!(out.len(), flat.len(), "漂移应退扁平, 实际 planned={}, flat={}", out.len(), flat.len());
        let out_names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
        let flat_names: Vec<&str> = flat.iter().map(|d| d.function.name.as_str()).collect();
        assert_eq!(out_names, flat_names, "漂移时 planned 应与 filter 扁平一致");
    }

    #[test]
    fn planned_multi_read_single_parallel_group_preserves_all() {
        // 规则 3:读多文件(无写)→ plan_hint 产单并行组 [read_file, list_directory]。
        // 全部命中 filtered → 两个工具置前(组内序),其余 filtered 工具追加尾部。
        let all = vec![
            tool_def("patch_file"),
            tool_def("read_file"),
            tool_def("write_file"),
            tool_def("list_directory"),
            tool_def("search_files"),
            tool_def("file_info"),
            tool_def("append_file"),
            tool_def("delete_file"),
            tool_def("rename_file"),
        ];
        let out = filter_tool_defs_planned(&all, &Intent::File, "file", "read 文件再看 directory 目录");
        let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
        assert_eq!(out.len(), 9, "planned 不应丢工具");
        // read_file 和 list_directory 都在 hint 单组,应置前
        let read_idx = names.iter().position(|n| *n == "read_file").unwrap();
        let dir_idx = names.iter().position(|n| *n == "list_directory").unwrap();
        assert!(read_idx < 4 && dir_idx < 4, "组内工具应置前, 实际 read_idx={} dir_idx={}", read_idx, dir_idx);
    }
}
