//! 高危工具去重缓存。
//!
//! 从 audit/mod.rs 搬迁:`find_cached_high_risk_result` + `canonical_args_key`
//! + `sort_object_keys` + `PENDING_APPROVAL_PLACEHOLDER`(去重与 process_tool_calls
//! 共用占位常量)。三函数内部闭环(find_cached 调 canonical→sort),无外部 helper 依赖。

use std::sync::Arc;

use df_ai::provider::ChatMessage;
use df_storage::crud::AiToolExecutionRepo;
use tokio::sync::Mutex;

use super::super::AiSession;

/// Med/High 工具挂起审批时回填的占位 tool_result 内容(基础文本,不含 tc_id 标记)。
///
/// 单一常量避免「写入处」(process_tool_calls) 与「去重排查处」
/// (find_cached_high_risk_result) 各持一份字面量致耦合——若两者漂移,
/// 去重会把 pending 占位误判为已落定结果命中缓存,污染 LLM 上下文。
///
/// **占位配对完整性**:实际写入 messages 时用 [`pending_placeholder_for`] 生成带
/// `__PENDING__:tc_id` 标记的完整占位文本,供 sanitize 识别"占位不可裁 + 出口断言自愈补头"。
/// 本常量保留为基础文本(向前兼容 + 去重匹配基准)。
pub(crate) const PENDING_APPROVAL_PLACEHOLDER: &str = "需要用户审批，等待确认";

/// 构造带唯一标记(`__PENDING__:tc_id`)的审批挂起占位 tool_result 内容。
///
/// 解 400 orphan:占位 tool_result 内嵌 tc_id 标记,sanitize step3.5(反向 orphan 检测)
/// 据此把占位豁免保留(不丢),出口断言 `assert_placeholder_pairing` 据此补 TOOL_MISSING_PREFIX
/// 占位头自愈,使占位 result 与(可能被裁掉的)tool_call 头闭合配对,防 provider 400 orphan。
///
/// 标记格式:`{PENDING_APPROVAL_PLACEHOLDER}{PENDING_MARKER_PREFIX}{tc_id}`,
/// 如 "需要用户审批，等待确认__PENDING__:call_abc123"。tc_id 为空时退回基础文本(老格式,向前兼容)。
pub(crate) fn pending_placeholder_for(tc_id: &str) -> String {
    if tc_id.is_empty() {
        return PENDING_APPROVAL_PLACEHOLDER.to_string();
    }
    format!("{}{}{}", PENDING_APPROVAL_PLACEHOLDER, df_ai::context_helpers::PENDING_MARKER_PREFIX, tc_id)
}

/// 判定 tool_result content 是否为审批挂起占位(含基础文本或带 __PENDING__ 标记)。
///
/// 替代原裸 `msg.content == PENDING_APPROVAL_PLACEHOLDER` 精确匹配(占位带 tc_id 标记后
/// 不再精确等于基础文本,需用子串/标记匹配)。兼容老占位(纯文本)与新占位(带标记)。
pub(crate) fn is_pending_placeholder(content: &str) -> bool {
    df_ai::context_helpers::is_pending_placeholder(content)
}

/// 缓存命中时前置的「重复调用拦截」警告头。
///
/// 治弱模型(尤其小参数模型)死循环重调同参工具:LLM 只能看到 tool_result,
/// prompt 层说教无效,必须在结果里机制化显式警告。仅真命中缓存才加(首次真执行不加),
/// 命中次数 hit_count 累计「第 N 次」,数字递增对弱模型更有威慑,促其换参数/换工具。
/// 返回的字符串以换行结尾,调用方拼接缓存结果原样附后。
///
/// AC-2 ②:list_directory 目录级去重(cache key 只取 path,同目录换参数也命中)后,
/// 通用文案「换参数或换工具」对同目录无效(换 max_depth 也仍命中),故目录类工具
/// 用专用文案:明确告知「目录已列过 + 内容未变」,引导换 grep/search_files 定向搜索。
pub(crate) fn cache_hit_warning(tool_name: &str, hit_count: u32) -> String {
    if matches!(tool_name, "list_directory" | "list_dir") {
        format!(
            "[重复调用拦截] 目录 {} 此前已列过(第 {} 次命中缓存),目录内容未变,以下为缓存结果。不要重复列同一目录,如需查找文件请改用 grep/search_files 定向搜索,或换其他目录。\n\n",
            tool_name, hit_count
        )
    } else {
        format!(
            "[重复调用拦截] 工具 {} 相同参数此前已成功执行过(第 {} 次命中缓存),以下为缓存结果。不要重复调用相同参数的工具,如需新信息请换参数或换工具。\n\n",
            tool_name, hit_count
        )
    }
}

