//! `ContextManager` 方法级单测 — 从 `mod.rs` 抽出以控制 `mod.rs` 行数。
//!
//! 这些测试覆盖 `ContextManager` 各公开/私有方法（push / build_for_request / compress /
//! topic marker / 溯源 id 等），需访问私有字段与方法，故仍置于 `crate::context` 模块树内
//! （`mod manager_tests;` 由 `mod.rs` 通过 `#[cfg(test)] mod manager_tests;` 引入）。

#![cfg(test)]

use super::*;
use crate::context_helpers::{ContextConfig, TokenEstimator, PROTECT_COUNT};
use crate::provider::ToolCall;

fn cfg(max_tokens: u32) -> ContextConfig {
    ContextConfig {
        max_tokens,
        output_reserve: 0,
        safety_ratio: 1.0,
    }
}

#[test]
fn estimate_message_counts_parts_tokens() {
    // 多模态回归：含图消息的大段 base64 必须计入 token 预算，
    // 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
    let est = TokenEstimator::default();

    // 纯文本基线
    let text_msg = ChatMessage::user("短文本");
    let text_tokens = est.estimate_message(&text_msg);

    // 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本
    let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符
    let multimodal = ChatMessage::user_parts(
        "短文本",
        vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())],
    );
    let mm_tokens = est.estimate_message(&multimodal);

    assert!(
        mm_tokens > text_tokens,
        "含图消息 token({}) 应高于纯文本({})",
        mm_tokens,
        text_tokens
    );
    // base64 字符按 0.35 粗估，约 1.7k * 0.35 ≈ 595 tokens 量级
    assert!(
        mm_tokens > 500,
        "大 base64 应贡献可观 token，实际 {}",
        mm_tokens
    );

    // url 模式（无字节）也按 URL 长度估算，不爆
    let url_msg = ChatMessage::user_parts(
        "t",
        vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
    );
    let url_tokens = est.estimate_message(&url_msg);
    assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献");
}

#[test]
fn short_history_no_trim() {
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("你好"));
    mgr.push(ChatMessage::assistant("你好啊"));
    let (msgs, trimmed) = mgr.build_for_request(10);
    assert!(!trimmed);
    assert_eq!(msgs.len(), 2);
}

#[test]
fn over_budget_trims_old() {
    // 小预算强制裁剪：20 条超预算，触发裁剪且保留保护区
    let mut mgr = ContextManager::new(cfg(200));
    // user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
    for i in 0..20 {
        if i % 2 == 0 {
            mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
        } else {
            mgr.push(ChatMessage::assistant(&format!("第 {} 条较长的回复用于撑爆预算", i)));
        }
    }
    let (msgs, trimmed) = mgr.build_for_request(0);
    assert!(trimmed, "超预算应触发裁剪");
    assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());

    // 保护区:最新一条必保留(末条 i=19 是 assistant)
    assert_eq!(
        msgs.last().unwrap().content,
        "第 19 条较长的回复用于撑爆预算",
        "保护区最新消息被误裁"
    );

    // 裁剪是视图：内存全量不变
    assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
}

#[test]
fn tool_triplet_kept_atomic() {
    // 三元组不可分离：Head 与 Tail 同进同出，永不从中间切断
    // 布局：6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条
    let mut mgr = ContextManager::new(cfg(95));
    for i in 0..6 {
        mgr.push(ChatMessage::user(&format!("旧消息 {}", i)));
    }
    mgr.push(ChatMessage::assistant_with_tools(
        "调工具",
        vec![ToolCall::new("tc1", "read_file", "{}")],
    ));
    mgr.push(ChatMessage::tool_result("tc1", "文件内容"));
    mgr.push(ChatMessage::assistant("完成"));
    for i in 0..6 {
        mgr.push(ChatMessage::user(&format!("新消息 {}", i)));
    }

    // 分支一：预算宽松，三元组整体保留 → Head 在则 Tail 在
    let (msgs_keep, trimmed1) = mgr.build_for_request(0);
    assert!(trimmed1, "分支一应触发裁剪");
    assert_eq!(
        has_head(&msgs_keep),
        has_tail(&msgs_keep),
        "分支一三元组被切断: head={} tail={}",
        has_head(&msgs_keep),
        has_tail(&msgs_keep)
    );

    // 分支二：预算紧张，三元组整体丢弃 → Head 不在则 Tail 也不在
    let (msgs_drop, trimmed2) = mgr.build_for_request(40);
    assert!(trimmed2, "分支二应触发裁剪");
    assert_eq!(
        has_head(&msgs_drop),
        has_tail(&msgs_drop),
        "分支二三元组被切断: head={} tail={}",
        has_head(&msgs_drop),
        has_tail(&msgs_drop)
    );

    // 裁剪是视图：两次 build 都不应改变内存全量
    assert_eq!(
        mgr.all_messages_clone().len(),
        15,
        "裁剪污染了内存全量"
    );
}

fn has_head(msgs: &[ChatMessage]) -> bool {
    msgs.iter()
        .any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
}

fn has_tail(msgs: &[ChatMessage]) -> bool {
    msgs.iter().any(|m| matches!(m.role, MessageRole::Tool))
}

