//! 上下文管理器 — 管理对话上下文和 token 预算
//!
//! 职责：
//! - 维护消息历史及其 token 计数缓存
//! - 提供预算感知的消息裁剪（保护工具调用三元组）
//! - 为 run_agentic_loop 提供受控的消息视图
//!
//! 裁剪策略与模型选择是正交维度：本模块只管「窗口多大、怎么裁」，
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
//!
//! 纯函数 / 数据类型 / 常量（TokenEstimator / ContextConfig / MessageGroup /
//! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT /
//! TOOL_MISSING_PREFIX）已抽至 [`crate::context_helpers`]，本模块 `use` 复用，
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见（零调用方变更）。
//!
//! # 子模块
//! - [`sanitize`]：畸形配对自愈（`sanitize_messages` / `drop_reverse_orphans` /
//!   `assert_placeholder_pairing` / `ensure_sequence_legal`）及其单测。
//!   实现为模块级 `pub fn`，本模块通过 `ContextManager` 上的关联函数薄包装转发，
//!   保持 `ContextManager::sanitize_messages(...)` 调用路径不变（零调用方变更）。

mod sanitize;

use crate::context_helpers::{
    classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
};
// 重导出：保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
// 历史路径对外可见（agentic.rs / commands/ai/mod.rs 等调用方零变更）。
// `pub use` 同时把类型带入本模块命名空间，供 ContextManager 结构体字段与 impl 直接引用。
pub use crate::context_helpers::{
    EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage,
};

use crate::provider::{ChatMessage, MessageRole, MessageStatus};

// ============================================================
// 上下文管理器
// ============================================================

/// 上下文管理器
///
/// 唯一的消息真相来源（替代原来的 `Vec<ChatMessage>`）。
/// 裁剪仅影响发送视图（`build_for_request`），不影响持久化（`all_messages_clone`）。
pub struct ContextManager {
    messages: Vec<TrackedMessage>,
    /// 当前历史总 token 数（不含 system prompt）
    history_tokens: u32,
    config: ContextConfig,
    estimator: TokenEstimator,
    /// 压缩重入标志（F-15 §4.3）：true 表示一次 LLM 压缩正在进行中。
    /// agentic loop 顶部检测，防同一轮内多次触发压缩互相覆盖。纯内存态，不落库。
    is_compressing: bool,
    /// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
    /// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
    /// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
    pending_topic_marker: Option<String>,
    /// msg-split-phase1:已落库(ai_messages 表)的消息条数。
    ///
    /// 增量写路径用:save_conversation 据此判定 `[persisted_msg_count..len)` 是"自上次 save
    /// 后新增的消息"(纯 append),走 `insert_batch`(INSERT OR IGNORE)只插新行,跳过昂贵的
    /// `replace_conversation`(DELETE 整对话 + 全量重插)。
    ///
    /// 仅 append-only 路径才有效——一旦 [`needs_full_rewrite`] 被置位(DB 与内存可能脱钩:
    /// compress 改 status / replace_tool_result_content 改 content / pop_last_assistant_round
    /// 缩短 / insert_at 中段插入改 seq / clear 清空),save 必须走全量重写收敛,本字段随之重置。
    ///
    /// restore_from_messages(DB 加载)后设为 len(DB 已是该真相源,append 基线对齐到当前长度)。
    /// 纯内存态,不落库(下次进程重启从 DB reload 时由 restore_from_messages 重置)。
    persisted_msg_count: usize,
    /// msg-split-phase1:全量重写需求标志。
    ///
    /// `true` = 自上次 save 后发生过修改既有消息 / 缩短 / 中段插入 / 清空,内存与 DB 可能脱钩,
    /// 下次 save 必须走 `replace_conversation` 全量重写收敛(DELETE + INSERT 全部行)。
    /// save 全量重写成功后清零;append-only save(insert_batch)成功后保持 false。
    ///
    /// 任何修改既有消息(非纯 push append)的入口置位:clear / pop_last_assistant_round /
    /// truncate_after_user_message / replace_tool_result_content / replace_last_active_user_content /
    /// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
    /// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
    needs_full_rewrite: bool,
}