/// namespace 引用展开为真实内容(缓存回填前置)。
///
/// 大工具结果(read_file/list_directory/grep 等)存 namespace 引用(ns://tool/key)于消息,
/// 发 LLM 前由 agentic 侧按「内容以 namespace:// 开头」展开。缓存命中回填要前置警告头,
/// 若仍原样带引用拼在警告后,前缀判定失效致 LLM 只看到无意义 URI。故此处先展开:
/// 引用 → 真实内容;引用被 LRU 淘汰 → EVICTED_PLACEHOLDER 提示;非引用 → 原样。
fn resolve_namespace_content(session: &AiSession, content: &str) -> String {
    if df_ai::namespace_store::is_namespace_ref(content) {
        session.namespace_store.read_only(content)
            .map(str::to_owned)
            .unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string())
    } else {
        content.to_string()
    }
}

/// 高危工具去重（根治 run_command 超时→重试→重新审批循环）。
///
/// ⚠ 性能注记:本函数在 session lock 持有期间对每个 high risk 工具
/// 串行查 AiToolExecutionRepo::find_by_tool_call_id,工具数多时锁持有线性增长。
/// 批量预取(改签名传 tool_call_ids 批量查)属架构改暂未做,后续 High risk 工具增多时优先处理。
///
/// **根因链**（F-04 batch42 已做超时标注）：LLM 调 run_command 超时 → F-04 把超时标注成
/// tool_result 回传 LLM → LLM（不可靠）仍重试同命令 → 新 tool_call_id（provider 每轮新 id）
/// → `process_tool_calls` 重新 insert pending(audit.rs:418) → 用户被迫重新审批，循环。
///
/// **治本（F-05）**：即使 LLM 仍重试，同 (tool_name, args) 不重复审批。本函数扫描会话历史，
/// 若发现**已落定**（executed/failed/rejected，非 pending）的同类同参高危调用，返回其
/// tool_result 内容，调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM，跳过审批。
///
/// **安全边界**：
/// - 仅 High risk（循环源头）；Low/Med 不去重（Low 直行无审批，Med 重试场景少且去重易误伤）。
/// - 仅匹配**已落定**结果（pending 的不命中——pending 已有独立流程，不会触发循环；
///   且避免两个 pending 互相吞掉审批）。LLM 只有在收到 tool_result 后才会重试，
///   故循环必然是「上一条已落定 → 重试」形态，pending 不命中不影响治本。
/// - args 走 JSON 规范化比较（键序无关），避免 LLM 两次生成键序不同误判为不同命令。
/// - 返回 None 表示无缓存命中（走原审批流程）。
///
/// `session` 只读扫描 messages（不写），调用方据返回值决定是否跳过 insert pending。
///
/// 经`conv_id` 索引 per_conv.messages(顶层 messages 在拆分存储后是死字段)。
///   conv_id 来源:process_tool_calls 入参 → 由 agentic/mod.rs run_agentic_loop 入参透传。
pub(crate) async fn find_cached_high_risk_result(
    session_arc: &Arc<Mutex<AiSession>>,
    conv_id: &str,
    audit_repo: &AiToolExecutionRepo,
    tool_name: &str,
    args: &serde_json::Value,
) -> Option<(String, String, u32)> {
    use df_ai::provider::MessageRole;

    // 规范化新调用的 args 为可比字符串（排序键，键序无关）
    let new_args_key = canonical_args_key(args);

    // 短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
    // drop 锁后再锁外 await DB 查 status(原代码持锁 await audit_repo,违反持锁 await 慢操作禁令)。
    // 第一步:锁内(async block 包裹,出 block 自动 drop guard)反向扫描,定位旧 tool_call_id 与对应 tool_result content
    let cached: Option<(String, String, u32)> = (async {
        let session = session_arc.lock().await;
        // 读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
        // 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
        let Some(conv) = session.conv_read(conv_id) else { return None };
        // ContextManager::iter 返回 impl Iterator（非 DoubleEnded），collect 成 Vec 再反向遍历。
        // 单对话消息量小（百级），collect 开销可忽略。
        let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();

        // 1) 反向扫描 assistant tool_calls，找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id，
        //    同时累计同名同参调用总数(hit_count 供「第 N 次命中缓存」威慑)。
        //    反向：循环是「最近一次超时→重试」，命中通常是末尾附近，反向先停省全扫。
        let mut prev_tool_call_id: Option<String> = None;
        let mut hit_count: u32 = 0;
        for msg in msgs.iter().rev() {
            if !matches!(msg.role, MessageRole::Assistant) {
                continue;
            }
            let Some(tcs) = msg.tool_calls.as_ref() else { continue };
            for tc in tcs {
                if tc.function.name != tool_name {
                    continue;
                }
                // 旧调用的 args 是流式拼接的 JSON 字符串，解析失败跳过（不误判为命中）
                let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
                    continue;
                };
                if canonical_args_key(&old_args) == new_args_key {
                    if prev_tool_call_id.is_none() {
                        prev_tool_call_id = Some(tc.id.clone());
                    }
                    hit_count += 1;
                }
            }
        }

        // 2) 用旧 tool_call_id 找对应 tool_result。注意：审批拒绝/超时失败也属「已落定」，
        //    其 tool_result 内容同样回传（LLM 看到原反馈自行决定，不再逼用户二次审批）。
        //    pending 占位（「需要用户审批，等待确认」）不命中——仍在审批中，走原流程。
        let Some(old_id) = prev_tool_call_id else { return None };
        for msg in msgs.iter().rev() {
            if !matches!(msg.role, MessageRole::Tool) {
                continue;
            }
            if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
                continue;
            }
            // 命中旧 tool_result：排除 pending 占位（基础文本或带 __PENDING__:tc_id 标记）
            // 占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
            // 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定);
            // 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容
            // 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。
            if is_pending_placeholder(&msg.content) {
                return None;
            }
            return Some((old_id, resolve_namespace_content(&session, &msg.content), hit_count));
        }
        None
    }).await;
    // 锁外:查审计表拿缓存来源真实 status(completed/rejected/failed),透传给
    // audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果
    // 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。
    let (old_id, content, hit_count) = cached?;
    let status = audit_repo
        .find_by_tool_call_id(old_id.as_str())
        .await
        .ok()
        .and_then(|opt| opt.map(|rec| rec.status))
        .unwrap_or_else(|| "completed".to_string());
    Some((content, status, hit_count))
}