#[test]
fn replace_tool_result_updates_tokens() {
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::tool_result("tc1", "短"));
    let before = mgr.history_tokens();
    assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估"));
    let after = mgr.history_tokens();
    assert!(after > before);
}

// ============================================================
// 三元组完整性 P1(根治):裁剪/压缩必须保证三元组原子性,
// 不出现 tool_result 残留但 tool_call 头被裁的 orphan(根因:AI Coding 静默停)。
// ============================================================

#[test]
fn trim_never_produces_orphan_tool_result_without_head() {
    // 三元组完整性 P1 回归:超预算裁剪后,发送视图中**任何 tool_result 的 tool_call_id 都必须
    // 有对应 assistant tool_call 头**(不残留 orphan result)。裁剪按 build_eviction_units 的
    // 三元组原子单元(Head+Tail+Standalone Assistant 同进同出),trim_end 始终落在单元边界,
    // 永不切断三元组。
    //
    // 布局:5 旧 user/assistant 文本(淘汰区) + 完整三元组(tc_mid) + 6 新(保护区) = 14 条
    // 强制小预算使裁剪切到三元组边界(整体保留或整体丢弃,不在中间切)。
    let mut mgr = ContextManager::new(cfg(120));
    for i in 0..5 {
        mgr.push(ChatMessage::user(&format!("旧消息 {} 用于撑爆预算的较长文本", i)));
    }
    // 完整三元组(裁剪边界附近)
    mgr.push(ChatMessage::assistant_with_tools(
        "调中间工具",
        vec![ToolCall::new("tc_mid", "read_file", "{}")],
    ));
    mgr.push(ChatMessage::tool_result("tc_mid", "中间工具结果"));
    mgr.push(ChatMessage::assistant("中间完成"));
    // 保护区(最近 PROTECT_COUNT=6 条)
    for i in 0..6 {
        mgr.push(ChatMessage::user(&format!("新消息 {} 保护区", i)));
    }

    let (msgs, trimmed) = mgr.build_for_request(0);
    assert!(trimmed, "应触发裁剪");

    // 收集发送视图中所有 assistant 头的 tool_call.id
    use std::collections::HashSet;
    let head_ids: HashSet<String> = msgs
        .iter()
        .filter(|m| matches!(m.role, MessageRole::Assistant))
        .filter_map(|m| m.tool_calls.as_ref())
        .flatten()
        .map(|c| c.id.clone())
        .collect();

    // 每个 tool_result 的 tool_call_id 都必须在 head_ids 内(不残留 orphan result)
    let orphans: Vec<&str> = msgs
        .iter()
        .filter(|m| matches!(m.role, MessageRole::Tool))
        .filter_map(|m| m.tool_call_id.as_deref())
        .filter(|id| !head_ids.contains(*id))
        .collect();
    assert!(
        orphans.is_empty(),
        "裁剪后不应有 orphan tool_result(无对应头), 实际 orphans={:?}, heads={:?}",
        orphans, head_ids
    );
}

#[test]
fn restore_rebuilds_token_cache() {
    let mut mgr = ContextManager::new(cfg(100_000));
    let src = vec![
        ChatMessage::user("测试消息一"),
        ChatMessage::assistant("回复一"),
        ChatMessage::user("测试消息二"),
    ];
    mgr.restore_from_messages(src);
    assert!(mgr.history_tokens() > 0);
    assert_eq!(mgr.len(), 3);
}

#[test]
fn push_token_only_active() {
    // F-15 §3.3：!active 消息（truncated / archived_segment / compressed）仍 push
    // 到 self.messages（全量保留，持久化/前端视图自管），但不计入 history_tokens，
    // 避免 build_for_request 误判超预算触发不必要裁剪。

    // 1) 直接 push 路径
    let mut mgr = ContextManager::new(cfg(100_000));
    let active_msg = ChatMessage::user("这条是 active 的");
    let active_tokens = TokenEstimator::default().estimate_message(&active_msg);
    let mut inactive_msg = ChatMessage::assistant("这条被截断了不该计 token");
    inactive_msg.status = Some(MessageStatus::Truncated);
    let inactive_tokens = TokenEstimator::default().estimate_message(&inactive_msg);

    mgr.push(active_msg);
    mgr.push(inactive_msg);

    // 全量保留（两条都在内存）
    assert_eq!(mgr.len(), 2, "active + !active 都应 push 到 self.messages");
    assert_eq!(mgr.all_messages_clone().len(), 2, "持久化全量不受 push 修正影响");
    // token 预算只含 active
    assert_eq!(
        mgr.history_tokens(),
        active_tokens,
        "history_tokens 应只含 active，多算了 {}（inactive 应被忽略）",
        mgr.history_tokens().saturating_sub(active_tokens)
    );
    assert!(
        inactive_tokens > 0,
        "前提：inactive 消息本身确有 token，否则无法证明它被排除"
    );

    // 2) restore_from_messages 路径（调 push，token 同步仅 active）
    let mut mgr2 = ContextManager::new(cfg(100_000));
    let mut a = ChatMessage::user("active 一");
    a.status = Some(MessageStatus::Active);
    let mut b = ChatMessage::user("archived 一");
    b.status = Some(MessageStatus::ArchivedSegment);
    let mut c = ChatMessage::user("compressed 一");
    c.status = Some(MessageStatus::Compressed);
    mgr2.restore_from_messages(vec![a, b, c]);

    assert_eq!(mgr2.len(), 3, "restore 后全量保留三条");
    // 只 active 一条计 token（b/c 是白名单外状态，is_active 返回 false）
    let only_active_tokens = TokenEstimator::default()
        .estimate_message(&ChatMessage::user("active 一"));
    assert_eq!(
        mgr2.history_tokens(),
        only_active_tokens,
        "restore 后 history_tokens 应只含 active 一条，archived/compressed 不计"
    );
}

