//! 单对话并行多轮 — Phase 0a · 0.8 plan_hint 纯函数
//!
//! 依据:`docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md` 第 0.8 节。
//! 上游设计:`单对话并行多轮-设计-2026-06-20.md`。
//!
//! ## 定位
//! Phase 0a 的**纯函数地基**:基于用户意图(`crate::intent::Intent`)/对话上下文关键词,
//! 产出一份「编排提示」(`PlanHint`)——告诉主 loop「这批工具调用可以并行」/「这些有
//! 顺序依赖」。它**扩 `intent::tool_subset_for` 语义**:后者只返扁平 tool 子集,本模块
//! 在其之上叠加「并行组 / 顺序依赖」的编排关系。
//!
//! ## 纯函数边界(零 IO 零状态)
//! - 无 `.await` / 无 db / 无 provider / 无全局状态。
//! - 输入:意图标签 + 上下文关键词(纯字符串切片)。
//! - 输出:`PlanHint`(数据结构 + 启发式算法产物)。
//! - **不调 LLM**:LLM 调度(plan_hint 落 prompt 让模型产 Plan)留 Phase 2;本模块是
//!   Phase 1 主 loop 接入前的「确定性降级路径」——主 loop 拿到 PlanHint 直接做编排,
//!   无需等 Phase 2 LLM 介入也能跑通串行/并行调度。
//!
//! ## 与 0.7 planner.rs 的关系
//! 本 agent 独立先做(避免等 agent A),用**自有轻量 `PlanHint` 结构**。
//! - `PlanHint` 是「提示」层(启发式,可错,主 loop 可忽略);
//! - `Plan`(`planner.rs`,agent A 出)是「计划」层(LLM/用户确认后的硬编排)。
//! Phase 1 接入主 loop 时,两者字段(group/dep)对齐,主 loop 优先消费 `Plan`,无 `Plan`
//! 时退 `PlanHint`。结构对齐工作放 Phase 1,本批互不阻塞。
//!
//! ## feature flag
//! `PLAN_HINT_ENABLED` 常量占位。关时单链 ReAct=旧行为(主 loop 不读 PlanHint)。
//! Phase 1 接入时主 loop 据 `PLAN_HINT_ENABLED` 决定是否走 plan_hint 分支。

// ---- feature flag 占位 ------------------------------------------------------

/// plan_hint 总开关。Phase 0a 先占位为 `true`(纯函数无副作用可单测)。
/// Phase 1 接入主 loop 时,关闭则主 loop 不读 PlanHint,退单链 ReAct(旧行为)。
/// 真正的运行时开关(配置驱动)留 Phase 1,本批用编译期常量便于单测固定行为。
pub const PLAN_HINT_ENABLED: bool = true;

// ---- 数据结构 --------------------------------------------------------------

/// 并行组:一组可同时调度的工具名(组内无顺序约束,可并行执行)。
///
/// 工具名对齐 `intent::tool_subset_for` 返回的工具名常量(read_file/write_file/...,
/// 见 `intent::ToolDomain::tools`)。空组无意义(构造时应保证非空,但 validate 兜底)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParallelGroup {
    /// 组内工具名列表(均为 `intent` crate 内声明的注册名)。
    pub tools: Vec<&'static str>,
}

impl ParallelGroup {
    /// 构造一个并行组。空 tools 允许构造(validate 阶段标记非法),调用方应保证非空。
    pub fn new(tools: Vec<&'static str>) -> Self {
        Self { tools }
    }

    /// 组内工具数。
    pub fn len(&self) -> usize {
        self.tools.len()
    }

    /// 是否为空组。
    pub fn is_empty(&self) -> bool {
        self.tools.is_empty()
    }
}

/// 顺序依赖边:`from` 必须先于 `to` 完成(A → B)。
///
/// 用于表达「读后写」「先查后改」等强顺序约束。`from`/`to` 是工具名
/// (或更细的工具调用标签,Phase 2 LLM 产 Plan 时可携带调用 id;本批仅工具名粒度)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SequentialDep {
    /// 前置工具(先执行)。
    pub from: &'static str,
    /// 后置工具(待 from 完成后执行)。
    pub to: &'static str,
}

impl SequentialDep {
    /// 构造一条顺序依赖边。
    pub fn new(from: &'static str, to: &'static str) -> Self {
        Self { from, to }
    }
}

