//! 对话持久化 + Token 累加器

use std::sync::Arc;
use std::time::Duration;

use tokio::sync::Mutex;

use df_storage::crud::AiConversationRepo;
use df_storage::db::Database;

use crate::commands::now_millis;

use super::commands::message_to_record;
use super::AiSession;

/// save_conversation 的总超时上限(秒)。
///
/// 内部 DB IO 经 Repo 方法已各自 spawn_blocking(不会阻塞 tokio runtime),
/// 但仍可能因 SQLite Mutex 长等待 / fs 卡 / 大体量 records 序列化等卡住整个调用,
/// 致 agentic loop 或 IPC 永久挂起。外层 timeout 防御:超时只 warn 不阻断
/// (save 本就是非关键路径,失败已 warn + 用户可见结果仍在内存真相源)。
const SAVE_CONVERSATION_TIMEOUT_SECS: u64 = 5;

/// Token 用量累加器(agent loop 生命周期内各轮叠加)
///
/// 纯结构 + 方法:抽自 run_agentic_loop 的 `total_prompt`/`total_completion` 双计数器,
/// 保证多轮累加、None 起始、跨 loop 实例叠加语义一致且可单测。
#[derive(Debug, Clone, Default)]
pub(crate) struct TokenAccumulator {
    prompt: u32,
    completion: u32,
}

impl TokenAccumulator {
    /// 叠加一轮用量(round_usage 为本轮流式末 chunk 的累计用量)
    ///
    /// saturating_add:恶意/异常 provider 返回巨大值时,避免 u32 += 溢出回绕打乱后续 budget 判定。
    pub(crate) fn add(&mut self, prompt: u32, completion: u32) {
        self.prompt = self.prompt.saturating_add(prompt);
        self.completion = self.completion.saturating_add(completion);
    }

    pub(crate) fn prompt(&self) -> u32 {
        self.prompt
    }

    pub(crate) fn completion(&self) -> u32 {
        self.completion
    }

    pub(crate) fn total(&self) -> u32 {
        self.prompt.saturating_add(self.completion)
    }
}

/// 把单轮增量叠加到 DB 的 Option<i64> 字段(读旧值+增量,跨 loop 实例防覆盖)
///
/// 纯函数:抽自 save_conversation 的 token 累加逻辑,None 起始当作 0。
/// saturating_add:长期对话累积接近 i64::MAX 时不再翻负,封顶在 i64::MAX(统计语义安全,
/// 溢出回绕成负值会污染前端用量展示与计费/限额判定)。
pub(crate) fn accumulate_tokens(old: Option<i64>, add: u32) -> Option<i64> {
    Some(old.unwrap_or(0).saturating_add(add as i64))
}

/// 持久化截断阈值:超过此长度的消息 content 落库前截断头尾各保 HEAD/TAIL 字符。
///
/// 防 read_file 1MB 洞 / list_directory 大体量结果落库后每轮重发累积致 token 暴增
/// (Sprint 19 实测单对话 in=115万 / 消息体 1.6MB)。仅作用于持久化视图,不污染内存真相源。
///
/// B-260619-02:50KB → 8KB 收紧。治 GLM 1214 messages 非法根因——GLM anthropic 端点对
/// 单条 tool_result content 限制更严(实测 ~10-20KB 即拒 1214),read_file 默认前 500 行源码
/// 约 11-21KB,旧 50KB 阈值未触发→DB 存全量→reload/下轮重发→GLM 1214 整请求失败。
/// 8KB 对齐 GLM 单条上限留余量;截断后 LLM 仍能据截断片段推理(优于 1214 直接拒收)。
pub(crate) const TRUNCATE_THRESHOLD: usize = 8_192;
const TRUNCATE_HEAD: usize = 3_072;
const TRUNCATE_TAIL: usize = 3_072;