#[test]
fn empty_history_returns_empty() {
    let mgr = ContextManager::new(cfg(100_000));
    let (msgs, trimmed) = mgr.build_for_request(10);
    assert!(!trimmed);
    assert!(msgs.is_empty(), "空历史应返回空列表");
}

#[test]
fn protect_zone_returns_full_when_untrimmable() {
    // 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量
    let mut mgr = ContextManager::new(cfg(10));
    mgr.push(ChatMessage::user("撑爆小预算的长消息内容"));
    mgr.push(ChatMessage::assistant("第二条撑爆预算的长消息"));
    let (msgs, trimmed) = mgr.build_for_request(0);
    assert!(!trimmed, "无可淘汰单元应返回 false(兜底)");
    assert_eq!(msgs.len(), 2, "兜底 sanitize 后返回全部保护区消息(user/assistant 交替不合并)");
}

#[test]
fn system_over_budget_trims_to_protect_zone() {
    // system prompt 吃光预算 → history 仍尝试裁剪到保护区,不 panic
    let mut mgr = ContextManager::new(cfg(200));
    for i in 0..10 {
        mgr.push(ChatMessage::user(&format!("消息 {} 撑量", i)));
    }
    let (msgs, _trimmed) = mgr.build_for_request(195);
    assert!(
        msgs.len() <= PROTECT_COUNT,
        "system 超预算时裁剪后至多保留保护区 {} 条,实际 {}",
        PROTECT_COUNT,
        msgs.len()
    );
}

// ── 辅助方法单测 ──

#[test]
fn compress_old_messages_marks_compressed_and_returns_refs() {
    // F-15 §4.2/§4.3：compress_old_messages 把 [0, end) 内 active 消息标 compressed,
    // 同步扣 history_tokens,返回它们的克隆供 LLM 摘要。持久化全量保留。
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("旧消息1"));
    mgr.push(ChatMessage::assistant("旧回复1"));
    mgr.push(ChatMessage::user("新消息2"));
    let tokens_before = mgr.history_tokens();
    assert!(tokens_before > 0);

    let compressed = mgr.compress_old_messages(2);
    assert_eq!(compressed.len(), 2, "应压缩前 2 条 active");
    assert_eq!(compressed[0].content, "旧消息1");
    assert_eq!(compressed[1].content, "旧回复1");

    // status 已改 compressed
    assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
    assert_eq!(mgr.messages_mut()[1].message.status.as_ref(), Some(&MessageStatus::Compressed));
    // 保护区外(本例 index 2)仍 active
    assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动");

    // 持久化全量不变
    assert_eq!(mgr.all_messages_clone().len(), 3, "compress 不应删消息(单向,全量保留)");

    // token 已扣(剩第 3 条的)
    let only_third_tokens = TokenEstimator::default().estimate_message(&ChatMessage::user("新消息2"));
    assert_eq!(mgr.history_tokens(), only_third_tokens, "history_tokens 应扣除前两条");
}

#[test]
fn compress_old_messages_is_idempotent() {
    // 幂等:已 compressed 不二次压缩,二次调用返回空 Vec 且 history_tokens 不再变。
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("a"));
    mgr.push(ChatMessage::user("b"));

    let first = mgr.compress_old_messages(2);
    assert_eq!(first.len(), 2);
    let tokens_after_first = mgr.history_tokens();

    let second = mgr.compress_old_messages(2);
    assert!(second.is_empty(), "二次压缩应返回空(已 compressed 不重压)");
    assert_eq!(
        mgr.history_tokens(),
        tokens_after_first,
        "二次压缩 history_tokens 不应再变(幂等)"
    );
}

#[test]
fn compress_old_messages_clamps_oversized_end() {
    // compress_end 越界自动 clamp 到 len,不 panic。
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("唯一"));
    let compressed = mgr.compress_old_messages(999);
    assert_eq!(compressed.len(), 1, "越界 end 应 clamp 到 len(1)");
    assert_eq!(mgr.history_tokens(), 0, "全量压缩后 history_tokens 归零");
}

#[test]
fn compress_old_messages_skips_already_inactive() {
    // 范围内含 truncated(已 !active)的消息:跳过,不返,不重复扣 token。
    let mut mgr = ContextManager::new(cfg(100_000));
    let mut truncated = ChatMessage::user("被截断");
    truncated.status = Some(MessageStatus::Truncated);
    mgr.push(truncated);
    mgr.push(ChatMessage::user("active 一条"));
    let tokens_before = mgr.history_tokens();
    // truncated 已不计 token(见 push_token_only_active),所以 tokens_before 只含 active 一条

    let compressed = mgr.compress_old_messages(2);
    assert_eq!(compressed.len(), 1, "只压缩 active 那条,truncated 跳过");
    assert_eq!(mgr.history_tokens(), 0);
    assert_eq!(
        mgr.history_tokens(),
        tokens_before.saturating_sub(tokens_before),
        "幂等扣除:truncated 本就没计 token,active 扣光"
    );
    // truncated 状态不被改成 compressed(保留原 truncated,语义不混淆)
    assert_eq!(
        mgr.messages_mut()[0].message.status.as_ref(),
        Some(&MessageStatus::Truncated),
        "已 truncated 不应被改写为 compressed"
    );
}