/// 编排提示(轻量 PlanHint)。
///
/// - `groups`:可并行执行的分组(每组内工具无序,可同时调度)。
/// - `sequence`:跨组的顺序依赖边(DAG 边)。
///
/// **合法 PlanHint 约束**(与 0.7 Kahn 拓扑对齐):
/// 1. `groups` 内各组 `tools` 非空;
/// 2. `sequence` 不含自环(`from != to`);
/// 3. `sequence` 无重复边(去重);
/// 4. `sequence` 构成的有向图**无环**(DAG)——`validate` 用 Kahn 判定。
///
/// validate 失败时主 loop 应**丢弃** PlanHint 退单链(不能据此调度,否则死锁/无限循环)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PlanHint {
    /// 并行组列表。空 `Vec` 表示「无并行提示」(单链或全顺序)。
    pub groups: Vec<ParallelGroup>,
    /// 顺序依赖边列表。空 `Vec` 表示「无跨组顺序约束」(组间全并行)。
    pub sequence: Vec<SequentialDep>,
}

impl PlanHint {
    /// 空提示(无并行、无顺序)。主 loop 见到此值应退旧行为。
    pub fn empty() -> Self {
        Self::default()
    }

    /// 是否为空提示(无任何编排信息)。
    pub fn is_empty(&self) -> bool {
        self.groups.is_empty() && self.sequence.is_empty()
    }

    /// 工具总数(所有并行组工具去重后的并集大小)。
    pub fn tool_count(&self) -> usize {
        let mut seen = std::collections::HashSet::new();
        for g in &self.groups {
            for t in &g.tools {
                seen.insert(*t);
            }
        }
        seen.len()
    }
}

// ---- 校验(Kahn 拓扑判 DAG 无环,对齐 0.7) --------------------------------

/// PlanHint 合法性校验结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HintValidity {
    /// 合法,可交付主 loop 调度。
    Ok,
    /// 非法(附原因)。主 loop 应丢弃 PlanHint 退单链。
    Invalid(&'static str),
}

/// 校验 PlanHint 合法性(纯函数)。
///
/// 检查四项(见 `PlanHint` 文档约束):
/// 1. 组内非空;
/// 2. 无自环(`from != to`);
/// 3. 无重复边;
/// 4. **Kahn 拓扑判无环**(与 0.7 `Plan::validate` 同算法,这里在 PlanHint 层独立实现,
///    避免依赖 agent A 的 planner.rs;Phase 1 接入时若 planner 已稳定可抽公共函数)。
///
/// 返回 `Invalid(reason)` 时 reason 是静态原因标签(便于日志/断言)。
pub fn validate(hint: &PlanHint) -> HintValidity {
    // 1. 组内非空
    for g in &hint.groups {
        if g.tools.is_empty() {
            return HintValidity::Invalid("empty_parallel_group");
        }
    }

    // 2. 无自环
    for d in &hint.sequence {
        if d.from == d.to {
            return HintValidity::Invalid("self_loop_dep");
        }
    }

    // 3. 无重复边(同 from→to 不重复)
    let mut seen_edges = std::collections::HashSet::new();
    for d in &hint.sequence {
        if !seen_edges.insert((d.from, d.to)) {
            return HintValidity::Invalid("duplicate_dep_edge");
        }
    }

    // 4. Kahn 拓扑判无环
    if has_cycle(&hint.sequence) {
        return HintValidity::Invalid("cyclic_dep");
    }

    HintValidity::Ok
}

/// Kahn 算法判有向边集合是否含环(纯函数)。
///
/// 输入是 `SequentialDep` 边列表,节点是工具名(`&'static str`)。
/// 返回 `true` = 有环(非法),`false` = DAG(合法)。
///
/// 实现:统计入度 → 入度 0 的节点入队 → BFS 出队并削减邻居入度 →
/// 最终已出队节点数 < 总节点数 → 存在环。
fn has_cycle(edges: &[SequentialDep]) -> bool {
    if edges.is_empty() {
        return false;
    }

    // 收集所有节点(去重)。
    let mut nodes: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
    for d in edges {
        nodes.insert(d.from);
        nodes.insert(d.to);
    }
    let n = nodes.len();
    if n == 0 {
        return false;
    }

    // 节点 → 索引(便于用 Vec 计数)。
    let mut idx: std::collections::HashMap<&'static str, usize> =
        std::collections::HashMap::with_capacity(n);
    for (i, name) in nodes.iter().enumerate() {
        idx.insert(*name, i);
    }

    // 入度表 + 邻接表。
    let mut indeg = vec![0usize; n];
    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
    for d in edges {
        let u = idx[&d.from];
        let v = idx[&d.to];
        adj[u].push(v);
        indeg[v] += 1;
    }

    // BFS from 入度 0 节点。
    let mut queue: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
    for (i, &d) in indeg.iter().enumerate() {
        if d == 0 {
            queue.push_back(i);
        }
    }

    let mut visited = 0usize;
    while let Some(u) = queue.pop_front() {
        visited += 1;
        for &v in &adj[u] {
            indeg[v] -= 1;
            if indeg[v] == 0 {
                queue.push_back(v);
            }
        }
    }

    // 访问到的节点数 < 总节点数 → 存在环(有节点入度永远不为 0)。
    visited < n
}