/// 把 JSON args 规范化为可比字符串：对象键按字典序排序后序列化，
/// 键序不同的等价参数生成同一 key（防 LLM 两次生成键序不同误判为不同命令）。
fn canonical_args_key(args: &serde_json::Value) -> String {
    let mut v = args.clone();
    sort_object_keys(&mut v);
    // 紧凑序列化（无空白），保证稳定可比
    serde_json::to_string(&v).unwrap_or_default()
}

/// AC-2 ②:只读缓存参数归一(目录级去重)。
///
/// list_directory 同目录换参数(recursive/max_depth/skip_noise_dirs 不同)也判重复——
/// 目录内容没变,换深度/递归参数重列是重复探索(实证 09e7abfa:同一目录列 3 次)。
/// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。
/// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。
fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String {
    if matches!(tool_name, "list_directory" | "list_dir") {
        args.get("path")
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_string()
    } else {
        canonical_args_key(args)
    }
}

/// 只读幂等工具结果缓存(治 LLM 死循环重调)。
///
/// **根因链(实测 9357c27c)**:LLM 无"已调用过"记忆,对同参只读工具反复触发:
/// - `read_file(application.xml)` 读了 17 次(两次返回完全相同的 8457c 全文)
/// - `search_files(pattern='.java')` 连调 13 次(每次返回 items=0/has_more=True 几乎一致)
/// 每次都真执行 + 全文回灌 tool_result,是 prompt 64 万的直接元凶。
///
/// **治本**:对幂等只读工具,扫描会话历史,若发现**已成功完成**的同类同参调用,
/// 返回其 tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,
/// 跳过真实执行(也跳过 emit Started/Completed——直接走缓存回填路径)。
///
/// **安全边界**(对照 find_cached_high_risk_result 严格收紧):
/// - 仅匹配白名单只读幂等工具(READONLY_CACHE_TOOLS):read_file/list_directory/
///   search_files/grep/read_symbol。写工具(write_file/patch_file/run_command 等)
///   永不缓存,避免误伤。
/// - 仅匹配 **status=completed** 的成功结果(失败/拒绝不缓存,可能重试成功)。
/// - 仅匹配 **非占位** 结果(排除 pending_placeholder 占位)。
/// - args 走 JSON 规范化比较(键序无关),与 find_cached_high_risk_result 一致。
/// - 返回 None 表示无缓存命中(走原执行流程)。
///
/// 与 find_cached_high_risk_result 区别:后者治"超时→重试→重新审批"循环( High risk),
/// 本函数治"LLM 失忆→同参重调"死循环( Low risk 只读),问题形态相似但触发原因不同。
pub(crate) async fn find_cached_readonly_result(
    session_arc: &Arc<Mutex<AiSession>>,
    conv_id: &str,
    audit_repo: &AiToolExecutionRepo,
    tool_name: &str,
    args: &serde_json::Value,
) -> Option<(String, u32)> {
    use df_ai::provider::MessageRole;

    // 白名单:只读幂等工具才缓存。写工具/有副作用工具永不缓存。
    if !READONLY_CACHE_TOOLS.contains(&tool_name) {
        return None;
    }

    // AC-2 ②:目录级去重——list_directory 的缓存 key 只取 path(忽略 recursive/max_depth),
    // 同目录换参数也判重复(目录内容没变,实证 09e7abfa 同一目录列 3 次)。
    // 其他只读工具仍全参归一(键序无关)。readonly_cache_args_key 内部区分。
    let new_args_key = readonly_cache_args_key(tool_name, args);

    // 短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
    let cached: Option<(String, String, u32)> = (async {
        let session = session_arc.lock().await;
        let Some(conv) = session.conv_read(conv_id) else { return None };
        let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();

        // 1) 反向扫描 assistant tool_calls,找最近一条同名同参调用 → 拿到旧 tool_call_id,
        //    同时累计同名同参调用总数(hit_count 供「第 N 次命中缓存」威慑)。
        let mut prev_tool_call_id: Option<String> = None;
        let mut hit_count: u32 = 0;
        for msg in msgs.iter().rev() {
            if !matches!(msg.role, MessageRole::Assistant) {
                continue;
            }
            let Some(tcs) = msg.tool_calls.as_ref() else { continue };
            for tc in tcs {
                if tc.function.name != tool_name {
                    continue;
                }
                let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
                    continue;
                };
                if readonly_cache_args_key(tool_name, &old_args) == new_args_key {
                    if prev_tool_call_id.is_none() {
                        prev_tool_call_id = Some(tc.id.clone());
                    }
                    hit_count += 1;
                }
            }
        }

        // 2) 用旧 tool_call_id 找对应 tool_result
        let Some(old_id) = prev_tool_call_id else { return None };
        for msg in msgs.iter().rev() {
            if !matches!(msg.role, MessageRole::Tool) {
                continue;
            }
            if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
                continue;
            }
            // 排除 pending 占位(仍在审批中,未真执行)
            if is_pending_placeholder(&msg.content) {
                return None;
            }
            return Some((old_id, resolve_namespace_content(&session, &msg.content), hit_count));
        }
        None
    }).await;
    // 锁外:查审计表确认是 completed 成功结果(失败/拒绝不缓存,给 LLM 重试机会)
    let (old_id, content, hit_count) = cached?;
    let status = audit_repo
        .find_by_tool_call_id(old_id.as_str())
        .await
        .ok()
        .and_then(|opt| opt.map(|rec| rec.status))
        .unwrap_or_else(|| "completed".to_string());
    if status != "completed" {
        return None;
    }
    Some((content, hit_count))
}