#[test]
fn has_compressible_messages_respects_protect_zone() {
    let mut mgr = ContextManager::new(cfg(100_000));
    for i in 0..8 {
        mgr.push(ChatMessage::user(&format!("消息 {}", i)));
    }
    // protect_start=6 → [0,6) 内有 active → true
    assert!(mgr.has_compressible_messages(6));
    // protect_start=0 → 空范围 → false
    assert!(!mgr.has_compressible_messages(0));
    // 全部压缩后 → false
    mgr.compress_old_messages(6);
    assert!(!mgr.has_compressible_messages(6), "全 compressed 后不应有可压缩消息");
}

#[test]
fn is_compressing_flag_round_trip() {
    // 标志位读写 round-trip;clear() 复位。
    let mut mgr = ContextManager::new(cfg(100_000));
    assert!(!mgr.is_compressing(), "默认 false");
    mgr.set_compressing(true);
    assert!(mgr.is_compressing(), "set true 后应读到 true");
    mgr.set_compressing(false);
    assert!(!mgr.is_compressing(), "set false 后复位");
    // clear 复位
    mgr.set_compressing(true);
    mgr.clear();
    assert!(!mgr.is_compressing(), "clear() 应复位 is_compressing");
}

#[test]
fn insert_at_adds_to_budget_when_active() {
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("a"));
    let tokens_before = mgr.history_tokens();

    // 插入 active system 消息 → 计入 token
    mgr.insert_at(0, ChatMessage::system("## 摘要"));
    assert!(mgr.history_tokens() > tokens_before, "active 消息应计入 token");
    assert_eq!(mgr.len(), 2);
    assert_eq!(mgr.messages_mut()[0].message.content, "## 摘要");

    // 插入 !active 消息 → 不计入 token
    let tokens_before_inactive = mgr.history_tokens();
    let mut inactive = ChatMessage::user("x");
    inactive.status = Some(MessageStatus::Truncated);
    mgr.insert_at(0, inactive);
    assert_eq!(
        mgr.history_tokens(),
        tokens_before_inactive,
        "!active 消息插入不应计 token"
    );
}

#[test]
fn build_eviction_units_keeps_triplet_atomic_public() {
    // 公开的 build_eviction_units:三元组(Head + Tail + Standalone Assistant)应落同一单元。
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("前置"));
    mgr.push(ChatMessage::assistant_with_tools(
        "调",
        vec![ToolCall::new("c1", "fn", "{}")],
    ));
    mgr.push(ChatMessage::tool_result("c1", "结果"));
    mgr.push(ChatMessage::assistant("完成"));
    mgr.push(ChatMessage::user("后置"));
    // protect_start=5(全部纳入)
    let units = mgr.build_eviction_units(5);
    // 第一个单元是前置 Standalone(end=1);第二个单元应包含三元组三件套 + 后置应分开
    // 确认三元组的 Head+Tail+Assistant 在同一单元(end 跳过 3)
    let unit2 = units.iter().find(|u| u.end >= 4).expect("应有跨三元组的单元");
    assert!(
        unit2.end >= 4,
        "三元组三件套应在同一淘汰单元, end={}",
        unit2.end
    );
}

// ── 消息级溯源:last_assistant/last_user message_id ──

#[test]
fn last_assistant_message_id_returns_latest() {
    // 多条 assistant,反向扫描取末条 id(本轮 AI 产出的载体)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("问1"));
    let first = push_and_get_id(&mut mgr, ChatMessage::assistant("答1"));
    mgr.push(ChatMessage::user("问2"));
    let last = push_and_get_id(&mut mgr, ChatMessage::assistant("答2"));
    // 末条 assistant id 应是 last(非 first)
    assert_eq!(
        mgr.last_assistant_message_id().as_deref(),
        Some(last.as_str()),
        "应取末条 assistant id, 而非首条"
    );
    assert_ne!(
        mgr.last_assistant_message_id().as_deref(),
        Some(first.as_str())
    );
}

#[test]
fn last_user_message_id_returns_latest() {
    // 多条 user,反向扫描取末条 id(触发本轮检索的 user)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("问1"));
    mgr.push(ChatMessage::assistant("答1"));
    let last_user = push_and_get_id(&mut mgr, ChatMessage::user("问2"));
    assert_eq!(
        mgr.last_user_message_id().as_deref(),
        Some(last_user.as_str())
    );
}

#[test]
fn last_message_id_none_when_no_such_role() {
    // 无 assistant → None;无 user → None
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("只有 user"));
    assert!(
        mgr.last_assistant_message_id().is_none(),
        "无 assistant 消息应返 None"
    );

    let mut mgr2 = ContextManager::new(cfg(100_000));
    mgr2.push(ChatMessage::assistant("只有 assistant"));
    assert!(
        mgr2.last_user_message_id().is_none(),
        "无 user 消息应返 None"
    );
}