/// 落库前对超长 content 做截断(保留头尾各 ~3KB + 中段标注省略字符数 + 总字节数)。
/// 8KB 阈值以下原样返回(零开销);按字符而非字节切避免 UTF-8 切坏中文。
/// 截断提示含原始字节数,让 LLM 明确感知非全文(防它误以为已看全)。
pub(crate) fn truncate_for_persist(content: &str) -> String {
    let chars: Vec<char> = content.chars().collect();
    if chars.len() <= TRUNCATE_THRESHOLD {
        return content.to_string();
    }
    let head: String = chars.iter().take(TRUNCATE_HEAD).collect();
    let tail: String = chars[chars.len() - TRUNCATE_TAIL..].iter().collect();
    let omitted = chars.len() - TRUNCATE_HEAD - TRUNCATE_TAIL;
    format!(
        "{}\n\n[...省略 {} 字符(已截断,原 {} 字节,仅显示前 8KB,完整内容仅在内存态可读)...]\n\n{}",
        head, omitted, content.len(), tail
    )
}

/// 落库前对 `ChatMessage.parts` 做截断(F-260614-05 Phase 2a)。
///
/// - `Text` 片:复用 `truncate_for_persist` 头尾截断语义(单 Text 片 > 8KB 才截)。
/// - `Image` 片:base64 通常已 8KB+,落库前替换为占位 `Text` 片
///   `<image: base64 已省略, 共 N 字节>`,避免大体量图把对话 JSON 撑爆
///   (一张 1MB PNG 的 base64 ≈ 1.3MB 字符串)。原 Image 片仅在内存真相源(ContextManager)
///   保留,重发时仍带图——对齐现有「持久化视图不污染内存真相源」约定。
/// - `Image` url 模式(无 base64):url 本身短,原样保留。
///
/// 返回 None 表示 parts 无需保留(全空或全部被替换且原 parts 仅含图);调用方据此清空 parts。
pub(crate) fn truncate_parts_for_persist(parts: &[df_ai::provider::ContentPart]) -> Option<Vec<df_ai::provider::ContentPart>> {
    use df_ai::provider::ContentPart;
    let mut out: Vec<ContentPart> = Vec::with_capacity(parts.len());
    let mut changed = false;
    for p in parts {
        match p {
            ContentPart::Text { text } => {
                let truncated = truncate_for_persist(text);
                if truncated.len() != text.len() {
                    changed = true;
                }
                out.push(ContentPart::Text { text: truncated });
            }
            ContentPart::Image { url, base64, media_type, alt } => {
                // base64 模式:体量大,替换占位 Text 片
                if let Some(b) = base64 {
                    let base64_len = b.len(); // 纯 base64 字符串长度（不含 data: 前缀）
                    let placeholder = format!("<image: base64 已省略, 共约 {} 字符>", base64_len);
                    out.push(ContentPart::Text { text: placeholder });
                    changed = true;
                } else {
                    // url 模式:url 短,原样保留(含 media_type/alt)
                    out.push(ContentPart::Image {
                        url: url.clone(),
                        base64: None,
                        media_type: media_type.clone(),
                        alt: alt.clone(),
                    });
                }
            }
        }
    }
    if out.is_empty() {
        None
    } else if changed {
        Some(out)
    } else {
        // 无变化:返回克隆的原 parts(保持引用语义一致)
        Some(parts.to_vec())
    }
}

