//! F-15 上下文压缩 — LLM 摘要公共函数
//!
//! 阶段1 基础(本文件):仅暴露 `compress_via_llm` 函数,供阶段2 IPC
//! (`ai_chat_compress_context`)与阶段3 agentic loop 自动压缩共用。
//!
//! 模式严格对齐 [`super::title`] 的 `generate_title_via_llm`:非流式
//! `provider.complete()` + `LlmConcurrency` 双层 Semaphore 限流 + select_model_id
//! 路由(Lite 智力, 无 tool_use, 标准成本上限)兜底 `default_model`。失败返 `Err`,
//! 调用方按场景降级(手动压缩报错给用户;自动压缩降级为原裁剪行为)。

use df_ai::context_helpers::CompressedSummary;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// F-15: 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
use df_ai::router::{
    select_model_id, Modality, TaskRequirements,
};
use df_storage::models::AiProviderRecord;

use crate::state::LlmConcurrency;

use super::prompt::compress_prompt;

/// 调 LLM 非流式把 active 消息压缩为四段式摘要。
///
/// - 输入:`provider`(经 [`super::secret::build_provider_for`] 构造,含 keyring 解析)
///   + `provider_config`(取 `default_model` 兜底 + `model_configs` 路由池)
///   + `active_msgs`(待压缩的 active 消息,来自 [`ContextManager::compress_old_messages`]
///     的返回或阶段2 IPC 取全段 active)
///   + `lang`("zh"/"en",与系统提示词语言一致,影响 `compress_prompt` 选模板)
/// - 输出:`Result<String, String>` — `Ok` 为 LLM 生成的摘要文本(已 trim);
///   `Err` 为 provider 调用失败(底层 `anyhow::Error` 转 String,调用方降级不阻塞)。
///
/// 模型选择:`select_model_id(TaskRequirements{无 tool_use})`,
/// 池空/无匹配兜底 `default_model`(与 `generate_title_via_llm` 一致)。
///
/// 限流:复用 `LlmConcurrency` 双层 Semaphore(压缩属独立 LLM 调用,纳入全局 + 单对话限流,
/// 不与主 stream_llm 抢资源失控)。
///
/// 注意:本函数只负责"喂 LLM 出摘要文本",不改 ContextManager 状态(标 compressed +
/// 插摘要 system 由调用方做,职责分离,便于单元测试与多调用点复用)。
//
// 调用方:agentic/mod.rs:807(agentic loop 自动压缩)+ commands/chat.rs:1102
//        (ai_chat_compress_context IPC)。
pub(crate) async fn compress_via_llm(
    provider: &dyn LlmProvider,
    provider_config: &AiProviderRecord,
    active_msgs: Vec<ChatMessage>,
    lang: &str,
    conv_id: &str,
    llm_concurrency: &LlmConcurrency,
) -> Result<CompressedSummary, String> {
    if active_msgs.is_empty() {
        return Err("无可压缩消息(active 消息为空)".to_string());
    }
    // 阶段2(占位配对完整性):压缩段含未闭合占位(占位 tool_result 无对应 tool_call 头)→
    // 跳过该三元组不压缩,返 Err 让调用方降级(保留原状)。理由:压缩 LLM 摘要可能破坏占位
    // content/__PENDING__ 标记或拆散占位 result 与(已丢的)头的关系,使下游 sanitize 无法识别
    // 占位 → orphan 触发 400。保守跳过:含未闭合占位的段不压缩(降级走 build_for_request 裁剪
    // 或关键词兜底),占位配对完整性由 sanitize step3.5 + 出口断言兜住。
    //
    // 检测:占位 tool_result(content 带 __PENDING__ 标记)的 tool_call_id 不在任何 assistant
    // 头 tool_calls 内 → 未闭合占位 → 返 Err 跳过压缩。
    if compress_segment_has_unclosed_placeholder(&active_msgs) {
        return Err("压缩段含未闭合占位 tool_result,跳过压缩保留原状(防破坏占位配对致 400 orphan)".to_string());
    }
    // MED-1214(CR): 治毒——active_msgs 过 sanitize_messages(畸形 tool 配对自愈),
    // 防 compress 带毒(assistant tool_calls 缺 result / 孤儿 tool_result 无 tool_calls)触发 provider 400。
    // 同 build_for_request 语义:仅发送视图剔毒,不改 ContextManager 持久化。
    let active_count = active_msgs.len();
    let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs);

    // 路由选模型:无 tool_use(纯文本摘要)。
    // None(池空/无匹配)→ 兜底 default_model(行为不变)。
    let compress_req = TaskRequirements {
        modalities: vec![Modality::Text],
        needs_tool_use: false,
        estimated_context: 0,
        tier: None,
    };
    let model = select_model_id(&compress_req, &provider_config.model_configs)
        .unwrap_or_else(|| provider_config.default_model.clone());

    // 四段式 system prompt + active 消息原文,喂 complete() 非流式出摘要。
    let mut prompt = vec![ChatMessage::system(compress_prompt(lang))];
    prompt.extend(active_msgs);
    let request = CompletionRequest {
        model,
        messages: prompt,
        temperature: Some(0.2),
        max_tokens: Some(1024),
        stream: false,
        tools: None,
        tool_choice: None,
        reasoning_content: None,
    };

    // LLM 并发限流(压缩属独立调用,纳入双层 Semaphore)
    // F-09 B 批5: per_conv 改 HashMap<conv_id>,压缩在 loop 内针对本对话,用 conv_id 共享限流槽。
    let _global_permit = llm_concurrency.acquire_global().await;
    // compress-permit-try: per_conv 改非阻塞 try。loop 入口已持 1 个 per_conv permit,
    // set_per_conv(1) 后 per_conv Semaphore 仅 1 permit 被入口占满,此处阻塞 acquire 将永久等待
    // → is_compressing 永真 → 卡死。改 try_acquire_per_conv:拿不到(None)提前返 Err 降级
    // (maybe_auto_compress 走失败兜底分支:关键词摘要 + set_compressing(false),下轮再试),非崩溃。
    let _per_conv_permit = match llm_concurrency.try_acquire_per_conv(conv_id).await {
        Some(p) => p,
        None => {
            tracing::warn!(
                conv_id = %conv_id,
                active_count,
                "[ai] 压缩 per_conv permit 已耗尽(loop 入口占用),降级返回走兜底分支,下轮再试"
            );
            return Err(format!(
                "压缩 per_conv 并发限流槽已耗尽(active {} 条,loop 入口占用;降级走兜底,下轮再试)",
                active_count
            ));
        }
    };

    // F-15 卡死根治(2026-06-21 诊断):provider.complete 原无超时,LLM hang → agentic loop
    // (agentic/mod.rs:780)await 永不返 → run_agentic_loop 永挂 → generating 卡死
    // (set_compressing(true) 已置 agentic/mod.rs:753,false 永不到达)。
    // 加 60s timeout 兜底(对齐 ai_approve 超时):超时返 Err → 调用方 Err 分支
    // (agentic/mod.rs:818-830)set_compressing(false) 复位 + 关键词摘要降级,loop 正常继续不卡死。
    let resp = tokio::time::timeout(
        std::time::Duration::from_secs(60),
        provider.complete(request),
    )
    .await
    .map_err(|_| format!("LLM 压缩调用超时(60s,active {} 条,可能 provider hang;降级关键词裁剪)", active_count))?
    .map_err(|e| format!("LLM 压缩调用失败: {} (active {} 条,已 sanitize 治毒;若仍失败查 provider 原文 tool 配对)", e, active_count))?;
    let summary = clean_summary(&resp.text);
    if summary.is_empty() {
        return Err("LLM 压缩返回空摘要".to_string());
    }
    // T3: 解析四段式输出为 JSON 卡片 + NL 摘要
    // 从现有 ## Intent / ## Decisions / ## Files / ## Constraints 格式解析
    let json_card = parse_sections_to_json(&summary);
    Ok(CompressedSummary {
        json_card,
        nl_summary: summary,
    })
}

/// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。
///
/// 占位 tool_result(content 带 __PENDING__ 标记,审批挂起)若无对应 tool_call 头(头被裁/丢)
/// → 未闭合占位。压缩此类段会破坏占位配对(摘要改写 content / 拆散关系),使下游 sanitize
/// 无法识别占位 → orphan 触发 400。故调用方检测到未闭合占位即跳过压缩(返 Err 降级)。
///
/// 检测逻辑:收集段内所有 assistant 头的 tool_call.id;占位 tool_result 的 id 不在内 → 未闭合。
/// 已闭合占位(头在)可正常压缩(配对完整,摘要不影响头关系)。
fn compress_segment_has_unclosed_placeholder(msgs: &[ChatMessage]) -> bool {
    use std::collections::HashSet;
    let head_ids: HashSet<&str> = msgs
        .iter()
        .filter(|m| matches!(m.role, df_ai::provider::MessageRole::Assistant))
        .filter_map(|m| m.tool_calls.as_ref())
        .flatten()
        .filter_map(|c| {
            // 排除 TOOL_MISSING_PREFIX 占位头(非真实头,由出口断言自愈补的临时闭合头)
            if c.id.starts_with(df_ai::context_helpers::TOOL_MISSING_PREFIX) {
                None
            } else {
                Some(c.id.as_str())
            }
        })
        .collect();
    msgs.iter().any(|m| {
        matches!(m.role, df_ai::provider::MessageRole::Tool)
            && df_ai::context_helpers::is_pending_placeholder(&m.content)
            && m.tool_call_id
                .as_deref()
                .is_some_and(|id| !head_ids.contains(id))
    })
}