#[test]
fn last_message_id_none_for_legacy_no_id() {
    // 老数据反序列化消息 id=None → 返 None(向前兼容,溯源降级 conv: 旧格式)
    let mut mgr = ContextManager::new(cfg(100_000));
    let mut legacy = ChatMessage::assistant("老消息无 id");
    legacy.id = None;
    mgr.push(legacy);
    assert!(
        mgr.last_assistant_message_id().is_none(),
        "老消息无 id 应返 None(向前兼容)"
    );
}

/// helper:push 一条消息并返回其 id(测试用,确认取到的是该消息自身 id)
fn push_and_get_id(mgr: &mut ContextManager, message: ChatMessage) -> String {
    let id = message.id.clone();
    mgr.push(message);
    id.expect("新构造消息必有 id")
}

// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──

#[test]
fn topic_marker_triggers_on_two_different_high_conf_topics() {
    // 双高置信:两条 user 消息各自 intent 置信 >= 0.7 且不同 topic → 置位 marker。
    // "帮我重构这段代码"(Code, conf=1.0) + "创建项目并绑定目录"(Project, conf=1.0)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("帮我重构这段代码"));
    // 此时只有一条 user,无 marker
    assert!(mgr.take_topic_marker().is_none(), "首条 user 不触发 marker");
    mgr.push(ChatMessage::user("创建项目并绑定目录"));
    let marker = mgr.take_topic_marker()
        .expect("双不同 topic 应置位 marker");
    assert!(marker.contains("code"), "old topic 应为 code, 实际: {}", marker);
    assert!(marker.contains("project"), "new topic 应为 project, 实际: {}", marker);
    // take 后清空(幂等)
    assert!(mgr.take_topic_marker().is_none(), "take 后应清空");
}

#[test]
fn topic_marker_not_triggered_on_same_topic() {
    // 同 topic(code/code)→ 不置位(非切换)。
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("重构这段代码"));
    mgr.push(ChatMessage::user("再重构另一段代码"));
    assert!(mgr.take_topic_marker().is_none(), "同 topic 不应触发 marker");
}

#[test]
fn topic_marker_not_triggered_when_either_topic_none() {
    // 保守:任一 topic 为 None(低置信未标)不置位。
    // "今天的天气不错"(Unknown, conf=0.0 → topic=None) + "重构代码"(Code, conf 高)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("今天的天气不错啊"));
    mgr.push(ChatMessage::user("帮我重构这段代码"));
    assert!(mgr.take_topic_marker().is_none(), "前一条 topic None 不应触发 marker(保守)");

    // 反向:前一条高置信 + 后一条低置信
    let mut mgr2 = ContextManager::new(cfg(100_000));
    mgr2.push(ChatMessage::user("帮我重构这段代码"));
    mgr2.push(ChatMessage::user("嗯嗯好的"));
    assert!(mgr2.take_topic_marker().is_none(), "后一条 topic None 不应触发 marker(保守)");
}

#[test]
fn topic_marker_not_polluting_compression_or_trim() {
    // topic 字段不参与裁剪/压缩(只检测):超预算裁剪 + 压缩后消息仍正常,
    // topic 标记独立工作。push 足量消息撑爆小预算触发裁剪。
    let mut mgr = ContextManager::new(cfg(80));
    for i in 0..10 {
        mgr.push(ChatMessage::user(&format!("重构代码第 {} 条长消息撑爆预算", i)));
    }
    mgr.push(ChatMessage::user("创建项目并绑定目录")); // Project topic → marker
    let _ = mgr.take_topic_marker(); // 消费 marker(topic 不影响裁剪)
    // 裁剪仍正常工作(topic 字段不参与裁剪逻辑)
    let (msgs, trimmed) = mgr.build_for_request(0);
    assert!(trimmed);
    assert!(!msgs.is_empty());
}

#[test]
fn topic_field_default_none_in_constructors() {
    // TrackedMessage.topic 在 push/insert_at 构造时默认 None(push 会按 intent 推断覆盖,
    // insert_at 始终 None——insert_at 不做主题推断,保守)。
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.insert_at(0, ChatMessage::system("摘要"));
    // insert_at 的 system 消息 topic=None(role 非 User)
    assert_eq!(mgr.messages_mut()[0].topic, None);
}

// ===== 苁刻测:主题切换检测对抗/边界/链式 =====

#[test]
fn topic_marker_chained_three_switches_each_triggers() {
    // 对抗(链式):A→B→C 三次连续主题切换,每次 push 不同 topic 都应置位 marker。
    // 验证 last_user_topic 反向扫描取最近 user,链式切换逐次触发不漏。
    let mut mgr = ContextManager::new(cfg(100_000));

    // 1. Code
    mgr.push(ChatMessage::user("帮我重构这段代码")); // topic=code
    assert!(mgr.take_topic_marker().is_none(), "首条不触发");

    // 2. Code → Project(切换)
    mgr.push(ChatMessage::user("创建项目并绑定目录")); // topic=project
    let m1 = mgr.take_topic_marker().expect("code→project 应触发");
    assert!(m1.contains("code") && m1.contains("project"), "marker1: {}", m1);
    assert!(mgr.take_topic_marker().is_none(), "take 后清空");

    // 3. Project → Task(切换)
    mgr.push(ChatMessage::user("推进这个任务到下一状态")); // topic=task
    let m2 = mgr.take_topic_marker().expect("project→task 应触发");
    assert!(m2.contains("project") && m2.contains("task"), "marker2: {}", m2);

    // 4. Task → Http(切换,证链式不因中间穿插断)
    mgr.push(ChatMessage::user("调用接口请求这个 api")); // topic=http
    let m3 = mgr.take_topic_marker().expect("task→http 应触发");
    assert!(m3.contains("task") && m3.contains("http"), "marker3: {}", m3);
}