// ---- 启发式产 PlanHint(纯函数,无 LLM) -----------------------------------

/// 输入:意图标签(来自 `intent::recognize`)+ 上下文关键词(原始用户消息)。
///
/// 关键词检测用 `str::contains`(中英文都支持,与 `intent::recognize` 一致风格)。
/// 输出基于**确定性启发式规则**(非 LLM),规则覆盖 devflow 常见编排场景:
///
/// | 场景关键词 | 产出 |
/// |---|---|
/// | 读多个 + 写后改 | [读组 并行] → write_file/patch_file |
/// | grep/find 后改 | search_files → write_file/patch_file |
/// | http + 写文件 | http_request → write_file(先取再存) |
/// | 仅读多文件 | 全并行单组 |
/// | 单工具 / 无编排信号 | 空 PlanHint(主 loop 退单链) |
///
/// **重要**:这是降级路径(Phase 2 前)。LLM 产 Plan 的质量一定高于本启发式,
/// 但本函数保证 Phase 1 主 loop 接入后**无 LLM 也能跑通基本编排**(读后写/grep 后改)。
///
/// `intent_label` 仅为日志/未来扩展预留(当前规则用关键词驱动,intent_label 不参与判定,
/// 保留参数对齐 Phase 1 主 loop 调用签名:`plan_hint(&intent, &message)`)。
pub fn plan_hint(intent_label: &str, context_keywords: &str) -> PlanHint {
    // 开关关闭:直接返空(主 loop 退单链)。Phase 1 接入时主 loop 据常量短路,这里也兜底。
    if !PLAN_HINT_ENABLED {
        return PlanHint::empty();
    }

    let _ = intent_label; // 预留:当前规则纯关键词驱动,不读 intent。

    let ctx = context_keywords;
    let lower = ctx.to_lowercase();

    // ---- 规则 1:grep/搜索 后改 → search_files → write_file/patch_file ----
    let is_search = lower.contains("grep")
        || lower.contains("搜索")
        || lower.contains("查找")
        || lower.contains("find ");
    let is_modify = lower.contains("修改")
        || lower.contains("改")
        || lower.contains("write")
        || lower.contains("patch")
        || lower.contains("写入")
        || lower.contains("重命名")
        || lower.contains("rename");
    if is_search && is_modify {
        return PlanHint {
            groups: vec![
                ParallelGroup::new(vec!["search_files"]),
                ParallelGroup::new(vec!["write_file", "patch_file", "rename_file"]),
            ],
            sequence: vec![SequentialDep::new("search_files", "write_file")],
        };
    }

    // ---- 规则 2:http 取数 + 写文件 → http_request → write_file ----
    let is_http = lower.contains("http")
        || lower.contains("api")
        || lower.contains("请求")
        || lower.contains("fetch");
    if is_http && (lower.contains("保存") || lower.contains("写入") || lower.contains("write")) {
        return PlanHint {
            groups: vec![
                ParallelGroup::new(vec!["http_request"]),
                ParallelGroup::new(vec!["write_file"]),
            ],
            sequence: vec![SequentialDep::new("http_request", "write_file")],
        };
    }

    // ---- 规则 3:读多文件(无写) → 全并行单组 ----
    let read_count = [
        ("read_file", lower.matches("read").count() + lower.matches("读取").count()),
        ("list_directory", lower.matches("目录").count() + lower.matches("directory").count()),
    ]
    .iter()
    .filter(|(_, c)| *c >= 1)
    .count();
    if read_count >= 2 && !is_modify {
        // 多种读工具信号 → 单并行组(可同时调度)
        let mut tools: Vec<&'static str> = Vec::new();
        if lower.contains("read") || lower.contains("读取") {
            tools.push("read_file");
        }
        if lower.contains("目录") || lower.contains("directory") {
            tools.push("list_directory");
        }
        if tools.len() >= 2 {
            return PlanHint {
                groups: vec![ParallelGroup::new(tools)],
                sequence: vec![],
            };
        }
    }

    // ---- 规则 4:读后写 → read_file → write_file/patch_file ----
    let is_read = lower.contains("read") || lower.contains("读取");
    if is_read && is_modify {
        return PlanHint {
            groups: vec![
                ParallelGroup::new(vec!["read_file"]),
                ParallelGroup::new(vec!["write_file", "patch_file"]),
            ],
            sequence: vec![SequentialDep::new("read_file", "write_file")],
        };
    }

    // ---- 兜底:无编排信号 → 空 PlanHint(主 loop 退单链) ----
    PlanHint::empty()
}

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

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

    // --- feature flag 占位 ---

    #[test]
    fn plan_hint_enabled_is_true_in_phase0a() {
        // Phase 0a 占位为 true,纯函数可单测。Phase 1 接入后改配置驱动。
        assert!(PLAN_HINT_ENABLED, "Phase 0a 占位应为 true");
    }

    // --- 数据结构基本 ---

    #[test]
    fn parallel_group_new_and_len() {
        let g = ParallelGroup::new(vec!["read_file", "write_file"]);
        assert_eq!(g.len(), 2);
        assert!(!g.is_empty());
    }

    #[test]
    fn parallel_group_empty_construct_allowed_but_invalid() {
        // 空 tools 允许构造(调用方失误),validate 阶段标记非法
        let g = ParallelGroup::new(vec![]);
        assert!(g.is_empty());
        assert_eq!(g.len(), 0);
    }

    #[test]
    fn sequential_dep_new() {
        let d = SequentialDep::new("read_file", "write_file");
        assert_eq!(d.from, "read_file");
        assert_eq!(d.to, "write_file");
    }

    #[test]
    fn plan_hint_empty_is_empty() {
        let h = PlanHint::empty();
        assert!(h.is_empty());
        assert_eq!(h.tool_count(), 0);
    }

    #[test]
    fn plan_hint_tool_count_dedup_across_groups() {
        // 两组含同名工具 → tool_count 去重
        let h = PlanHint {
            groups: vec![
                ParallelGroup::new(vec!["read_file", "write_file"]),
                ParallelGroup::new(vec!["read_file", "patch_file"]),
            ],
            sequence: vec![],
        };
        // read_file/write_file/patch_file = 3(去重)
        assert_eq!(h.tool_count(), 3);
    }

    // --- plan_hint 基本编排提示 ---

    #[test]
    fn hint_read_then_modify_yields_sequence() {
        // 规则 4:读后写 → read_file → write_file
        let h = plan_hint("code", "先 read 这个文件再修改它");
        assert!(!h.is_empty(), "读后写应产非空 hint");
        assert_eq!(h.groups.len(), 2, "应分两组(读组/改组)");
        assert!(h.sequence.iter().any(|d| d.from == "read_file" && d.to == "write_file"),
            "应含 read_file→write_file 依赖边");
    }

    #[test]
    fn hint_grep_then_modify_yields_search_first() {
        // 规则 1:grep 后改 → search_files → write_file
        let h = plan_hint("search", "grep 一下再修改 write_file");
        assert!(!h.is_empty());
        // 首组应是 search_files
        assert!(h.groups[0].tools.contains(&"search_files"));
        assert!(h.sequence.iter().any(|d| d.from == "search_files"),
            "search_files 应作为顺序依赖起点");
    }

    #[test]
    fn hint_http_then_save_yields_http_first() {
        // 规则 2:http + 保存 → http_request → write_file
        let h = plan_hint("http", "请求这个 api 并保存写入到文件");
        assert!(!h.is_empty());
        assert!(h.sequence.iter().any(|d| d.from == "http_request" && d.to == "write_file"));
    }

    #[test]
    fn hint_multi_read_only_yields_single_parallel_group() {
        // 规则 3:读多文件(无写)→ 单并行组
        let h = plan_hint("file", "read 文件,再看一下 directory 目录");
        assert!(!h.is_empty());
        assert_eq!(h.groups.len(), 1, "应单并行组");
        let tools = &h.groups[0].tools;
        assert!(tools.contains(&"read_file"), "组内应含 read_file");
        assert!(tools.contains(&"list_directory"), "组内应含 list_directory");
        assert!(h.sequence.is_empty(), "全并行无顺序依赖");
    }

    // --- 并行组识别 ---

    #[test]
    fn parallel_group_tools_are_registry_names() {
        // 对抗:产出的工具名必须在 intent ToolDomain 注册表内(防笔误)。
        // 注:intent::ToolDomain::tools() 是 private 方法(同模块 intent.rs 内单测可用,
        // 跨模块不可访问)。本测用从 intent.rs:246-284 抄录的 registry 名常量做校验,
        // 不依赖私有方法访问(保持 plan_hint.rs 独立可单测)。若 registry 名漂移,
        // intent.rs 的 tool_domain_names_match_registry 测会先抓到,本测同步对齐。
        let registry: std::collections::HashSet<&str> = [
            // Data domain
            "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",
            // File domain
            "read_file", "write_file", "patch_file", "list_directory", "search_files",
            "file_info", "append_file", "delete_file", "rename_file",
            // Exec domain
            "run_command",
            // Http domain
            "http_request",
        ]
        .iter()
        .copied()
        .collect();
        let h = plan_hint("file", "read 再读取目录 directory");
        if !h.is_empty() {
            for g in &h.groups {
                for t in &g.tools {
                    assert!(registry.contains(*t), "工具名 {} 不在 registry", t);
                }
            }
        }
    }

    #[test]
    fn hint_groups_tools_non_empty_when_non_empty_hint() {
        // 非空 hint 的每组都应非空(由 plan_hint 产出保证,validate 兜底)
        let h = plan_hint("code", "read 后 write_file 修改");
        if !h.is_empty() {
            for g in &h.groups {
                assert!(!g.tools.is_empty(), "产出组的 tools 不应空");
            }
        }
    }

    // --- 顺序依赖 ---

    #[test]
    fn hint_sequence_edges_from_less_than_to() {
        // 顺序依赖边 from/to 都是合法工具名(from 先于 to)
        let h = plan_hint("code", "read 后修改 write_file");
        for d in &h.sequence {
            assert_ne!(d.from, d.to, "依赖边不应自环");
            assert!(!d.from.is_empty());
            assert!(!d.to.is_empty());
        }
    }

    #[test]
    fn hint_no_duplicate_sequence_edge() {
        // 产出的依赖边不应重复
        let h = plan_hint("code", "read 后 write_file 修改");
        let mut seen = std::collections::HashSet::new();
        for d in &h.sequence {
            assert!(seen.insert((d.from, d.to)), "重复依赖边: {}→{}", d.from, d.to);
        }
    }

    // --- 空 hint ---

    #[test]
    fn hint_empty_for_plain_chat() {
        // 无编排信号 → 空 PlanHint
        let h = plan_hint("chat", "你好谢谢");
        assert!(h.is_empty(), "闲聊应产空 hint, 退单链");
    }

    #[test]
    fn hint_empty_for_unknown_intent_no_signal() {
        let h = plan_hint("unknown", "今天的天气不错");
        assert!(h.is_empty());
    }

    #[test]
    fn hint_empty_when_flag_off() {
        // 开关关闭 → 恒空(主 loop 退单链)。即使有强编排信号也忽略。
        // 注:PLAN_HINT_ENABLED 是编译期常量,本测在 true 时验证 plan_hint 内部
        // 短路逻辑(若常量改 false,此测会失败提醒对齐)。
        // 用单工具避免命中多工具规则。
        let h = plan_hint("file", "read");
        // 单 read(不命中规则 3 需 ≥2 读工具,不命中规则 4 需 modify)→ 空
        assert!(h.is_empty(), "单工具无编排信号应空 hint");
    }

    // --- validate 合法性 ---

    #[test]
    fn validate_empty_hint_is_ok() {
        let h = PlanHint::empty();
        assert_eq!(validate(&h), HintValidity::Ok);
    }

    #[test]
    fn validate_normal_dag_is_ok() {
        // read_file → write_file(单边 DAG)合法
        let h = PlanHint {
            groups: vec![
                ParallelGroup::new(vec!["read_file"]),
                ParallelGroup::new(vec!["write_file"]),
            ],
            sequence: vec![SequentialDep::new("read_file", "write_file")],
        };
        assert_eq!(validate(&h), HintValidity::Ok);
    }

    #[test]
    fn validate_empty_parallel_group_is_invalid() {
        let h = PlanHint {
            groups: vec![ParallelGroup::new(vec![])],
            sequence: vec![],
        };
        assert_eq!(validate(&h), HintValidity::Invalid("empty_parallel_group"));
    }

    #[test]
    fn validate_self_loop_dep_is_invalid() {
        let h = PlanHint {
            groups: vec![
                ParallelGroup::new(vec!["read_file"]),
                ParallelGroup::new(vec!["write_file"]),
            ],
            sequence: vec![SequentialDep::new("read_file", "read_file")],
        };
        assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
    }

    #[test]
    fn validate_duplicate_edge_is_invalid() {
        let h = PlanHint {
            groups: vec![
                ParallelGroup::new(vec!["read_file"]),
                ParallelGroup::new(vec!["write_file"]),
            ],
            sequence: vec![
                SequentialDep::new("read_file", "write_file"),
                SequentialDep::new("read_file", "write_file"),
            ],
        };
        assert_eq!(validate(&h), HintValidity::Invalid("duplicate_dep_edge"));
    }

    // --- Kahn 环检测 ---

    #[test]
    fn has_cycle_empty_edges_false() {
        assert!(!has_cycle(&[]));
    }

    #[test]
    fn has_cycle_single_edge_no_cycle() {
        let edges = vec![SequentialDep::new("a", "b")];
        assert!(!has_cycle(&edges));
    }

    #[test]
    fn has_cycle_two_node_cycle_detected() {
        // a→b→a 环
        let edges = vec![
            SequentialDep::new("a", "b"),
            SequentialDep::new("b", "a"),
        ];
        assert!(has_cycle(&edges), "a↔b 环应被检测");
    }

    #[test]
    fn has_cycle_three_node_cycle_detected() {
        // a→b→c→a 环
        let edges = vec![
            SequentialDep::new("a", "b"),
            SequentialDep::new("b", "c"),
            SequentialDep::new("c", "a"),
        ];
        assert!(has_cycle(&edges));
    }

    #[test]
    fn has_cycle_dag_with_branch_no_cycle() {
        // a→b, a→c, b→d, c→d(菱形 DAG,无环)
        let edges = vec![
            SequentialDep::new("a", "b"),
            SequentialDep::new("a", "c"),
            SequentialDep::new("b", "d"),
            SequentialDep::new("c", "d"),
        ];
        assert!(!has_cycle(&edges));
    }

    #[test]
    fn validate_cyclic_dep_is_invalid() {
        let h = PlanHint {
            groups: vec![
                ParallelGroup::new(vec!["read_file"]),
                ParallelGroup::new(vec!["write_file"]),
            ],
            sequence: vec![
                SequentialDep::new("read_file", "write_file"),
                SequentialDep::new("write_file", "read_file"),
            ],
        };
        assert_eq!(validate(&h), HintValidity::Invalid("cyclic_dep"));
    }

    // --- 对抗/边界 ---

    #[test]
    fn validate_check_order_self_loop_before_cycle() {
        // 自环也构成环,但 validate 应先报 self_loop_dep(更具体的错误)
        let h = PlanHint {
            groups: vec![ParallelGroup::new(vec!["a"])],
            sequence: vec![SequentialDep::new("a", "a")],
        };
        assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
    }

    #[test]
    fn hint_intent_label_ignored_in_phase0a() {
        // 当前规则纯关键词驱动,intent_label 不参与判定(预留 Phase 1)
        // 同关键词不同 intent_label → 同产出
        let h1 = plan_hint("code", "read 后修改 write_file");
        let h2 = plan_hint("debug", "read 后修改 write_file");
        assert_eq!(h1, h2, "Phase 0a 不读 intent_label, 产出应一致");
    }

    #[test]
    fn hint_single_tool_no_signal_is_empty() {
        // 单 read(无写无多读)→ 不命中任何规则 → 空
        let h = plan_hint("file", "读取文件");
        assert!(h.is_empty());
    }

    #[test]
    fn validate_ignores_nodes_not_in_groups() {
        // validate 不检查 sequence 边引用的工具是否在 groups 内(Phase 0a 宽松,
        // 主 loop 自行对齐;Phase 1 接入 planner.rs 后由 Plan::validate 收口)
        let h = PlanHint {
            groups: vec![ParallelGroup::new(vec!["read_file"])],
            sequence: vec![SequentialDep::new("read_file", "write_file")],
        };
        // write_file 不在任何 group,但边合法且 DAG,validate 仍 Ok(宽松)
        assert_eq!(validate(&h), HintValidity::Ok);
    }
}