/// 可缓存的只读幂等工具白名单。
/// 仅含纯读取/查询类工具:同参多次调用结果一致,缓存命中安全。
/// 写工具/有副作用工具(write_file/patch_file/run_command/create_*/update_*/delete_* 等)
/// 永不进此白名单——它们每次执行都可能改变状态,缓存会掩盖变化。
const READONLY_CACHE_TOOLS: &[&str] = &[
    "read_file",       // 读文件内容(同 path+offset+limit+search 参数 → 同结果)
    "read_symbol",     // AST 符号读取(同 path+symbol+full → 同结果)
    "list_directory",  // 列目录(AC-2 ②:缓存 key 只取 path,同目录换 recursive/max_depth 也判重复)
    "search_files",    // 搜文件名(同 path+pattern+offset+limit → 同结果)
    "grep",            // 搜文件内容(同 path+pattern+mode → 同结果)
];

/// 递归对 JSON 对象的键做字典序排序（就地），数组成员也递归排序。
fn sort_object_keys(v: &mut serde_json::Value) {
    match v {
        serde_json::Value::Object(map) => {
            // BTreeMap 按键排序，重建 Object
            let mut entries: Vec<(String, serde_json::Value)> = map
                .iter()
                .map(|(k, val)| (k.clone(), val.clone()))
                .collect();
            entries.sort_by(|a, b| a.0.cmp(&b.0));
            map.clear();
            for (k, mut val) in entries {
                sort_object_keys(&mut val);
                map.insert(k, val);
            }
        }
        serde_json::Value::Array(arr) => {
            for item in arr {
                sort_object_keys(item);
            }
        }
        _ => {}
    }
}