#[test]
fn topic_marker_low_confidence_chain_never_triggers() {
    // 对抗(低置信链):连续 push 低置信(Unknown)消息 → topic 全 None → 永不置位
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
    assert!(mgr.take_topic_marker().is_none());
    mgr.push(ChatMessage::user("啊这样啊")); // Unknown/0.0 → None
    assert!(mgr.take_topic_marker().is_none(), "双 None 链不触发");
    // 再接一条高置信,但前一条 None → 仍不触发(保守:任一 None 即不标)
    mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
    assert!(
        mgr.take_topic_marker().is_none(),
        "前一条 topic None,即使本条高置信也不触发(双高置信约束)"
    );
}

#[test]
fn topic_marker_high_then_low_confidence_never_triggers() {
    // 对抗(反向低置信链):高置信 → 低置信,后一条 None → 不触发
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
    assert!(mgr.take_topic_marker().is_none());
    mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
    assert!(
        mgr.take_topic_marker().is_none(),
        "后一条 topic None,即使前一条高置信也不触发"
    );
}

#[test]
fn topic_marker_switch_back_to_same_after_different_not_triggered() {
    // 边界:A→B→A:第二次 A 时,末两条 user 是 B(高)→ A(高),不同 → 应触发。
    // 验证 last_user_topic 只看最近一条 user,不缓存历史(不会因"曾标过 code"漏判)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("重构这段代码")); // code
    mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → 触发 code|project
    let _ = mgr.take_topic_marker();
    mgr.push(ChatMessage::user("再重构另一段代码")); // code(末两条:project→code 不同)
    let m = mgr.take_topic_marker().expect("project→code 切回应触发");
    assert!(m.contains("project") && m.contains("code"), "切回原 topic 也应触发: {}", m);
}

#[test]
fn topic_field_survives_compress_old_messages() {
    // 对抗(topic 跨 compress):compress_old_messages 只改 status,不动 topic 字段。
    // 压缩后 compressed 消息的 topic 标签保留(向前兼容,DB/内存一致性)。
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("重构这段代码")); // idx 0, topic=code
    mgr.push(ChatMessage::user("读取这个文件")); // idx 1, topic=file
    mgr.push(ChatMessage::assistant("ok")); // idx 2

    // 压缩 [0,2):idx 0/1 标 compressed
    let compressed = mgr.compress_old_messages(2);
    assert_eq!(compressed.len(), 2);
    // topic 字段保留(compress 不触碰)
    assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留");
    assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留");
    // status 改为 compressed
    assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
}

#[test]
fn topic_marker_system_message_in_send_view_under_adequate_budget() {
    // 边界(marker 端到端):取 marker → 调用方 insert_at 顶 system 标记 →
    // build_for_request 在充足预算下 system 正常进发送视图(不丢,不 panic)。
    // 注:build_for_request 裁剪策略裁前部(保护最近 PROTECT_COUNT 条),顶置 system
    // 在极小预算撑爆时会被裁——这是策略既定行为,非 bug。本测验证充足预算下 marker
    // 流程端到端正常:marker 取出 + insert system + 发送视图含该 system。
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("重构这段代码")); // code
    mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → marker
    let marker = mgr.take_topic_marker().expect("应触发 marker");
    assert!(marker.contains("code") && marker.contains("project"));

    // 调用方据 marker insert system 标记(agentic loop 实际行为)
    mgr.insert_at(0, ChatMessage::system("[主题切换标记]"));
    let (msgs, trimmed) = mgr.build_for_request(0);
    assert!(!trimmed, "充足预算不应裁剪");
    assert!(
        msgs.iter().any(|m| m.content.contains("[主题切换标记]")),
        "marker system 应进发送视图: {:?}",
        msgs.iter().map(|m| m.content.chars().take(15).collect::<String>()).collect::<Vec<_>>()
    );
}

#[test]
fn topic_marker_not_triggered_by_assistant_or_tool_messages() {
    // 边界:只有 user 消息参与主题推断;assistant/tool push 不触发 marker(topic=None)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("重构这段代码")); // code
    assert!(mgr.take_topic_marker().is_none());
    // assistant 消息(含 error 关键词但不影响 topic 推断,因 role 非 User)
    mgr.push(ChatMessage::assistant("编译 error 出现了"));
    assert!(
        mgr.take_topic_marker().is_none(),
        "assistant 消息不参与主题推断,不触发 marker"
    );
    // 再 push user(同 code topic)→ 末两条 user 都是 code,不切换
    mgr.push(ChatMessage::user("继续重构"));
    assert!(mgr.take_topic_marker().is_none(), "同 topic user 间不切换(assistant 不算)");
    // 此时末条 user 是 code,切到 project 应触发
    mgr.push(ChatMessage::user("创建项目并绑定目录"));
    assert!(mgr.take_topic_marker().is_some(), "code→project 切换应触发(assistant 不打断)");
}