/// 从四段式 Markdown 摘要中解析出 JSON 卡片
/// 段标题: ## Intent / ## Decisions / ## Files / ## Constraints
fn parse_sections_to_json(summary: &str) -> String {
    let mut topics = Vec::new();
    let mut decisions = Vec::new();
    let mut key_files = Vec::new();

    let mut current_section: Option<&str> = None;
    for line in summary.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with("## ") {
            current_section = trimmed.strip_prefix("## ");
            continue;
        }
        let text = trimmed.trim_start_matches("- ").trim();
        if text.is_empty() {
            continue;
        }
        match current_section {
            Some("Intent") | Some("意图") => {
                topics.push(text.to_string());
            }
            Some("Decisions") | Some("决策") => {
                decisions.push(serde_json::json!({"summary": text}));
            }
            Some("Files") | Some("文件") => {
                key_files.push(serde_json::json!({"change": text}));
            }
            _ => {}
        }
    }

    serde_json::json!({
        "topics": topics,
        "decisions": decisions,
        "key_files": key_files,
        "parsed_from": "four_section_summary",
    }).to_string()
}

/// 在文本中提取标记间的内容
#[allow(dead_code)]
fn extract_between<'a>(text: &'a str, start_marker: &str, end_marker: &str) -> Option<&'a str> {
    let start = text.find(start_marker)?;
    let after_start = &text[start + start_marker.len()..];
    let end = after_start.find(end_marker)?;
    Some(after_start[..end].trim())
}

/// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回
/// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。
///
/// **退化检测(BUG-2026-07-07/P0-1)**:实测 9357c27c 会话 seq0 system 消息内容为
/// 同一短语重复 41 次的 1879 字符废文本(LLM 退化输出 / repetition degeneration)。
/// 原因:某些情况下 LLM(compress 用的 Standard 智力模型)会陷入重复循环,产出形如
/// "现在更新项目信息：...让我更新项目描述和技术栈。" × N 的退化输出,被当作合法摘要
/// 塞进 active system 消息,污染每一轮 prompt。此处检测后返空,调用方据空值报错降级
/// 走关键词摘要兜底(extract_keyword_summary),不让退化输出污染上下文。
fn clean_summary(raw: &str) -> String {
    let t = raw.trim();
    // 去掉可能的整体代码围栏(LLM 偶尔把整段包成 ```markdown ... ```)
    let t = t
        .strip_prefix("```markdown")
        .or_else(|| t.strip_prefix("```"))
        .unwrap_or(t)
        .trim_start_matches('\n');
    let t = t.strip_suffix("```").unwrap_or(t).trim();
    // 退化检测:若文本含同一片段重复 ≥5 次,判定为 LLM 退化输出,返空让调用方降级。
    // 阈值 5 容忍正常排比(如列表项),只拦死循环式拼接(实测 41 次远超阈值)。
    if is_degenerated_repetition(t) {
        tracing::warn!(
            len = t.len(),
            "[ai] 压缩摘要疑似 LLM 退化重复输出,丢弃降级走关键词兜底"
        );
        return String::new();
    }
    t.to_string()
}