/// 保存对话到数据库（按 conv_id 写库，不受 active_conversation_id 切换影响）
///
/// 写 ai_messages(每条消息一行,F-260619-03 拆分存储)+ updated_at + 累加 token 用量 +
/// 首次落库的 model;标题由 ensure_conversation_title 单独生成。
/// token 走累加模式:upsert 读旧值叠加,保证审批暂停→恢复跨 loop 实例不覆盖丢失。
/// model 仅首次落库写入 + 旧记录缺值时补填(不覆盖历史已存值,兼容本次改造前的老对话)。
///
/// F-260619-03 批次 B:写路径切 ai_messages(全量重写,replace_conversation)。
///   内存 ContextManager 是运行时真相源,save 是同步点——每轮全量回写 ai_messages,
///   compress/replace/edit/clear_context 内存改 status/content 后下轮 save 自动覆盖。
///   旧 messages JSON 列**保留不写**(作备份,防 reload fallback 读旧脏数据),不赋新值也不置空。
pub(crate) async fn save_conversation(
    session_arc: &Arc<Mutex<AiSession>>,
    db: &Arc<Database>,
    conv_id: &str,
    usage: Option<&df_ai::provider::TokenUsage>,
    model: Option<&str>,
    touch_updated_at: bool,
) {
    // 外层 timeout(SAVE_CONVERSATION_TIMEOUT_SECS):超时只 warn 不阻断。
    // save 是非关键路径(失败已 warn,内存真相源不变),防 DB/fs 卡死拖垮 agentic loop / IPC。
    // timeout 仅作用于 DB 写入段(此处包住整个 inner,因 session 锁/序列化也属 save 周期,
    // 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。
    if let Err(_elapsed) = tokio::time::timeout(
        Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS),
        save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at),
    )
    .await
    {
        tracing::warn!(
            "save_conversation 超时 ({}s) 放弃 — conv_id={} (DB/fs 可能卡住,内存真相源不变,下次 loop 重落库)",
            SAVE_CONVERSATION_TIMEOUT_SECS,
            conv_id
        );
    }
}