impl ContextManager {
    pub fn new(config: ContextConfig) -> Self {
        Self {
            messages: Vec::new(),
            history_tokens: 0,
            config,
            estimator: TokenEstimator::default(),
            is_compressing: false,
            pending_topic_marker: None,
            persisted_msg_count: 0,
            needs_full_rewrite: false,
        }
    }

    /// 追加消息（自动计算 token 并更新缓存）
    ///
    /// 不在此处淘汰——push 可能发生在 agentic loop 中间（追加 tool_result），
    /// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
    pub fn push(&mut self, message: ChatMessage) {
        let tokens = self.estimator.estimate_message(&message);
        let group = classify_group(&message);
        // 仅 active 消息计入 token 预算（F-15 §3.3）：truncated / archived_segment /
        // compressed 不进 LLM 上下文，token 虚高会致 build_for_request 误判超预算
        // 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留（持久化不受影响），
        // sanitize_messages step0(is_active 过滤)在发送视图统一剔除。
        if message.is_active() {
            self.history_tokens += tokens;
        }
        // [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
        // 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
        // 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
        let topic: Option<String> = if matches!(message.role, MessageRole::User) {
            let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
            if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
                Some(intent.as_str().to_string())
            } else {
                None
            }
        } else {
            None
        };
        // [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
        // 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
        // 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
        if matches!(message.role, MessageRole::User) {
            if let Some(prev_topic) = self.last_user_topic() {
                if let Some(this_topic) = &topic {
                    if prev_topic != *this_topic {
                        self.pending_topic_marker =
                            Some(format!("{}|{}", prev_topic, this_topic));
                    }
                }
            }
        }
        self.messages.push(TrackedMessage {
            message,
            token_count: tokens,
            group,
            topic,
        });
    }

    /// 清空所有消息
    pub fn clear(&mut self) {
        self.messages.clear();
        self.history_tokens = 0;
        self.is_compressing = false;
        self.pending_topic_marker = None;
        // msg-split-phase1:全清后 DB 必须也清(调用方 chat.rs:1149 走 delete_range),
        // 全量重写基线重置到 0。needs_full_rewrite=true 保险(若 save 先于 delete_range 触发,
        // 全量重写空列表也会清 DB)。
        self.persisted_msg_count = 0;
        self.needs_full_rewrite = true;
    }

    /// 消息数量
    pub fn len(&self) -> usize {
        self.messages.len()
    }

    pub fn is_empty(&self) -> bool {
        self.messages.is_empty()
    }

    /// 当前历史占用的 token 数（不含 system prompt）
    pub fn history_tokens(&self) -> u32 {
        self.history_tokens
    }

    /// 预算上限
    pub fn budget_limit(&self) -> u32 {
        self.config.budget_limit()
    }

    // ── 核心：构建请求消息（受控裁剪版本）──

    /// 构建发送给 LLM 的消息列表
    ///
    /// `sys_tokens` 为调用方已估算好的 system prompt token 数。
    /// 超预算时自动裁剪旧消息（保护工具调用三元组 + 最近 PROTECT_COUNT 条）。
    /// 返回 (消息列表, 是否发生了裁剪)。
    pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
        let budget = self.budget_limit();
        let available = budget.saturating_sub(sys_tokens);

        // system prompt 自身超预算：裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
        if sys_tokens > budget {
            tracing::warn!(
                "system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
                sys_tokens, budget
            );
        }

        // 未超预算 → 直接返回全量（仍做畸形配对自愈，防历史中毒触发 provider 500 死循环）
        if self.history_tokens <= available {
            let sanitized = Self::sanitize_messages(self.all_messages_clone());
            // 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
            return (
                Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
                false,
            );
        }

        // 超预算 → 视图裁剪（不修改 self.messages，保证 all_messages_clone 仍返回全量）
        let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
        let units = self.build_eviction_units(protect_start);

        let mut removed: u64 = 0;
        let mut trim_end = 0;
        for unit in &units {
            if self.history_tokens.saturating_sub(removed as u32) <= available {
                break;
            }
            removed += unit.token_sum as u64;
            trim_end = unit.end;
        }

        if trim_end == 0 {
            tracing::warn!(
                "history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
                self.history_tokens, available, PROTECT_COUNT
            );
            // 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
            // 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
            // truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
            // 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
            // ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
            // view-only:不改 self.messages 持久化(与分支 1/3 一致)。
            let sanitized = Self::sanitize_messages(self.all_messages_clone());
            return (
                Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
                false,
            );
        }

        let msgs: Vec<ChatMessage> = self.messages[trim_end..]
            .iter()
            .map(|t| t.message.clone())
            .collect();

        tracing::info!(
            "context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
            trim_end, removed
        );
        let sanitized = Self::sanitize_messages(msgs);
        // 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
        (
            Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
            true,
        )
    }

    // ── 畸形配对自愈（转发至 [`sanitize`] 子模块，保持 ContextManager::xxx 调用路径）──

    /// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
    ///
    /// 保留为 `ContextManager` 关联函数以兼容历史调用路径（`Self::sanitize_messages` /
    /// `ContextManager::sanitize_messages`），实现见子模块文档。
    pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
        sanitize::sanitize_messages(messages)
    }

    /// 发送视图出口断言 — 转发到 [`sanitize::assert_placeholder_pairing`]。
    pub fn assert_placeholder_pairing(
        messages: Vec<ChatMessage>,
        enabled: bool,
    ) -> Vec<ChatMessage> {
        sanitize::assert_placeholder_pairing(messages, enabled)
    }

    /// 全量克隆（持久化 save_conversation / build_for_request 未裁剪分支，不受裁剪影响）
    pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
        self.messages.iter().map(|t| t.message.clone()).collect()
    }

    /// 从 Vec 恢复（兼容从 DB 加载）
    pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
        // clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载",
        // 加载后的 messages 与 DB 完全一致(它们就是从 DB 来的),append 基线应对齐到当前 len,
        // 且不需要全量重写(DB 已是真相源)。故 clear 后立即覆盖这两个字段。
        let len = messages.len();
        self.clear();
        for msg in messages {
            self.push(msg);
        }
        self.persisted_msg_count = len;
        self.needs_full_rewrite = false;
    }

    /// 就地替换某条 tool_result 的内容（兼容审批 replace_tool_result）
    /// 返回 true 如果找到并替换了
    ///
    /// 反向遍历：tool_result 由 append 进入历史，被替换的通常是最近的审批占位，
    /// 从尾部查找命中即停，避免对长历史做正向 O(n) 累积扫描。
    pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
        let pos = self.messages.iter().rposition(|t| {
            matches!(t.message.role, MessageRole::Tool)
                && t.message.tool_call_id.as_deref() == Some(tool_call_id)
        });

        let Some(i) = pos else { return false };

        // 先更新 content，再重估 token 并校正总量
        let old_tokens = self.messages[i].token_count;
        self.messages[i].message.content = new_content.to_string();
        let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
        self.messages[i].token_count = new_tokens;
        self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
        // msg-split-phase1:改既有消息 content,DB 需全量重写收敛(append-only 路径不覆盖旧行)。
        self.needs_full_rewrite = true;
        true
    }

    /// 弹出末尾连续的 assistant 消息（含其 tool_calls 三元组尾随 tool_result）
    ///
    /// 用于「重新生成」(UX-02):删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
    /// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
    ///
    /// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
    /// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。
    /// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。
    pub fn pop_last_assistant_round(&mut self) -> bool {
        if self.messages.is_empty() {
            return false;
        }
        let mut popped_any = false;
        // 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空
        while let Some(last) = self.messages.last() {
            if matches!(last.message.role, MessageRole::User) {
                break;
            }
            let removed = self.messages.pop().expect("just checked non-empty");
            self.history_tokens = self.history_tokens.saturating_sub(removed.token_count);
            if matches!(removed.message.role, MessageRole::Assistant) {
                popped_any = true;
            }
        }
        // msg-split-phase1:从末尾弹出 → len 缩短,append-only 基线(persisted_msg_count)
        // 会大于新 len。下次 save 必须全量重写(删 DB 中已不存在的尾行)。
        if popped_any {
            self.needs_full_rewrite = true;
        }
        popped_any
    }

    /// 编辑某条 user 消息后，将其后所有消息标记为 truncated（UX-09 编辑重生成）。
    ///
    /// 软删语义：保留在内存真相源 + DB（可追溯），但 sanitize_messages 过滤后不进 LLM 上下文，
    /// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数（0 表示该 user 消息已是末尾，无后续）。
    ///
    /// `target_content` 为该 user 消息的预期内容（用于反向唯一定位：末条 user 消息可能内容相同，
    /// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息）。
    /// 找不到返回 Err(())，调用方据此报错。
    pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
        // 反向找末条 active user 消息且 content 匹配
        let pos = self.messages.iter().rposition(|t| {
            matches!(t.message.role, MessageRole::User)
                && t.message.content == target_content
                && t.message.is_active()
        });
        let Some(i) = pos else { return Err(()) };
        // i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
        let mut count = 0usize;
        for t in self.messages[i + 1..].iter_mut() {
            if t.message.is_active() {
                t.message.status = Some(MessageStatus::Truncated);
                count += 1;
            }
        }
        // msg-split-phase1:改既有消息 status(truncated),DB 需全量重写收敛。
        if count > 0 {
            self.needs_full_rewrite = true;
        }
        Ok(count)
    }

    /// 替换末条 active user 消息的 content（UX-09 编辑重生成）。
    ///
    /// 编辑语义：只能编辑最后一条 user 消息（中间编辑语义复杂，拒绝）。返回 Err(()) 表示无 active user 消息。
    /// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
    pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
        let pos = self.messages.iter().rposition(|t| {
            matches!(t.message.role, MessageRole::User) && t.message.is_active()
        });
        let Some(i) = pos else { return Err(()) };
        let old_tokens = self.messages[i].token_count;
        self.messages[i].message.content = new_content.to_string();
        let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
        self.messages[i].token_count = new_tokens;
        self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
        // msg-split-phase1:改既有 user 消息 content,DB 需全量重写收敛。
        self.needs_full_rewrite = true;
        Ok(())
    }

    /// 只读迭代（兼容 ensure_conversation_title 的 .iter().filter() 等）
    pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
        self.messages.iter().map(|t| &t.message)
    }

    /// 消息级溯源:取末条指定 role 消息的 id(ULID)。
    ///
    /// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
    /// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
    /// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。
    ///
    /// 用途:
    /// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id
    ///   (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取)
    /// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id
    fn last_message_id_by_role(&self, role: MessageRole) -> Option<String> {
        self.messages
            .iter()
            .rev()
            .find(|t| {
                std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role)
            })
            .and_then(|t| t.message.id.clone())
    }

    /// 末条 assistant 消息的 id(消息级溯源用)。
    pub fn last_assistant_message_id(&self) -> Option<String> {
        self.last_message_id_by_role(MessageRole::Assistant)
    }

    /// 末条 user 消息的 id(消息级溯源用)。
    pub fn last_user_message_id(&self) -> Option<String> {
        self.last_message_id_by_role(MessageRole::User)
    }

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

    /// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。
    ///
    /// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。
    /// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。
    fn last_user_topic(&self) -> Option<String> {
        self.messages
            .iter()
            .rev()
            .find(|t| matches!(t.message.role, MessageRole::User))
            .and_then(|t| t.topic.clone())
    }

    /// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。
    ///
    /// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。
    /// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。
    pub fn take_topic_marker(&mut self) -> Option<String> {
        self.pending_topic_marker.take()
    }

    // ── F-15 上下文管理增强辅助方法（压缩链路核心 API）──
    //
    // 已全量接入压缩链路:
    // - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
    //   set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
    //   messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
    //   insert_at(摘要/续接锚点 system 消息插入首位)
    // - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
    // - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。

    /// 配置（只读视图，供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`）
    pub fn config(&self) -> &ContextConfig {
        &self.config
    }

    /// 可变消息切片（供标记 status="compressed"/"archived_segment" + 调整 token）
    ///
    /// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
    /// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
    ///
    /// msg-split-phase1:**若调用方借此句柄改了既有消息(status/content),必须紧接着调
    /// [`mark_needs_full_rewrite`]**,否则下次 save 走 append-only 增量路径会漏更新旧行,
    /// DB 与内存脱钩。仅读(过滤后 clone)不需调。本方法无法自行置位(借用冲突:返回 &mut
    /// 切片时不能再持有 &mut self 标志)。
    pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
        &mut self.messages
    }

    /// msg-split-phase1:显式标记"下次 save 须全量重写"。
    ///
    /// 供 [`messages_mut`] 的调用方在改完既有消息后调用(本 struct 无法在返回 &mut 切片时
    /// 自行置位)。也可供任何绕过本 impl 直接改 messages 的路径兜底。幂等(重复置 true 无害)。
    pub fn mark_needs_full_rewrite(&mut self) {
        self.needs_full_rewrite = true;
    }

    /// msg-split-phase1:读取已落库消息条数(append-only 增量写路径的基线)。供 save_conversation
    /// 判定 `[persisted_msg_count..len)` 是否为"自上次 save 后新增"。
    pub fn persisted_msg_count(&self) -> usize {
        self.persisted_msg_count
    }

    /// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。
    /// 同时清 needs_full_rewrite(DB 已与内存一致)。供 save_conversation 在写库成功后调用。
    pub fn advance_persisted_count(&mut self, new_count: usize) {
        // 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。
        self.persisted_msg_count = self.persisted_msg_count.max(new_count);
        self.needs_full_rewrite = false;
    }

    /// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。
    pub fn needs_full_rewrite(&self) -> bool {
        self.needs_full_rewrite
    }

    /// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
    ///
    /// 供压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
    /// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
    pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
        let tokens = self.estimator.estimate_message(&message);
        let group = classify_group(&message);
        if message.is_active() {
            self.history_tokens += tokens;
        }
        self.messages.insert(index, TrackedMessage {
            message,
            token_count: tokens,
            group,
            topic: None,
        });
        // msg-split-phase1:中段插入会改变 index 及之后所有消息的 seq(append-only 基线
        // persisted_msg_count 按"末尾追加"语义计算 seq,中段插入后 seq 错位),DB 必须全量重写。
        self.needs_full_rewrite = true;
    }

    /// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
    ///
    /// 返回每个单元的右开区间 end + token 总和,保证:
    /// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
    /// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
    ///
    /// 公开会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
    pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
        let mut units = Vec::new();
        let mut i = 0usize;

        while i < protect_start {
            let mut token_sum = 0u32;

            if self.messages[i].group == MessageGroup::ToolCallHead {
                token_sum += self.messages[i].token_count;
                i += 1;
                while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
                    token_sum += self.messages[i].token_count;
                    i += 1;
                }
                if i < protect_start
                    && self.messages[i].group == MessageGroup::Standalone
                    && matches!(self.messages[i].message.role, MessageRole::Assistant)
                {
                    token_sum += self.messages[i].token_count;
                    i += 1;
                }
            } else {
                token_sum += self.messages[i].token_count;
                i += 1;
            }

            units.push(EvictionUnit { end: i, token_sum });
        }

        units
    }

    /// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。
    ///
    /// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated
    /// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
    pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
        let end = protect_start.min(self.messages.len());
        // 排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
        // 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
        // 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
        // compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
        self.messages[..end]
            .iter()
            .any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
    }

    /// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
    /// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供喂 LLM 摘要)。
    ///
    /// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
    /// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
    ///
    /// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
    /// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`。
    ///
    /// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
    pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
        let end = compress_end.min(self.messages.len());
        let mut newly_compressed = Vec::new();
        for t in self.messages[..end].iter_mut() {
            if t.message.is_active() {
                newly_compressed.push(t.message.clone());
                t.message.status = Some(MessageStatus::Compressed);
                self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
            }
        }
        // msg-split-phase1:改了既有消息 status(active → compressed),DB 需全量重写收敛
        // (append-only 增量路径不覆盖旧行)。仅在确实标了新 compressed 时置位(无可压缩时
        // newly_compressed 为空,状态未变,无需全量重写)。
        if !newly_compressed.is_empty() {
            self.needs_full_rewrite = true;
        }
        newly_compressed
    }

    /// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
    pub fn is_compressing(&self) -> bool {
        self.is_compressing
    }

    /// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位),
    /// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。
    pub fn set_compressing(&mut self, v: bool) {
        self.is_compressing = v;
    }

    // build_eviction_units / classify_group 等已在上方公开或文件级定义。
}

#[cfg(test)]
mod manager_tests;