#[test]
fn topic_marker_take_is_idempotent_and_clears() {
    // 边界:take_topic_marker 一次性消费,take 两次第二次必 None(防重复 insert)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("重构这段代码"));
    mgr.push(ChatMessage::user("创建项目并绑定目录"));
    let _ = mgr.take_topic_marker().expect("应有 marker");
    // 第二次 take 必 None
    assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
}

// ============================================================
// msg-split-phase1:增量写路径基线(persisted_msg_count)+ 全量重写标志(needs_full_rewrite)
// ============================================================
//
// 这些测试验证 save_conversation 增量优化的核心不变量:
// - 纯 push(append)不置 needs_full_rewrite,persisted_msg_count 由 save 推进(此处只测标志语义)
// - 任何修改既有消息 / 缩短 / 中段插入 / 清空的入口都置 needs_full_rewrite=true
//   (漏置位 = 静默 DB 不同步,是本优化最危险的回归点)
// - restore_from_messages(DB 加载)后基线对齐到 len + 清 needs_full_rewrite
//
// persisted_msg_count 的推进/读取由 save_conversation(src-tauri) 调 advance_persisted_count 完成,
// 此处仅测 ContextManager 侧的字段初值 + advance 语义(max 防回退 + 清标志)。

#[test]
fn phase1_new_manager_zero_baseline_no_rewrite_needed() {
    // 新建 ContextManager:基线 0,无需全量重写(append-only 起点)
    let mgr = ContextManager::new(cfg(100_000));
    assert_eq!(mgr.persisted_msg_count(), 0);
    assert!(!mgr.needs_full_rewrite(), "新 manager 不应需全量重写");
}

#[test]
fn phase1_push_only_keeps_clean_no_dirty_flag() {
    // 纯 append(push 多条)不置 needs_full_rewrite —— 这是常态(agentic loop 每轮追加),
    // 必须走增量路径只插新消息,不能误触发全量重写。
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("问题1"));
    assert!(!mgr.needs_full_rewrite(), "push 不应置 dirty");
    mgr.push(ChatMessage::assistant("回答1"));
    mgr.push(ChatMessage::user("问题2"));
    assert!(!mgr.needs_full_rewrite(), "连续 push 不应置 dirty");
    assert_eq!(mgr.len(), 3);
}

#[test]
fn phase1_advance_persisted_count_sets_baseline_and_clears_dirty() {
    // advance_persisted_count(new_count):推进基线 + 清 needs_full_rewrite(DB 已与内存一致)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("x"));
    mgr.push(ChatMessage::user("y"));
    // 模拟 save 成功后推进
    mgr.advance_persisted_count(2);
    assert_eq!(mgr.persisted_msg_count(), 2);
    assert!(!mgr.needs_full_rewrite());
}

#[test]
fn phase1_advance_persisted_count_max_no_regression() {
    // 防回退:advance 传更小值不回退基线(防误传 / 并发致基线丢失 → 下次增量漏写)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.advance_persisted_count(5);
    mgr.advance_persisted_count(3); // 误传更小
    assert_eq!(mgr.persisted_msg_count(), 5, "基线不可回退");
}

#[test]
fn phase1_compress_old_messages_sets_dirty() {
    // compress 改既有消息 status(active→compressed) → 必须全量重写收敛
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("旧1"));
    mgr.push(ChatMessage::user("旧2"));
    mgr.push(ChatMessage::user("保护区"));
    assert!(!mgr.needs_full_rewrite());
    let compressed = mgr.compress_old_messages(2);
    assert_eq!(compressed.len(), 2, "应压缩 2 条");
    assert!(mgr.needs_full_rewrite(), "compress 后必须全量重写");
}

#[test]
fn phase1_compress_no_op_does_not_set_dirty() {
    // 边界:compress_end 范围内无可压缩(全已 compressed)→ 状态未变,不应置 dirty
    // (避免无意义全量重写,compress 重试场景常见)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("a"));
    let _ = mgr.compress_old_messages(1);
    assert!(mgr.needs_full_rewrite(), "首次压缩应置 dirty");
    mgr.advance_persisted_count(1); // 模拟 save 后清 dirty
    // 二次压缩同范围:全已 compressed,无新标 → 不应再置 dirty
    let compressed = mgr.compress_old_messages(1);
    assert!(compressed.is_empty());
    // 注:此处 dirty 在二次 compress 时未变(未置位),但 advance 已清,故仍 false
    assert!(!mgr.needs_full_rewrite(), "无新压缩不应置 dirty");
}

#[test]
fn phase1_replace_tool_result_content_sets_dirty() {
    // 审批 replace_tool_result 改既有 tool 消息 content → 必须全量重写
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::assistant_with_tools(
        "调用工具",
        vec![ToolCall {
            id: "call_1".into(),
            call_type: "function".into(),
            function: crate::provider::ToolCallFunction {
                name: "read_file".into(),
                arguments: "{}".into(),
            },
        }],
    ));
    mgr.push(ChatMessage::tool_result("call_1", "占位结果"));
    assert!(!mgr.needs_full_rewrite());
    let replaced = mgr.replace_tool_result_content("call_1", "真实结果");
    assert!(replaced, "应找到并替换");
    assert!(mgr.needs_full_rewrite(), "replace content 后必须全量重写");
}