/// save_conversation 内部实现(无超时,由外层 save_conversation 包 timeout 调用)。
async fn save_conversation_inner(
    session_arc: &Arc<Mutex<AiSession>>,
    db: &Arc<Database>,
    conv_id: &str,
    usage: Option<&df_ai::provider::TokenUsage>,
    model: Option<&str>,
    touch_updated_at: bool,
) {
    // 取 messages + 懒创建首次落库所需的 provider_id/created_at
    // 工具结果(content)超 8KB(B-260619-02:50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段
    // 标注(含原字节数),防大体量结果(read_file 1MB 洞 / list_directory 13782 项)落库后每轮重发
    // 累积致 token 暴增。仅影响持久化视图,不污染内存真相源(ContextManager)——build_for_request
    // 仍读全量 messages。
    //
    // F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源:本函数入参,与 save 落库的 conv 一致)。
    //   loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。
    //   conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端
    //   未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。
    // BUG-2026-07-19: lock 段只 clone 必要读(messages + 元数据),truncate 移出 lock。
    // 原 clone+truncate 全在 lock 内,对话历史长(多轮+大工具结果)时 clone+truncate 持锁可达秒级,
    // 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+
    // 回答完卡住+下条进队列)。truncate 在 clone 副本上操作,锁外执行不污染 session 真相源,行为等价。
    let __lock_start = std::time::Instant::now();
    // msg-split-phase1:同时读 persisted_msg_count + needs_full_rewrite(增量写路径判定用)。
    //   读基线与读 messages 在同一 lock 段(快,无 await),保证两者一致性快照。
    //   借用顺序:先 clone session 顶层字段(immutable borrow),再 conv(conv_id) mutable borrow,
    //   避免同时持有 session 的 mut 和 immut 借用(E0502)。
    let (mut msgs, provider_id, created_at, pinned_goals, persisted_count, needs_full_rewrite) = {
        let mut session = session_arc.lock().await;
        let __wait = __lock_start.elapsed();
        let provider_id = session.active_provider_id.clone();
        let created_at = session.active_conv_created_at.clone();
        let conv = session.conv(conv_id);
        let cloned = (
            conv.messages.all_messages_clone(),
            provider_id,
            created_at,
            conv.pinned_goals.clone(),
            conv.messages.persisted_msg_count(),
            conv.messages.needs_full_rewrite(),
        );
        let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
        cloned
    };
    let __lock_total = __lock_start.elapsed();
    if __lock_total > std::time::Duration::from_millis(50) {
        eprintln!("[LOCK-DIAG] save clone 段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total);
    }
    // truncate 在锁外(clone 副本上操作,不影响 session 真相源)
    // T2: 展开 namespace 引用为原文再落库
    {
        let session = session_arc.lock().await;
        for m in &mut msgs {
            if df_ai::namespace_store::is_namespace_ref(&m.content) {
                if let Some(original) = session.namespace_store.read_only(&m.content) {
                    m.content = original.to_string();
                }
            }
        }
    }
    for m in &mut msgs {
        // P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
        // 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。
        // tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。
        if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
            m.content = truncate_for_persist(&m.content);
        }
        // F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
        // 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
        if let Some(parts) = m.parts.as_ref() {
            m.parts = truncate_parts_for_persist(parts);
        }
    }
    let persist_msgs = msgs;


    // 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目,
    // 序列化为 JSON 对象(tool_call_id → PendingApproval),落库供重启/切回时恢复。
    let pending_approvals_json = {
        let session = session_arc.lock().await;
        let conv_pending: std::collections::HashMap<&String, &crate::commands::ai::PendingApproval> = session.pending_approvals
            .iter()
            .filter(|(_, a)| a.conversation_id.as_deref() == Some(conv_id))
            .collect();
        serde_json::to_string(&conv_pending).unwrap_or_else(|_| "{}".to_string())
    };

    // F-260619-03 批次 B:映射 Vec<ChatMessage> → Vec<AiMessageRecord>(带 seq 索引 + conv_id)
    //   全量重写 ai_messages(单事务 DELETE + INSERT OR IGNORE,原子无中间空窗)。
    //   created_at 用对话级 created_at(老对话 None 时 now 兜底),保证消息创建时间与对话一致。
    //
    // msg-split-phase1:增加 append-only 增量写路径(治"50+ 轮对话每轮全量 DELETE+INSERT 重写"):
    //   - 纯追加(常态):needs_full_rewrite=false 且 len > persisted_count → 只 insert_batch
    //     records[persisted_count..len](INSERT OR IGNORE 幂等,新消息 id 稳定不冲突),
    //     跳过 replace_conversation 的全表 DELETE+重插(省 N 倍写入 + 页链重整)。
    //   - 脏路径(compress/edit/replace/pop/insert_at/clear 触发 needs_full_rewrite=true):
    //     内存与 DB 可能脱钩(既有行 status/content 变了 / len 缩了 / 中段插了),
    //     走全量 replace_conversation 收敛(DELETE + INSERT 全部),保证最终一致。
    //   首次落库(Ok(None))走 insert_batch(空表,直接插全量,无需 DELETE)。
    let now = now_millis();
    let msg_created_at = created_at.clone().unwrap_or_else(|| now.clone());
    let total_len = persist_msgs.len();
    let records: Vec<df_storage::models::AiMessageRecord> = persist_msgs
        .iter()
        .enumerate()
        .map(|(seq, m)| message_to_record(m, conv_id, seq as i64, &msg_created_at))
        .collect();

    let conv_repo = AiConversationRepo::new(db);
    let msg_repo = df_storage::crud::AiMessageRepo::new(db);
    // msg-split-phase1:本轮 save 的消息写入是否成功(成功才推进 persisted_count 基线)。
    //   失败保基线不变 → 下次 save 仍会尝试写这些消息(INSERT OR IGNORE 幂等 / 全量重写收敛),
    //   不丢消息。默认 false(保守:出错时不推进)。
    let mut msgs_written_ok = false;
    match conv_repo.get_by_id(conv_id).await {
        Ok(Some(mut rec)) => {
            // 已落库:更新对话元数据(token/model/updated_at);messages JSON 列**不赋新值**(保留旧值作备份)。
            //   updated_at 按 touch_updated_at 条件改(用户活跃=true 反映最后活跃,
            //   系统摘要/压缩=false 防会话时间分组"昨天→今天"跳变);
            //   token 累加(读旧值+新值,跨 loop 实例防覆盖)。
            if touch_updated_at {
                rec.updated_at = now_millis();
            }
            if let Some(u) = usage {
                rec.prompt_tokens = accumulate_tokens(rec.prompt_tokens, u.prompt_tokens);
                rec.completion_tokens = accumulate_tokens(rec.completion_tokens, u.completion_tokens);
            }
            // model: 旧记录缺值时补填(不覆盖已有);models: 去重追加用过的所有 model(JSON 数组)
            if let Some(m) = model {
                if rec.model.is_none() { rec.model = Some(m.to_string()); }
                let mut list: Vec<String> = rec.models
                    .as_deref()
                    .and_then(|s| serde_json::from_str(s).ok())
                    .unwrap_or_default();
                if !list.iter().any(|x| x == m) { list.push(m.to_string()); }
                rec.models = Some(serde_json::to_string(&list).unwrap_or_else(|_| "[]".to_string()));
            }
            // G1 目标钉扎持久化:将 per_conv.pinned_goals 写入 DB
            rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string()));
            // 挂起审批快照持久化:每轮 save 同步当前 pending_approvals 快照
            rec.pending_approvals = Some(pending_approvals_json.clone());
            // update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
            if let Err(e) = conv_repo.update_full(&rec).await {
                tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
            }
            // 消息拆分存储(msg-split-phase1 增量优化):
            if !needs_full_rewrite && total_len == persisted_count {
                // 无变化捷径:消息条数未变且无 dirty → DB ai_messages 与内存完全一致,
                // 跳过 ai_messages 写入(省整次 DELETE+INSERT,仅元数据 update_full 已写过)。
                // 场景:loop 中段多次 save 同一消息集合(如 MidStream 保文前后的状态同步);
                // 元数据(token/updated_at/pinned_goals/pending_approvals)仍走上方 update_full。
                msgs_written_ok = true;
            } else if !needs_full_rewrite && total_len > persisted_count {
                // append-only 增量:只插 [persisted_count, total_len) 新切片。
                //   records 的 seq 是全量索引(persisted_count 处即下一条新消息的 seq),
                //   切片 [persisted_count..] 正好是本轮新增消息。
                let new_records: Vec<df_storage::models::AiMessageRecord> =
                    records[persisted_count.min(total_len)..].to_vec();
                match msg_repo.insert_batch(new_records).await {
                    Ok(()) => { msgs_written_ok = true; }
                    Err(e) => tracing::warn!(
                        "增量写 ai_messages 失败 {conv_id} (slice [{},{}),回退全量重写下轮收敛): {e}",
                        persisted_count, total_len
                    ),
                }
            } else {
                // 脏路径或无新增但 dirty:全量重写(DELETE + INSERT 全部),保证最终一致。
                //   needs_full_rewrite=true 时既有行 status/content 可能变了(如 compress 标
                //   compressed 但消息条数没增),必须重写覆盖旧行。
                if let Err(e) = msg_repo.replace_conversation(conv_id, records).await {
                    tracing::warn!("全量重写 ai_messages 失败 {conv_id}: {e}");
                } else {
                    msgs_written_ok = true;
                }
            }
        }
        Ok(None) => {
            // 懒创建首次落库(此为空对话不落库的落库点:走到这里 messages 必非空)
            //   messages JSON 列首次落库也写(兼容未跑迁移的老库 fallback 读路径),
            //   同时写 ai_messages(新读路径真相源)。
            let messages_json = serde_json::to_string(&persist_msgs).unwrap_or_else(|_| "[]".to_string());
            let conv_created = created_at.unwrap_or_else(|| now.clone());
            let rec = df_storage::models::AiConversationRecord {
                id: conv_id.to_string(),
                title: None,
                messages: messages_json,
                provider_id,
                model: model.map(|m| m.to_string()),
                models: model.map(|m| serde_json::to_string(&[m]).unwrap_or_else(|_| "[]".to_string())),
                archived: false,
                pinned: false,
                prompt_tokens: usage.map(|u| u.prompt_tokens as i64),
                completion_tokens: usage.map(|u| u.completion_tokens as i64),
                pinned_goals: Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string())),
                pending_approvals: Some(pending_approvals_json.clone()),
                created_at: conv_created.clone(),
                updated_at: now,
            };
            if let Err(e) = conv_repo.insert(rec).await {
                tracing::warn!("落库对话失败 {conv_id}: {e}");
            }
            // 消息拆分存储:首次落库空表,直接 insert_batch 全量(无需 DELETE)。
            //   原 replace_conversation 也可(DELETE 空表 + INSERT),但 insert_batch 省 DELETE 开销。
            if let Err(e) = msg_repo.insert_batch(records).await {
                tracing::warn!("首次写 ai_messages 失败 {conv_id}: {e}");
            } else {
                msgs_written_ok = true;
            }
        }
        Err(e) => tracing::warn!("读取对话 {conv_id} 失败: {e}"),
    }

    // msg-split-phase1:写库成功才推进基线(DB 已与内存一致)。
    //   失败保基线不变:下次 save 会重新尝试写未落库的消息(增量路径重试同 slice,INSERT OR IGNORE
    //   幂等不冲突;脏路径全量重写收敛)。基线推进必须在锁内与读时一致(本进程内单线程 save 串行)。
    //   注意:写库期间若有其他操作 push/置 dirty,基线推进可能与最新内存错位——但 advance 用
    //   max 防回退,且 dirty 标志由那次操作自己设(下次 save 仍走全量重写收敛),最终一致。
    if msgs_written_ok {
        let mut session = session_arc.lock().await;
        session.conv(conv_id).messages.advance_persisted_count(total_len);
    }

    // 自动 checkpoint（每 20 轮或总 token > 150k 时创建）
    {
        let total_tokens: i64 = persist_msgs.len() as i64;
        if total_tokens > 0 && total_tokens % 20 == 0 {
            let snapshot = serde_json::to_string(&persist_msgs).unwrap_or_default();
            if !snapshot.is_empty() && snapshot.len() < 1_000_000 {
                let ck_id = format!("ck_{}_{}", conv_id.replace('-', ""), total_tokens);
                let ck_now = now_millis().to_string();
                // 参数化绑定(走 AiConversationRepo::insert_checkpoint),替代原 format! 拼 SQL,
                // 防 snapshot 含引号/特殊字符致注入或损坏。
                if let Err(e) = conv_repo.insert_checkpoint(&ck_id, conv_id, &snapshot, total_tokens, &ck_now).await {
                    tracing::warn!("checkpoint 写入失败 {conv_id}: {e}");
                }
            }
        }
    }
}

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

    // ---------- TokenAccumulator + accumulate_tokens ----------

    #[test]
    fn accumulator_starts_zero() {
        let acc = TokenAccumulator::default();
        assert_eq!(acc.prompt(), 0);
        assert_eq!(acc.completion(), 0);
        assert_eq!(acc.total(), 0);
    }

    #[test]
    fn accumulator_single_add() {
        let mut acc = TokenAccumulator::default();
        acc.add(100, 50);
        assert_eq!(acc.prompt(), 100);
        assert_eq!(acc.completion(), 50);
        assert_eq!(acc.total(), 150);
    }

    #[test]
    fn accumulator_multi_round_accumulation() {
        // 多轮累加(模拟 agent loop 多次迭代)
        let mut acc = TokenAccumulator::default();
        acc.add(100, 20); // 轮1
        acc.add(200, 40); // 轮2
        acc.add(50, 10);  // 轮3
        assert_eq!(acc.prompt(), 350);
        assert_eq!(acc.completion(), 70);
        assert_eq!(acc.total(), 420);
    }

    #[test]
    fn accumulator_add_zero_is_noop() {
        let mut acc = TokenAccumulator::default();
        acc.add(10, 5);
        acc.add(0, 0);
        assert_eq!(acc.total(), 15);
    }

    #[test]
    fn accumulate_tokens_from_none() {
        // 新记录(None 起始)落库
        assert_eq!(accumulate_tokens(None, 100), Some(100));
        assert_eq!(accumulate_tokens(None, 0), Some(0));
    }

    #[test]
    fn accumulate_tokens_adds_to_existing() {
        // 跨 loop 实例叠加:旧值 + 新增不覆盖
        assert_eq!(accumulate_tokens(Some(500), 100), Some(600));
        assert_eq!(accumulate_tokens(Some(0), 42), Some(42));
    }

    #[test]
    fn accumulate_tokens_multi_round_db_simulation() {
        // 模拟 save_conversation 多次落库累加(审批暂停→恢复跨 loop)
        let mut field: Option<i64> = None;
        field = accumulate_tokens(field, 100); // 首次
        field = accumulate_tokens(field, 200); // 二次
        field = accumulate_tokens(field, 50);  // 三次
        assert_eq!(field, Some(350));
    }

    #[test]
    fn accumulator_and_db_accumulate_are_consistent() {
        // loop 内 TokenAccumulator 与落库 accumulate_tokens 总量语义一致
        let mut acc = TokenAccumulator::default();
        let mut db_prompt: Option<i64> = None;
        let mut db_completion: Option<i64> = None;
        for (p, c) in [(100u32, 20u32), (200, 40), (50, 10)] {
            acc.add(p, c);
            db_prompt = accumulate_tokens(db_prompt, p);
            db_completion = accumulate_tokens(db_completion, c);
        }
        assert_eq!(acc.prompt() as i64, db_prompt.unwrap());
        assert_eq!(acc.completion() as i64, db_completion.unwrap());
    }

    // ---------- truncate_for_persist ----------

    #[test]
    fn truncate_short_content_unchanged() {
        // 阈值以下原样返回
        assert_eq!(truncate_for_persist("hello"), "hello");
        assert_eq!(truncate_for_persist(""), "");
        let near_limit: String = "a".repeat(TRUNCATE_THRESHOLD);
        assert_eq!(truncate_for_persist(&near_limit).len(), TRUNCATE_THRESHOLD);
    }

    #[test]
    fn truncate_long_content_keeps_head_and_tail() {
        // 超阈值:保留头尾各 TRUNCATE_HEAD/TAIL 字符 + 中段标注
        let long: String = "x".repeat(TRUNCATE_THRESHOLD + 1000);
        let result = truncate_for_persist(&long);
        // 头尾各 20k 字符应在结果中
        assert!(result.starts_with(&"x".repeat(TRUNCATE_HEAD)));
        assert!(result.ends_with(&"x".repeat(TRUNCATE_TAIL)));
        // 中段标注存在 + 标注省略字符数(中段 = 总长 - 头 - 尾 = 9192 - 3072 - 3072 = 3048)
        assert!(result.contains("已截断"));
        assert!(result.contains("省略 3048 字符"));
        // B-260619-02:截断提示含原字节数(9192 个 'x' = 9192 字节),让 LLM 感知非全文
        assert!(result.contains("原 9192 字节"));
        // 结果总长应远小于原长(20k 头 + 20k 尾 + 标注)
        assert!(result.chars().count() < TRUNCATE_THRESHOLD + 1000);
    }

    #[test]
    fn truncate_preserves_utf8_chinese() {
        // 按字符切不切坏 UTF-8 中文
        let chinese: String = "中".repeat(TRUNCATE_THRESHOLD + 500);
        let result = truncate_for_persist(&chinese);
        assert!(result.starts_with('中'));
        assert!(result.ends_with('中'));
        assert!(result.contains("已截断"));
    }

    // ---------- F-260614-05 Phase 2a truncate_parts_for_persist ----------

    #[test]
    fn truncate_parts_image_base64_replaced_with_placeholder() {
        use df_ai::provider::ContentPart;
        let parts = vec![
            ContentPart::text("前缀文本"),
            ContentPart::image_base64("image/png", &"A".repeat(60_000)),
            ContentPart::text("后缀"),
        ];
        let out = truncate_parts_for_persist(&parts).expect("应有结果");
        // Image base64 被替换为占位 Text 片
        assert!(out.iter().all(|p| !matches!(p, ContentPart::Image { base64: Some(_), .. })));
        let placeholder = out.iter().find_map(|p| match p {
            ContentPart::Text { text } if text.contains("base64 已省略") => Some(text.clone()),
            _ => None,
        }).expect("应含占位 Text 片");
        assert!(placeholder.contains("60000"));
        // 非 base64 的 Text 片保留原值
        assert!(out.iter().any(|p| matches!(p, ContentPart::Text { text } if text == "前缀文本")));
        assert!(out.iter().any(|p| matches!(p, ContentPart::Text { text } if text == "后缀")));
    }

    #[test]
    fn truncate_parts_image_url_preserved() {
        use df_ai::provider::ContentPart;
        // url 模式:url 短,原样保留(含 media_type)
        let parts = vec![ContentPart::Image {
            url: Some("https://x/a.png".into()),
            base64: None,
            media_type: None,
            alt: None,
        }];
        let out = truncate_parts_for_persist(&parts).expect("应有结果");
        assert!(matches!(out[0], ContentPart::Image { ref url, .. } if url.as_deref() == Some("https://x/a.png")));
    }

    #[test]
    fn truncate_parts_long_text_part_truncated() {
        use df_ai::provider::ContentPart;
        // 单 Text 片超阈值 → 头尾截断
        let long: String = "x".repeat(TRUNCATE_THRESHOLD + 1000);
        let parts = vec![ContentPart::Text { text: long }];
        let out = truncate_parts_for_persist(&parts).expect("应有结果");
        match &out[0] {
            ContentPart::Text { text } => assert!(text.contains("已截断"), "超长 Text 片应被截断"),
            _ => panic!("应仍是 Text 片"),
        }
    }

    #[test]
    fn truncate_parts_empty_returns_none() {
        assert_eq!(truncate_parts_for_persist(&[]), None);
    }

    // ---------- P0-2: tool role content 不截断(防 JSON 结构破坏) ----------

    /// 辅助:构造指定 role + content 的 ChatMessage,用 provider 全路径类型
    fn make_msg(role: df_ai::provider::MessageRole, content: &str) -> df_ai::provider::ChatMessage {
        df_ai::provider::ChatMessage {
            id: None,
            role,
            content: content.to_string(),
            parts: None,
            tool_call_id: None,
            tool_calls: None,
            model: None,
            status: None,
            reasoning_content: None,
            timestamp: None,
        }
    }

    /// save_conversation 的 content 截断决策片段(抽测 role 分支,避免起 DB/锁)。
    /// 对 tool role 跳过截断,其余 role 走截断。
    fn truncate_decision(m: &mut df_ai::provider::ChatMessage) {
        if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
            m.content = truncate_for_persist(&m.content);
        }
    }

    #[test]
    fn tool_role_large_content_not_truncated() {
        // tool result 是结构化 JSON,中段截断插裸换行+省略标记会破坏 JSON parse。
        // >8192(阈值)的 tool content 必须原样保留(不进 truncate_for_persist)。
        let json_like = format!(
            "{{\"file\":\"x.rs\",\"content\":\"{}\"}}",
            "a".repeat(TRUNCATE_THRESHOLD + 2000)
        );
        let original = json_like.clone();
        let mut m = make_msg(df_ai::provider::MessageRole::Tool, &json_like);
        truncate_decision(&mut m);
        // 关键断言:tool role 不截断,原样保留(长度与内容完全一致)
        assert_eq!(m.content.len(), original.len(), "tool role content 不应被截断");
        assert_eq!(m.content, original, "tool role content 原样保留");
        // 不含截断标记(证明未进 truncate_for_persist)
        assert!(!m.content.contains("已截断"), "tool role 不应含截断标注");
    }

    #[test]
    fn assistant_role_large_content_still_truncated() {
        // 对照:assistant role 超阈值仍截断(回归保护)
        let long: String = "x".repeat(TRUNCATE_THRESHOLD + 1000);
        let mut m = make_msg(df_ai::provider::MessageRole::Assistant, &long);
        truncate_decision(&mut m);
        assert!(m.content.contains("已截断"), "assistant role 超阈值应截断");
        assert!(m.content.len() < long.len(), "assistant role 截断后应变短");
    }

    #[test]
    fn tool_role_short_content_unchanged() {
        // 短 tool content:即使进了 truncate_for_persist 也不会改(阈值以下),
        // 但此处验证 role 分支本身对短 tool content 也安全(原样保留)。
        let short = r#"{"ok":true,"rows":3}"#;
        let mut m = make_msg(df_ai::provider::MessageRole::Tool, short);
        truncate_decision(&mut m);
        assert_eq!(m.content, short);
    }
}