// ============================================================
// AC-2 ③:run_command 目录列举绕行检测(2026-08-08 最保守版,只警告不硬拒)
//
// 实证 09e7abfa:LLM list_directory 列某目录后,又用 run_command `Get-ChildItem`/`ls`
// 再探同一目录,绕过 ② 的目录级去重。需解析 run_command 命令内容判定,**易误判**
// (ls/Get-ChildItem 也有正常用途),故本批只做最保守版:
//   - 仅当命令含列举类命令(Get-ChildItem/gci/ls) **且** 命令/working_dir 引用了会话中
//     已用 list_directory 列过的目录路径(精确子串) 才判绕行;
//   - 只 insert 一条 system 警告(非静默),不硬拒——命令照常执行/审批,
//     误报最多多一条提示,不阻断工作流;
//   - 未命中 → None,零行为变更。
// 已列目录路径来自消息历史扫描(listed_directory_paths),与 find_cached_readonly_result
// 同源(读 assistant tool_calls 的 list_directory args.path)。
// ============================================================

/// 词边界子串匹配(纯函数):needle 在 haystack 中出现且前后均非字母数字。
///
/// 用字节扫描避免引入 regex 依赖;注意 "Get-ChildItem" 含连字符,不能简单按非字母数字
/// 分词(会把 get-childitem 拆成 get/childitem),故按整词含连字符匹配。
fn contains_word(haystack: &str, needle: &str) -> bool {
    let bytes = haystack.as_bytes();
    let n = needle.as_bytes();
    if n.is_empty() || n.len() > bytes.len() {
        return false;
    }
    let max_start = bytes.len() - n.len();
    let mut i = 0;
    while i <= max_start {
        if &bytes[i..i + n.len()] == n {
            let prev_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
            let next_idx = i + n.len();
            let next_ok = next_idx >= bytes.len() || !bytes[next_idx].is_ascii_alphanumeric();
            if prev_ok && next_ok {
                return true;
            }
        }
        i += 1;
    }
    false
}