#[test]
fn phase1_pop_last_assistant_round_sets_dirty() {
    // 重新生成 pop 末尾 assistant 轮 → len 缩短 → 必须全量重写(删 DB 尾行)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("问"));
    mgr.push(ChatMessage::assistant("答"));
    assert!(!mgr.needs_full_rewrite());
    let popped = mgr.pop_last_assistant_round();
    assert!(popped);
    assert!(mgr.needs_full_rewrite(), "pop 后必须全量重写");
    assert_eq!(mgr.len(), 1, "应只剩 user");
}

#[test]
fn phase1_pop_no_op_does_not_set_dirty() {
    // 边界:无可弹 assistant(末尾是 user)→ 不变,不应置 dirty
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("只有 user"));
    let popped = mgr.pop_last_assistant_round();
    assert!(!popped);
    assert!(!mgr.needs_full_rewrite(), "pop no-op 不应置 dirty");
}

#[test]
fn phase1_truncate_after_user_message_sets_dirty() {
    // 编辑重生成 truncate 后续消息 status → 必须全量重写
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("原始问题"));
    mgr.push(ChatMessage::assistant("答1"));
    mgr.push(ChatMessage::assistant("答2"));
    assert!(!mgr.needs_full_rewrite());
    let count = mgr.truncate_after_user_message("原始问题").expect("应找到");
    assert_eq!(count, 2);
    assert!(mgr.needs_full_rewrite(), "truncate 后必须全量重写");
}

#[test]
fn phase1_replace_last_active_user_content_sets_dirty() {
    // 编辑 user 消息 content → 必须全量重写
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("旧问题"));
    assert!(!mgr.needs_full_rewrite());
    mgr.replace_last_active_user_content("新问题").expect("应替换");
    assert!(mgr.needs_full_rewrite(), "replace user content 后必须全量重写");
}

#[test]
fn phase1_insert_at_sets_dirty() {
    // 中段插入(压缩摘要/话题标记)改变 seq → 必须全量重写
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("a"));
    mgr.push(ChatMessage::user("b"));
    assert!(!mgr.needs_full_rewrite());
    mgr.insert_at(0, ChatMessage::system("[摘要]"));
    assert!(mgr.needs_full_rewrite(), "insert_at 后必须全量重写( seq 错位)");
    assert_eq!(mgr.len(), 3);
}

#[test]
fn phase1_clear_resets_baseline_and_sets_dirty() {
    // clear:基线重置 0 + 置 dirty(下次 save 全量重写空列表清 DB)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("a"));
    mgr.advance_persisted_count(1);
    assert_eq!(mgr.persisted_msg_count(), 1);
    mgr.clear();
    assert_eq!(mgr.persisted_msg_count(), 0, "clear 应重置基线到 0");
    assert!(mgr.needs_full_rewrite(), "clear 应置 dirty");
    assert_eq!(mgr.len(), 0);
}

#[test]
fn phase1_restore_from_messages_aligns_baseline_clears_dirty() {
    // DB 加载(restore_from_messages)后:基线对齐到 len + 清 dirty
    // (DB 已是该真相源,append 基线应对齐当前长度,不需全量重写)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("旧内存")); // 制造一个 dirty + 非零状态
    mgr.mark_needs_full_rewrite();
    // 模拟从 DB 加载 3 条消息
    let loaded = vec![
        ChatMessage::user("db1"),
        ChatMessage::assistant("db2"),
        ChatMessage::user("db3"),
    ];
    mgr.restore_from_messages(loaded);
    assert_eq!(mgr.persisted_msg_count(), 3, "基线应对齐到 DB 加载的消息数");
    assert!(!mgr.needs_full_rewrite(), "DB 加载后不应需全量重写");
    assert_eq!(mgr.len(), 3);
}

#[test]
fn phase1_mark_needs_full_rewrite_idempotent() {
    // 幂等:重复 mark 不报警不改变语义(供 messages_mut 调用方多次调用兜底)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.mark_needs_full_rewrite();
    mgr.mark_needs_full_rewrite();
    mgr.mark_needs_full_rewrite();
    assert!(mgr.needs_full_rewrite());
}

#[test]
fn phase1_append_after_dirty_stays_dirty_until_save() {
    // dirty 后再 push(append):dirty 保持(下次 save 仍走全量重写收敛,不会因 push 重置)
    // 验证 push 不清 dirty(否则 compress 后追加新消息会漏收敛旧行)
    let mut mgr = ContextManager::new(cfg(100_000));
    mgr.push(ChatMessage::user("a"));
    mgr.mark_needs_full_rewrite(); // 模拟 compress
    mgr.push(ChatMessage::user("b")); // 追加
    assert!(mgr.needs_full_rewrite(), "dirty 后 push 不应清 dirty");
    // 模拟 save(全量重写)成功后推进 + 清
    mgr.advance_persisted_count(2);
    assert!(!mgr.needs_full_rewrite());
}