/// 检测 LLM 退化重复输出:文本中是否存在某一长片段(≥8 字符)重复 ≥5 次。
///
/// 实测样本:`"我已经了解了项目结构。" + "现在更新项目信息：...让我更新项目描述和技术栈。" × 41`
/// 即「开头有引导句 + 后接同一片段重复 N 次」形态。
///
/// 检测策略:退化输出的重复片段在文本中点附近必然是周期性的,故取**文本中部**一段作候选,
/// 检查它在全文出现次数。取中点而非前缀,是因为退化输出常带不同前缀引导句
/// ("我已经了解了项目结构。"),前缀取样会漏检。
///
/// **UTF-8 安全**:中文字符占 3 字节,按字节切片会切断字符致 panic。改按 `char_indices`
/// 取字符边界,确保片段始终在完整字符边界上。
fn is_degenerated_repetition(text: &str) -> bool {
    if text.len() < 40 {
        return false; // 太短不可能退化
    }
    const MIN_FRAGMENT_CHARS: usize = 8; // 最少 8 个字符(非字节)
    const REPEAT_THRESHOLD: usize = 5;
    // 收集字符边界位置(每个字符起始字节偏移),用于安全切片
    let char_boundaries: Vec<usize> = std::iter::once(0)
        .chain(text.char_indices().map(|(i, _)| i))
        .collect();
    let char_count = char_boundaries.len() - 1;
    if char_count < MIN_FRAGMENT_CHARS * REPEAT_THRESHOLD {
        return false; // 字符太少不可能重复 5 次
    }
    // 从文本中点取样候选片段(退化输出中段必然是周期性重复)
    let mid_char = char_count / 2;
    // 候选片段长度(字符数):从 MIN_FRAGMENT_CHARS 到 40 逐步试
    let max_frag_chars = 40.min(char_count / REPEAT_THRESHOLD);
    for frag_chars in (MIN_FRAGMENT_CHARS..=max_frag_chars).rev() {
        // 取中点附近的 frag_chars 个字符作候选(按字符边界切片)
        let start_char = mid_char.saturating_sub(frag_chars / 2);
        let end_char = (start_char + frag_chars).min(char_count);
        let start_byte = char_boundaries[start_char];
        let end_byte = char_boundaries[end_char];
        let fragment = &text[start_byte..end_byte];
        if fragment.chars().count() < MIN_FRAGMENT_CHARS {
            continue;
        }
        // 用子串计数(不依赖正则,避免引入依赖)
        let count = text.matches(fragment).count();
        if count >= REPEAT_THRESHOLD {
            return true;
        }
    }
    false
}

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

    #[test]
    fn clean_summary_trims_and_strips_fences() {
        assert_eq!(clean_summary("  hello  "), "hello");
        assert_eq!(clean_summary("```markdown\n## 意图\nfoo\n```"), "## 意图\nfoo");
        assert_eq!(clean_summary("```\n## 决策\nbar\n```"), "## 决策\nbar");
        // 保留内部 markdown 结构(摘要本就是结构化)
        assert_eq!(clean_summary("## 意图\n- a\n- b"), "## 意图\n- a\n- b");
    }

    #[test]
    fn clean_summary_empty_stays_empty() {
        assert_eq!(clean_summary("   "), "");
        assert_eq!(clean_summary(""), "");
    }

    // ── BUG-2026-07-07/P0-1: LLM 退化重复输出检测 ──
    #[test]
    fn clean_summary_rejects_degenerated_repetition() {
        // 实测样本(9357c27c seq0):同一短语重复 41 次
        let fragment = "现在更新项目信息：基于代码分析，这是一个大型综合游戏平台后端。让我更新项目描述和技术栈。";
        let degenerated = format!("我已经了解了项目结构。{}", fragment.repeat(41));
        // 退化输出应返空(调用方据空值报错降级走关键词兜底)
        assert_eq!(clean_summary(&degenerated), "", "重复拼接的退化输出应被丢弃");
    }

    #[test]
    fn clean_summary_keeps_normal_summary_with_lists() {
        // 正常摘要(含列表项重复词)不应被误判为退化
        let normal = "## 意图\n- 整理文档\n## 决策\n- 使用方案A\n- 使用方案A\n- 使用方案A\n## 约束\n不超 100 字";
        assert_eq!(clean_summary(normal), normal, "正常摘要不应被误判");
    }

    #[test]
    fn is_degenerated_repetition_detects_5x_repeat() {
        // 5 次重复阈值边界测试
        let fragment = "这是一个测试片段abcdef";
        assert!(is_degenerated_repetition(&fragment.repeat(5)));
        assert!(is_degenerated_repetition(&fragment.repeat(41)));
    }

    #[test]
    fn is_degenerated_repetition_ignores_short_text() {
        // 短文本不触发(即使重复)
        assert!(!is_degenerated_repetition("短文本短文本短文本短文本"));
        assert!(!is_degenerated_repetition("ab"));
    }

    #[test]
    fn compress_prompt_returns_template_per_lang() {
        // 中文模板(默认)
        let zh = compress_prompt("zh");
        assert!(zh.contains("意图"));
        assert!(zh.contains("决策"));
        assert!(zh.contains("文件"));
        assert!(zh.contains("约束"));
        // 英文模板
        let en = compress_prompt("en");
        assert!(en.contains("Intent"));
        assert!(en.contains("Decisions"));
        assert!(en.contains("Files"));
        assert!(en.contains("Constraints"));
        // 未知语言降级中文
        assert_eq!(compress_prompt("fr"), compress_prompt("zh"));
    }

    // ── 阶段2 占位配对完整性:compress_segment_has_unclosed_placeholder ──

    #[test]
    fn unclosed_placeholder_detected_when_no_head() {
        // 占位 tool_result(带标记)无对应 tool_call 头 → 未闭合 → 应跳过压缩
        let msgs = vec![
            df_ai::provider::ChatMessage::user("问题"),
            df_ai::provider::ChatMessage::tool_result(
                "call_pending",
                "需要用户审批，等待确认__PENDING__:call_pending",
            ),
        ];
        assert!(
            compress_segment_has_unclosed_placeholder(&msgs),
            "无头的占位 tool_result 应判为未闭合占位"
        );
    }

    #[test]
    fn closed_placeholder_not_flagged() {
        // 占位 tool_result 有对应 tool_call 头 → 已闭合 → 不跳过(可正常压缩)
        let msgs = vec![
            df_ai::provider::ChatMessage::user("问题"),
            df_ai::provider::ChatMessage::assistant_with_tools(
                "调",
                vec![df_ai::provider::ToolCall::new(
                    "call_pending",
                    "write_file",
                    "{}",
                )],
            ),
            df_ai::provider::ChatMessage::tool_result(
                "call_pending",
                "需要用户审批，等待确认__PENDING__:call_pending",
            ),
        ];
        assert!(
            !compress_segment_has_unclosed_placeholder(&msgs),
            "有头的占位 tool_result 是已闭合,不应跳过压缩"
        );
    }

    #[test]
    fn non_placeholder_orphan_not_flagged() {
        // 普通 tool_result(非占位)无头 → 不算"未闭合占位"(普通 orphan 由 sanitize 处理,
        // 不触发压缩跳过;占位保护语义专属于审批挂起占位)
        let msgs = vec![
            df_ai::provider::ChatMessage::user("问题"),
            df_ai::provider::ChatMessage::tool_result("orphan_id", "普通结果"),
        ];
        assert!(
            !compress_segment_has_unclosed_placeholder(&msgs),
            "非占位 orphan 不应触发压缩跳过(仅占位专享保护)"
        );
    }

    #[test]
    fn no_tool_messages_not_flagged() {
        // 纯文本段(无 tool_result)→ 永不触发跳过
        let msgs = vec![
            df_ai::provider::ChatMessage::user("问题1"),
            df_ai::provider::ChatMessage::assistant("回答1"),
            df_ai::provider::ChatMessage::user("问题2"),
        ];
        assert!(!compress_segment_has_unclosed_placeholder(&msgs));
    }
}