/// 列举类命令检测(纯函数,词边界匹配防误判)。
///
/// 覆盖 PowerShell `Get-ChildItem`/`gci` 与类 Unix `ls`。`dir` 别名歧义大
/// (PowerShell 里既列举又是路径常见词)排除,靠「命令引用已列目录」精化过滤。
fn contains_listing_command(command: &str) -> bool {
    let lower = command.to_ascii_lowercase();
    contains_word(&lower, "get-childitem")
        || contains_word(&lower, "gci")
        || contains_word(&lower, "ls")
}

/// 扫描会话历史,取所有 list_directory 已列过的目录路径(去重,保留原始大小写)。
///
/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 list_directory
/// 的 args.path,忽略递归/深度参数(② 同语义:同目录即算已列过)。
async fn listed_directory_paths(
    session_arc: &Arc<Mutex<AiSession>>,
    conv_id: &str,
) -> Vec<String> {
    use df_ai::provider::MessageRole;
    let session = session_arc.lock().await;
    let Some(conv) = session.conv_read(conv_id) else { return Vec::new() };
    let mut dirs: Vec<String> = Vec::new();
    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
    for m in conv.messages.iter() {
        if !matches!(m.role, MessageRole::Assistant) {
            continue;
        }
        let Some(tcs) = m.tool_calls.as_ref() else { continue };
        for tc in tcs {
            if tc.function.name != "list_directory" {
                continue;
            }
            let Ok(args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
                continue;
            };
            let Some(p) = args.get("path").and_then(|v| v.as_str()) else { continue };
            if seen.insert(p.to_string()) {
                dirs.push(p.to_string());
            }
        }
    }
    dirs
}

/// AC-2 ③:检测 run_command 是否用目录列举命令绕行 list_directory 去重。
///
/// 返回 Some(已列过的目录路径) 当命令含列举类命令且引用了该目录;None = 无绕行。
/// 只检测**先前轮次**已列的目录(当前轮同一批 list_directory 尚未写入消息,不覆盖
/// 同轮先列后 ls 的极端形态——属边缘场景,主要绕行形态跨轮已覆盖)。
pub(crate) async fn detect_listing_bypass(
    session_arc: &Arc<Mutex<AiSession>>,
    conv_id: &str,
    args: &serde_json::Value,
) -> Option<String> {
    // 1) 取命令文本 + working_dir(绕行路径通常出现在二者之一)。
    let command = args.get("command").and_then(|v| v.as_str())?.trim();
    if command.is_empty() {
        return None;
    }
    // 2) 命令须含列举类命令(Get-ChildItem/gci/ls)。
    if !contains_listing_command(command) {
        return None;
    }
    let working_dir = args.get("working_dir").and_then(|v| v.as_str()).unwrap_or("");
    // 3) 扫描会话已列过的目录路径。
    let listed_dirs = listed_directory_paths(session_arc, conv_id).await;
    if listed_dirs.is_empty() {
        return None;
    }
    // 4) 命令或 working_dir 引用已列过的目录 → 绕行(精确子串,不模糊匹配)。
    for dir in &listed_dirs {
        if dir.is_empty() {
            continue;
        }
        if command.contains(dir.as_str())
            || (!working_dir.is_empty() && working_dir.contains(dir.as_str()))
        {
            return Some(dir.clone());
        }
    }
    None
}

/// AC-2 ③:目录列举绕行警告注入(system 消息首位,对齐 G2/AC-2① 软提示模式)。
///
/// 只警告不硬拒:命令照常执行/审批,误报最多多一条提示,不阻断工作流。
pub(crate) async fn insert_listing_bypass_warning(
    session_arc: &Arc<Mutex<AiSession>>,
    conv_id: &str,
    dir: &str,
) {
    let warn_text = format!(
        "⚠ 检测到用 run_command 列目录({}) 绕行 list_directory 去重。列目录请用 list_directory 工具,如需查找文件请用 grep/search_files 定向搜索。",
        dir
    );
    let mut session = session_arc.lock().await;
    if session.per_conv.contains_key(conv_id) {
        let conv = session.conv(conv_id);
        conv.messages.insert_at(0, ChatMessage::system(&warn_text));
        tracing::info!(
            conv_id = %conv_id,
            dir = %dir,
            "[ai] AC-2 目录列举绕行:注入警告(只警告不硬拒)"
        );
    }
}

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

    // ── AC-2 ② 目录级去重 key 测试 ──

    #[test]
    fn readonly_key_list_directory_ignores_depth_and_recursive() {
        let a = readonly_cache_args_key(
            "list_directory",
            &serde_json::json!({"path": "/tmp", "max_depth": 1, "recursive": false, "skip_noise_dirs": true}),
        );
        let b = readonly_cache_args_key(
            "list_directory",
            &serde_json::json!({"path": "/tmp", "max_depth": 3, "recursive": true}),
        );
        assert_eq!(a, b, "同目录换 max_depth/recursive 应判同 key(目录级去重)");
        let c = readonly_cache_args_key("list_directory", &serde_json::json!({"path": "/other"}));
        assert_ne!(a, c, "不同目录应不同 key");
    }

    #[test]
    fn readonly_key_other_tools_use_full_args() {
        let a = readonly_cache_args_key("read_file", &serde_json::json!({"path": "x", "offset": 0}));
        let b = readonly_cache_args_key("read_file", &serde_json::json!({"path": "x", "offset": 50}));
        assert_ne!(a, b, "read_file 不同 offset 应不同 key(分段读正常)");
    }

    // ── AC-2 ③ 列举命令检测测试 ──

    #[test]
    fn listing_command_detects_powershell_and_unix() {
        assert!(contains_listing_command("Get-ChildItem -Recurse"));
        assert!(contains_listing_command("gci -Force"));
        assert!(contains_listing_command("ls -la /tmp"));
    }

    #[test]
    fn listing_command_avoids_false_positives() {
        assert!(!contains_listing_command("git log"));
        assert!(!contains_listing_command("else"));
        assert!(!contains_listing_command("glass"));
        assert!(!contains_listing_command("cd /tmp"));
        assert!(!contains_listing_command("Get-ChildItem2"));
    }

    #[test]
    fn contains_word_boundary_behavior() {
        // "ls" 不应命中 "else"/"glass"(词边界);"Get-ChildItem" 含连字符仍应整词命中
        assert!(contains_word("get-childitem -recurse", "get-childitem"));
        assert!(!contains_word("else", "ls"));
        assert!(!contains_word("glass", "ls"));
    }

    // ── cache_hit_warning 目录专用提示 ──

    #[test]
    fn cache_hit_warning_directory_specific() {
        let w = cache_hit_warning("list_directory", 2);
        assert!(w.contains("已列过"), "目录缓存命中应有'已列过'提示: {}", w);
        let w2 = cache_hit_warning("read_file", 2);
        assert!(!w2.contains("已列过"));
        assert!(w2.contains("相同参数"));
    }
}
