use serde::{Deserialize, Serialize};
use std::path::Path;

// ── 主节点：master CONVENTIONS.md 内容和版本（编译时嵌入）────────────────────
// CONVENTIONS v1.4.5：新增无 MCP 环境兼容章节（Qwen Code / 其他 AI）
const MASTER_CONVENTIONS: &str = include_str!("../../../.blueprint/CONVENTIONS.md");

/// 从 MASTER_CONVENTIONS frontmatter 自动解析版本号（内置基线版本，不随用户版变化）。
fn master_conventions_version() -> &'static str {
    static VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
    VERSION.get_or_init(|| {
        parse_frontmatter_version_str(MASTER_CONVENTIONS)
            .unwrap_or_else(|| "unknown".to_string())
    })
}

// ── 用户版 CONVENTIONS（运行时可写，优先级高于内置）────────────────────────────

/// app data dir 下的 conventions/ 子目录路径，由 init_user_conventions_dir 初始化。
static USER_CONVENTIONS_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();

/// effective 内容缓存（None = 未缓存，Some = 已缓存用户版；内置版不缓存，直接用常量）。
static USER_CONVENTIONS_CACHE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);

/// 由 lib.rs setup() 调用，初始化用户版存储目录。
pub fn init_user_conventions_dir(app_data_dir: std::path::PathBuf) {
    let dir = app_data_dir.join("conventions");
    std::fs::create_dir_all(&dir).ok();
    USER_CONVENTIONS_DIR.set(dir).ok();
}

fn user_conventions_path() -> Option<std::path::PathBuf> {
    USER_CONVENTIONS_DIR.get().map(|d| d.join("CONVENTIONS.md"))
}

/// 清除 effective 内容缓存，下次调用 effective_conventions_content() 时重新读文件。
pub(crate) fn invalidate_effective_cache() {
    if let Ok(mut cache) = USER_CONVENTIONS_CACHE.lock() {
        *cache = None;
    }
}

/// 返回当前生效的 CONVENTIONS 内容：用户版（app data）优先，fallback 编译时内置。
pub(crate) fn effective_conventions_content() -> String {
    // 先查缓存
    if let Ok(cache) = USER_CONVENTIONS_CACHE.lock() {
        if let Some(ref content) = *cache {
            return content.clone();
        }
    }
    // 尝试读用户版文件
    if let Some(path) = user_conventions_path() {
        if let Ok(content) = std::fs::read_to_string(&path) {
            if !content.is_empty() {
                if let Ok(mut cache) = USER_CONVENTIONS_CACHE.lock() {
                    *cache = Some(content.clone());
                }
                return content;
            }
        }
    }
    // fallback 内置
    MASTER_CONVENTIONS.to_string()
}

/// 返回当前生效的 CONVENTIONS 版本号。
pub(crate) fn effective_conventions_version() -> String {
    let content = effective_conventions_content();
    parse_frontmatter_version_str(&content)
        .unwrap_or_else(|| master_conventions_version().to_string())
}

/// 从字符串内容解析 frontmatter version 字段。
fn parse_frontmatter_version_str(content: &str) -> Option<String> {
    let mut lines = content.lines();
    // 必须以 --- 开头才进入 frontmatter 模式
    if lines.next()?.trim() != "---" { return None; }
    for line in lines {
        if line.trim() == "---" { break; } // 遇到关闭 --- 停止
        if let Some(v) = line.strip_prefix("version: ") {
            return Some(v.trim().to_string());
        }
    }
    None
}

/// AGENTS.md 蓝图管理区模板（跨工具单一来源，措辞工具无关），版本号使用当前生效的 CONVENTIONS 版本。
fn agents_managed_template() -> String {
    let ver = effective_conventions_version();
    format!(r#"<!-- BLUEPRINT_MANAGED_START version="{ver}" -->
## 项目蓝图

本项目使用 `.blueprint/` 目录维护项目全景架构图，驱动需求讨论和任务管理。
本节适用于任何 AI 编程工具（Claude / Qwen / Cursor / Copilot 等）。

### 对话开始时必须执行

1. 读取 `.blueprint/manifest.yaml` 了解项目全景和当前状态
2. 读取 `.blueprint/CONVENTIONS.md` 了解完整工作流规则

### 需求讨论 → 蓝图同步规则

| 用户说的 | AI 应做的 |
|----------|--------------|
| "我想加个 XX 功能" | 确认需求后：manifest 新增模块（status: concept），创建 `.blueprint/modules/<id>.md` |
| "XX 功能需要 A、B、C" | 在模块文件中拆分为任务卡 |
| "这个方案确定了" | 模块 status → planned，补充任务卡 files/acceptance |
| "开始做 XX" | 模块 status → in_progress |
| 没提蓝图但在讨论功能 | 主动提议更新蓝图 |

### 复杂度决策（讨论新功能 / 拆解需求时）

| complexity | 执行流程 |
|-----------|---------|
| S / M | 直接拆任务卡，确保每张卡有 `files` + `acceptance` + `complexity` → 标记 📋 |
| L（复杂系统设计） | 先做架构设计（Claude 用 `/architect` 技能，其他工具按 CONVENTIONS「复杂功能分析流程」章节执行）输出设计文档，用户确认后拆成 S/M 任务卡 |

> L 级任务禁止直接写代码，必须先经过 架构设计 → 任务拆解 流程。

### 任务执行两阶段提交

- **开始写代码前**：任务卡前缀改为 🔵，status → in_progress
- **完成验收后**：任务卡前缀改为 ✅，status → done，更新 manifest `updated` 日期

完整规则（迭代管理、阻塞处理、复盘笔记等）详见 `.blueprint/CONVENTIONS.md`。
<!-- BLUEPRINT_MANAGED_END -->"#)
}

/// CLAUDE.md 蓝图管理区模板：薄引用，指向 AGENTS.md 单一来源。
/// 保留 version marker 使 get_blueprint_status 的版本检测继续工作。
fn claude_managed_template() -> String {
    let ver = effective_conventions_version();
    format!(r#"<!-- BLUEPRINT_MANAGED_START version="{ver}" -->
## 项目蓝图

蓝图工作流规则统一维护在 @AGENTS.md 的 BLUEPRINT_MANAGED 区（跨工具单一来源，勿在此复制）。

对话开始时必须执行：
1. 读取 `.blueprint/manifest.yaml` 了解项目全景和当前状态
2. 读取 `.blueprint/CONVENTIONS.md` 了解完整工作流规则
3. 遵循 AGENTS.md 中的蓝图工作流（需求→蓝图同步、复杂度决策、两阶段提交）
<!-- BLUEPRINT_MANAGED_END -->"#)
}

// ── manifest.yaml 对应结构 ───────────────────────────────────────────────────

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BlueprintManifest {
    pub version: u32,
    pub name: String,
    pub description: Option<String>,
    pub updated: Option<String>,
    /// 当前迭代号（默认 1，重构已完成模块时手动递增）
    pub iteration: Option<u32>,
    #[serde(default)]
    pub areas: Vec<BlueprintArea>,
    #[serde(default)]
    pub modules: Vec<BlueprintModule>,
    #[serde(default)]
    pub edges: Vec<BlueprintEdge>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BlueprintArea {
    pub id: String,
    pub name: String,
    pub color: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BlueprintModule {
    pub id: String,
    pub name: String,
    pub area: String,
    pub status: String,
    #[serde(default)]
    pub progress: u32,
    #[serde(default)]
    pub position: Vec<f64>,
    // 运行时填充：从 modules/<id>.md 解析
    #[serde(skip_deserializing, default)]
    pub description: Option<String>,
    #[serde(skip_deserializing, default)]
    pub decisions: Vec<String>,
    #[serde(skip_deserializing, default)]
    pub tasks: Vec<BlueprintTask>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BlueprintEdge {
    pub from: String,
    pub to: String,
    #[serde(rename = "type")]
    pub edge_type: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BlueprintTask {
    pub title: String,
    pub status: String,
    pub complexity: Option<String>,
    pub files: Option<String>,
    pub depends: Option<String>,
    pub acceptance: Option<String>,
    pub notes: Option<String>,
    pub blocked_reason: Option<String>,
    #[serde(default)]
    pub locked: bool, // depends 引用的模块尚未完成
}

// ── 完整蓝图返回结构 ─────────────────────────────────────────────────────────

#[derive(Debug, Serialize, Clone)]
pub struct BlueprintData {
    pub manifest: BlueprintManifest,
    pub stats: BlueprintStats,
}

#[derive(Debug, Serialize, Clone)]
pub struct BlueprintStats {
    pub total_modules: u32,
    pub done: u32,
    pub in_progress: u32,
    pub planned: u32,
    pub concept: u32,
    pub blocked: u32,
    pub total_tasks: u32,
    pub tasks_done: u32,
    pub tasks_blocked: u32,
    pub tasks_locked: u32,
    pub dispatchable: u32,
}

// ── tauri command ────────────────────────────────────────────────────────────

#[tauri::command]
pub fn get_blueprint(project_path: String) -> Result<Option<BlueprintData>, String> {
    let bp_dir = Path::new(&project_path).join(".blueprint");
    let manifest_path = bp_dir.join("manifest.yaml");

    if !manifest_path.exists() {
        return Ok(None);
    }

    let yaml_str = std::fs::read_to_string(&manifest_path)
        .map_err(|e| format!("读取 manifest.yaml 失败: {e}"))?;
    let mut manifest: BlueprintManifest = serde_yaml::from_str(&yaml_str)
        .map_err(|e| format!("解析 manifest.yaml 失败: {e}"))?;

    // 读取每个模块的 markdown 文件
    let modules_dir = bp_dir.join("modules");
    for module in &mut manifest.modules {
        let md_path = modules_dir.join(format!("{}.md", module.id));
        if md_path.exists() {
            let content = std::fs::read_to_string(&md_path).unwrap_or_default();
            let parsed = parse_module_md(&content);
            module.description = parsed.description;
            module.decisions = parsed.decisions;
            module.tasks = parsed.tasks;

            // 自动计算 progress：有任务卡时按完成比例，无任务卡保留 manifest 中的值
            if !module.tasks.is_empty() {
                let done_count = module.tasks.iter().filter(|t| t.status == "done").count();
                module.progress = ((done_count as f64 / module.tasks.len() as f64) * 100.0).round() as u32;
            }
        }
    }

    // 依赖检查：构建模块状态表，标记 locked 任务
    let module_status: std::collections::HashMap<String, String> = manifest
        .modules
        .iter()
        .map(|m| (m.id.clone(), m.status.clone()))
        .collect();

    for module in &mut manifest.modules {
        for task in &mut module.tasks {
            if task.status == "done" || task.status == "blocked" {
                continue; // 已完成或已阻塞的任务不需要检查
            }
            if let Some(ref depends) = task.depends.clone() {
                let unmet = depends
                    .split(',')
                    .map(|s| s.trim())
                    .filter(|id| !id.is_empty())
                    .any(|id| {
                        module_status.get(id).map(|s| s.as_str()) != Some("done")
                    });
                task.locked = unmet;
            }
        }
    }

    // 统计
    let stats = compute_stats(&manifest);

    // 飞轮快照（每日一次，静默写入，不影响主流程）
    append_usage_snapshot(&bp_dir, &manifest, &stats);

    Ok(Some(BlueprintData { manifest, stats }))
}

// ── markdown 解析 ────────────────────────────────────────────────────────────

struct ParsedModule {
    description: Option<String>,
    decisions: Vec<String>,
    tasks: Vec<BlueprintTask>,
}

fn parse_module_md(content: &str) -> ParsedModule {
    let mut tasks = Vec::new();
    let mut description = None;
    let mut decisions: Vec<String> = Vec::new();

    let body = skip_frontmatter(content);

    #[derive(PartialEq)]
    enum Section { None, Overview, Decisions, Tasks }
    let mut section = Section::None;
    let mut overview_lines: Vec<&str> = Vec::new();
    let mut current_task: Option<BlueprintTask> = None;

    for line in body.lines() {
        // 二级标题切换段落
        if line.starts_with("## ") {
            if let Some(task) = current_task.take() {
                tasks.push(task);
            }
            let heading = line.trim_start_matches("## ").trim();
            section = match heading {
                "概述" => Section::Overview,
                "决策记录" => Section::Decisions,
                "任务卡" => Section::Tasks,
                _ => Section::None,
            };
            continue;
        }

        // 三级标题 = 任务卡（仅在任务卡段落内）
        if line.starts_with("### ") && (section == Section::Tasks || section == Section::None) {
            section = Section::Tasks;
            if let Some(task) = current_task.take() {
                tasks.push(task);
            }
            let title_raw = line.trim_start_matches("### ").trim();
            let (status, title) = parse_task_title(title_raw);
            current_task = Some(BlueprintTask {
                title: title.to_string(),
                status,
                complexity: None,
                files: None,
                depends: None,
                acceptance: None,
                notes: None,
                blocked_reason: None,
                locked: false,
            });
            continue;
        }

        match section {
            Section::Overview => {
                let trimmed = line.trim();
                if !trimmed.is_empty() {
                    overview_lines.push(trimmed);
                }
            }
            Section::Decisions => {
                let trimmed = line.trim();
                // 收集 "- xxx" 格式的决策条目
                if let Some(item) = trimmed.strip_prefix("- ") {
                    if !item.is_empty() {
                        decisions.push(item.to_string());
                    }
                }
            }
            Section::Tasks => {
                if let Some(ref mut task) = current_task {
                    let trimmed = line.trim().trim_start_matches("- ");
                    if let Some(val) = trimmed.strip_prefix("status: ") {
                        task.status = val.trim().to_string();
                    } else if let Some(val) = trimmed.strip_prefix("complexity: ") {
                        task.complexity = Some(val.trim().to_string());
                    } else if let Some(val) = trimmed.strip_prefix("files: ") {
                        task.files = Some(val.trim().to_string());
                    } else if let Some(val) = trimmed.strip_prefix("depends: ") {
                        task.depends = Some(val.trim().to_string());
                    } else if let Some(val) = trimmed.strip_prefix("acceptance: ") {
                        task.acceptance = Some(val.trim().to_string());
                    } else if let Some(val) = trimmed.strip_prefix("notes: ") {
                        task.notes = Some(val.trim().to_string());
                    } else if let Some(val) = trimmed.strip_prefix("blocked_reason: ") {
                        task.blocked_reason = Some(val.trim().to_string());
                    }
                }
            }
            Section::None => {}
        }
    }

    if let Some(task) = current_task {
        tasks.push(task);
    }

    if !overview_lines.is_empty() {
        description = Some(overview_lines.join(" "));
    }

    ParsedModule { description, decisions, tasks }
}

fn skip_frontmatter(content: &str) -> &str {
    if content.starts_with("---") {
        if let Some(end) = content[3..].find("\n---") {
            let skip = 3 + end + 4; // "---" + content + "\n---"
            return content.get(skip..).unwrap_or("").trim_start_matches('\n');
        }
    }
    content
}

fn parse_task_title(raw: &str) -> (String, &str) {
    // "✅ 基础操作" → ("done", "基础操作")
    if let Some(rest) = raw.strip_prefix("✅ ") {
        return ("done".to_string(), rest);
    }
    if let Some(rest) = raw.strip_prefix("🔵 ") {
        return ("in_progress".to_string(), rest);
    }
    if let Some(rest) = raw.strip_prefix("📋 ") {
        return ("todo".to_string(), rest);
    }
    if let Some(rest) = raw.strip_prefix("💭 ") {
        return ("concept".to_string(), rest);
    }
    if let Some(rest) = raw.strip_prefix("🔴 ") {
        return ("blocked".to_string(), rest);
    }
    ("todo".to_string(), raw)
}

// ── generate_blueprint_prompt ────────────────────────────────────────────────

#[tauri::command]
pub fn generate_blueprint_prompt(
    project_path: String,
    prompt_type: String, // "init" | "sync"
) -> Result<String, String> {
    // 去除尾部斜杠，避免 file_name() 返回 None
    let path_str = project_path.trim_end_matches(['/', '\\']).to_string();
    let root = Path::new(&path_str);

    // 基础信息
    let project_name = root
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("unknown")
        .to_string();

    let dir_tree = scan_dir_tree(root, 0, 3);
    let tech_stack = detect_tech_stack(root);
    let config_files = read_config_files(root);

    let is_monorepo = root.join("pnpm-workspace.yaml").exists()
        || root.join("lerna.json").exists()
        || root.join("turbo.json").exists();

    match prompt_type.as_str() {
        "init" => {
            let git_summary = get_git_summary(root);
            // monorepo：补充子包 package.json
            let subpackages = if is_monorepo {
                read_monorepo_subpackages(root)
            } else {
                String::new()
            };
            let full_config = if subpackages.is_empty() {
                config_files
            } else {
                format!("{config_files}\n\n## 子包配置\n{subpackages}")
            };
            Ok(build_init_prompt(&project_name, &tech_stack, &dir_tree, &full_config, &git_summary, is_monorepo))
        }
        "sync" => {
            let git_log = get_git_log(root);
            let blueprint_summary = read_blueprint_summary(root);
            Ok(build_sync_prompt(&project_name, &tech_stack, &dir_tree, &config_files, &git_log, &blueprint_summary))
        }
        _ => Err(format!("未知 prompt_type: {prompt_type}，应为 init 或 sync")),
    }
}

/// 通过文件存在性检测技术栈（不读 AI 文档内容，避免被旧描述干扰）
fn detect_tech_stack(root: &Path) -> String {
    let mut found: std::collections::HashSet<&str> = std::collections::HashSet::new();

    // 对给定目录做一次检测，结果写入 found
    let mut scan_dir = |dir: &Path| {
        let cfgs = ["js", "ts", "mjs", "cjs"];
        let has_cfg = |name: &str| cfgs.iter().any(|ext| dir.join(format!("{name}.{ext}")).exists());

        if has_cfg("next.config")     { found.insert("Next.js"); }
        if has_cfg("nuxt.config")     { found.insert("Nuxt"); }
        if has_cfg("svelte.config")   { found.insert("SvelteKit"); }
        if has_cfg("remix.config")    { found.insert("Remix"); }
        if has_cfg("astro.config")    { found.insert("Astro"); }
        if has_cfg("vite.config")     { found.insert("Vite"); }
        if has_cfg("webpack.config")  { found.insert("Webpack"); }
        if has_cfg("tailwind.config") { found.insert("TailwindCSS"); }

        if dir.join("tsconfig.json").exists() || dir.join("tsconfig.base.json").exists() {
            found.insert("TypeScript");
        }
        if dir.join("Cargo.toml").exists()            { found.insert("Rust"); }
        if dir.join("go.mod").exists()                { found.insert("Go"); }
        if dir.join("pyproject.toml").exists()
            || dir.join("requirements.txt").exists()  { found.insert("Python"); }
        if dir.join("pom.xml").exists()               { found.insert("Java/Maven"); }
        if dir.join("build.gradle").exists()
            || dir.join("build.gradle.kts").exists()  { found.insert("Java/Gradle"); }
        if dir.join("prisma").join("schema.prisma").exists() { found.insert("Prisma"); }
        let drizzle = ["drizzle.config.ts", "drizzle.config.js", "drizzle.config.mjs"];
        if drizzle.iter().any(|f| dir.join(f).exists()) { found.insert("Drizzle"); }

        // 读 package.json 的 dependencies 字段（仅用于子应用补充框架识别）
        let pkg = dir.join("package.json");
        if pkg.exists() {
            if let Ok(content) = std::fs::read_to_string(&pkg) {
                for (kw, label) in &[("\"vue\"", "Vue"), ("\"react\"", "React"),
                                     ("\"hono\"", "Hono"), ("\"fastify\"", "Fastify"),
                                     ("\"express\"", "Express"), ("\"nestjs\"", "NestJS"),
                                     ("\"@nestjs/core\"", "NestJS")] {
                    if content.contains(kw) { found.insert(label); }
                }
            }
        }
    };

    // ── 包管理器 / monorepo ──────────────────────────────────────────────────
    let mut hints: Vec<String> = Vec::new();
    let is_monorepo = root.join("pnpm-workspace.yaml").exists();
    if is_monorepo                                { hints.push("pnpm monorepo".into()); }
    else if root.join("pnpm-lock.yaml").exists()  { hints.push("pnpm".into()); }
    else if root.join("bun.lockb").exists() || root.join("bun.lock").exists() { hints.push("Bun".into()); }
    else if root.join("yarn.lock").exists()        { hints.push("Yarn".into()); }
    if root.join("turbo.json").exists()            { hints.push("Turborepo".into()); }

    // ── 桌面 ──────────────────────────────────────────────────────────────────
    if root.join("src-tauri").join("Cargo.toml").exists() { hints.push("Tauri".into()); }
    if root.join("electron-builder.json").exists()
        || root.join("electron-builder.yml").exists()     { hints.push("Electron".into()); }

    // ── 根目录扫描 ────────────────────────────────────────────────────────────
    scan_dir(root);

    // ── monorepo：扫描 apps/* 和 packages/* 一级子目录 ────────────────────────
    for workspace_dir in &["apps", "packages"] {
        let ws = root.join(workspace_dir);
        if let Ok(entries) = std::fs::read_dir(&ws) {
            for entry in entries.flatten() {
                if entry.path().is_dir() {
                    scan_dir(&entry.path());
                }
            }
        }
    }

    // ── 源码文件扩展名抽样（检测 Vue / React / Svelte）────────────────────────
    detect_ui_from_src(root, &mut found);

    hints.extend(found.into_iter().map(|s| s.to_string()));
    hints.sort();
    if hints.is_empty() { "未知".to_string() } else { hints.join(", ") }
}

/// 抽样 src/ 目录（及 apps/*/src/）判断 UI 框架
fn detect_ui_from_src(root: &Path, found: &mut std::collections::HashSet<&'static str>) {
    let src_dirs: Vec<std::path::PathBuf> = {
        let mut dirs = vec![root.join("src")];
        // monorepo apps
        if let Ok(entries) = std::fs::read_dir(root.join("apps")) {
            for e in entries.flatten() {
                dirs.push(e.path().join("src"));
            }
        }
        dirs
    };

    for src in src_dirs {
        if !src.is_dir() { continue; }
        if let Ok(entries) = std::fs::read_dir(&src) {
            for entry in entries.flatten().take(40) {
                let name = entry.file_name();
                let name_str = name.to_string_lossy();
                if name_str.ends_with(".vue")     { found.insert("Vue"); }
                if name_str.ends_with(".tsx") || name_str.ends_with(".jsx") { found.insert("React"); }
                if name_str.ends_with(".svelte")  { found.insert("Svelte"); }
            }
        }
    }
}

/// 读取项目配置文件（package.json / Cargo.toml / AI文档 等）
/// 所有候选文件按「存在则读，不存在跳过」策略处理，对简单项目零影响。
fn read_config_files(root: &Path) -> String {
    let mut parts = Vec::new();

    let candidates: &[(&str, &str, usize)] = &[
        // 核心配置
        ("README.md",        "README.md",        80),
        ("package.json",     "package.json",     60),
        ("Cargo.toml",       "Cargo.toml",       50),
        ("go.mod",           "go.mod",           30),
        ("pyproject.toml",   "pyproject.toml",   40),
        ("requirements.txt", "requirements.txt", 30),
        ("pom.xml",          "pom.xml",          30),
        // AI 上下文文档（存在才读；简单项目通常无此类文件）
        ("ARCHITECTURE.md",  "ARCHITECTURE.md",  120),
        ("CLAUDE.md",        "CLAUDE.md",         60),
        ("AGENTS.md",        "AGENTS.md",         60),
        ("DEVELOPMENT.md",   "DEVELOPMENT.md",    50),
    ];

    for (filename, label, max_lines) in candidates {
        let content = read_file_short(root, filename, *max_lines);
        if !content.is_empty() {
            let ext = filename.rsplit('.').next().unwrap_or("txt");
            let lang = match ext { "json" => "json", "toml" => "toml", "xml" => "xml", _ => "" };
            // 用 ~~~ 围栏，避免文件内容含 ``` 时提前关闭代码块
            parts.push(format!("### {label}\n~~~{lang}\n{content}\n~~~"));
        }
    }

    if parts.is_empty() { "（无可读配置文件）".to_string() } else { parts.join("\n\n") }
}

/// monorepo 子包 package.json 读取（最多 apps 前4个 + packages 前2个）
/// 仅在检测到 pnpm-workspace.yaml 时调用，对普通项目零影响。
fn read_monorepo_subpackages(root: &Path) -> String {
    let mut parts: Vec<String> = Vec::new();

    for (workspace_dir, limit) in &[("apps", 4usize), ("packages", 2)] {
        let ws = root.join(workspace_dir);
        if !ws.is_dir() { continue; }
        let Ok(entries) = std::fs::read_dir(&ws) else { continue };

        let mut dirs: Vec<_> = entries.flatten()
            .filter(|e| e.path().is_dir())
            .collect();
        dirs.sort_by_key(|e| e.file_name());

        for entry in dirs.iter().take(*limit) {
            let content = read_file_short(&entry.path(), "package.json", 40);
            if content.is_empty() { continue; }
            let label = format!("{}/{}/package.json", workspace_dir, entry.file_name().to_string_lossy());
            parts.push(format!("### {label}\n~~~json\n{content}\n~~~"));
            if parts.len() >= 6 { return parts.join("\n\n"); }
        }
    }

    parts.join("\n\n")
}


fn scan_dir_tree(dir: &Path, depth: usize, max_depth: usize) -> String {
    const SKIP: &[&str] = &[
        "node_modules", ".git", "target", "dist", ".next", ".nuxt",
        "build", "__pycache__", ".venv", "venv", ".cache", "out",
        ".turbo", "coverage",
    ];
    let indent = "  ".repeat(depth);
    let mut lines = Vec::new();

    let Ok(entries) = std::fs::read_dir(dir) else { return String::new() };

    let mut sorted: Vec<_> = entries.flatten().collect();
    sorted.sort_by_key(|e| e.file_name());

    for entry in sorted.iter().take(30) {
        let name = entry.file_name();
        let name_str = name.to_string_lossy();
        if name_str.starts_with('.') && name_str != ".blueprint" { continue; }
        if SKIP.iter().any(|s| *s == name_str.as_ref()) { continue; }
        // 过滤 Vite 时间戳缓存文件（如 vite.config.ts.timestamp-xxx.mjs）
        if name_str.contains(".timestamp-") { continue; }

        let path = entry.path();
        if path.is_dir() {
            lines.push(format!("{indent}{name_str}/"));
            if depth < max_depth {
                let sub = scan_dir_tree(&path, depth + 1, max_depth);
                if !sub.is_empty() { lines.push(sub); }
            }
        } else {
            lines.push(format!("{indent}{name_str}"));
        }
    }

    lines.join("\n")
}

fn read_file_short(root: &Path, filename: &str, max_lines: usize) -> String {
    let path = root.join(filename);
    if !path.exists() { return String::new(); }
    let Ok(content) = std::fs::read_to_string(&path) else { return String::new() };
    let lines: Vec<&str> = content.lines().take(max_lines).collect();
    lines.join("\n")
}

fn get_git_log(root: &Path) -> String {
    let output = crate::silent_cmd("git")
        .args(["log", "--oneline", "-20"])
        .current_dir(root)
        .output();
    match output {
        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
        _ => "（无法读取 git log，可能不是 git 仓库）".to_string(),
    }
}

/// init 用的 git 摘要：总提交数 + 最近 15 条
fn get_git_summary(root: &Path) -> String {
    let run = |args: &[&str]| {
        crate::silent_cmd("git")
            .args(args)
            .current_dir(root)
            .output()
            .ok()
            .filter(|o| o.status.success())
            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
    };

    let count = run(&["rev-list", "--count", "HEAD"]).unwrap_or_default();
    let log   = run(&["log", "--oneline", "-15"]).unwrap_or_default();

    if count.is_empty() && log.is_empty() {
        return "（无 git 仓库或暂无提交）".to_string();
    }

    let mut parts = Vec::new();
    if !count.is_empty() { parts.push(format!("总提交数: {count}")); }
    if !log.is_empty()   { parts.push(format!("最近 15 条:\n{log}")); }
    parts.join("\n")
}

fn read_blueprint_summary(root: &Path) -> String {
    let bp_dir = root.join(".blueprint");
    if !bp_dir.exists() { return "（无现有蓝图）".to_string(); }

    let mut parts = Vec::new();

    // manifest
    let manifest_path = bp_dir.join("manifest.yaml");
    if let Ok(content) = std::fs::read_to_string(&manifest_path) {
        parts.push(format!("=== manifest.yaml ===\n{content}"));
    }

    // modules
    let modules_dir = bp_dir.join("modules");
    if let Ok(entries) = std::fs::read_dir(&modules_dir) {
        let mut module_files: Vec<_> = entries.flatten().collect();
        module_files.sort_by_key(|e| e.file_name());
        for entry in module_files {
            let name = entry.file_name();
            let name_str = name.to_string_lossy();
            if name_str.ends_with(".md") {
                if let Ok(content) = std::fs::read_to_string(entry.path()) {
                    parts.push(format!("=== modules/{name_str} ===\n{content}"));
                }
            }
        }
    }

    if parts.is_empty() { "（蓝图目录为空）".to_string() } else { parts.join("\n\n") }
}

/// manifest.yaml 硬约束骨架——就近、简短、高亮，防止子项目 AI 凭感觉或沿用非法旧格式。
/// （CONVENTIONS 全文 900+ 行，manifest 规范埋在中间，实测会被忽略——noteapp 事故）
fn manifest_schema_hint() -> &'static str {
    r##"## ⚠️ manifest.yaml 严格格式（解析器强校验，违反则蓝图无法识别）

```yaml
version: 1
name: 项目名
iteration: 1
updated: YYYY-MM-DD

areas:                      # 至少定义一个
  - id: frontend
    name: 前端
    color: "#3B82F6"

modules:
  - id: kebab-case-id       # 必填，唯一
    name: 显示名称           # 必填
    area: frontend          # 必填！必须是上方 areas 中已定义的 id
    status: done            # 必填，只允许：done | in_progress | planned | concept | blocked | abandoned
    position: [0, 0]        # 可选

edges:                      # 可选
  - from: module-a
    to: module-b
    type: dependency        # 只允许 dependency | related
```

硬性规定：
- 禁止自造字段与取值（如 `project:`、`stable`、`in-progress`、`depends_on:` 均非法）——依赖关系写入顶层 edges
- **若项目中已存在不符合此格式的 manifest.yaml，必须重写为合法格式**（保留其中的有效信息），不要沿用旧结构"##
}

fn build_init_prompt(
    project_name: &str,
    tech_stack: &str,
    dir_tree: &str,
    config_files: &str,
    git_summary: &str,
    is_monorepo: bool,
) -> String {
    let module_hint = if is_monorepo {
        "建议 10-25 个（monorepo 项目请以**业务能力**为单位划分，不要以 app 目录为单位，否则粒度太粗、任务卡无法可派发）"
    } else {
        "建议 5-15 个"
    };

    let monorepo_note = if is_monorepo {
        "\n> ⚠️ **monorepo 项目**：请以**业务能力**（如「用户认证」「某业务线数据管理」「数据层」）而非 app 目录为单位划分模块。每个 app 可能涉及多个业务模块，每个业务模块也可能跨 app 和 packages。\n"
    } else {
        ""
    };

    format!(r#"你是一个软件架构师。请基于以下项目信息，从零初始化这个项目的 .blueprint/ 蓝图。
{monorepo_note}
## 项目信息
- 名称: {project_name}
- 技术栈: {tech_stack}

## 目录结构
```
{dir_tree}
```

## 项目配置文件
{config_files}

## Git 提交摘要
```
{git_summary}
```

## 蓝图规则（CONVENTIONS.md 完整内容）
以下是本蓝图系统的格式规范，请严格遵循：

{conventions}

{schema_hint}

## 你的任务
1. 识别项目的主要功能模块（{module_hint}），合理划分 area（前端/后端/基础设施等）
2. 创建 `.blueprint/manifest.yaml`（含 areas、modules、edges）
3. 为每个模块创建 `.blueprint/modules/<id>.md`（含概述 + 任务卡，每张任务卡必须包含 files、acceptance、complexity 字段）
4. 已实现的功能标记 ✅ done，未实现的标记 📋 todo 或 💭 concept
5. **检查测试基建**：查看 package.json 是否有 test 脚本、是否已安装 vitest/jest。若缺失，额外创建一张 📋「补测试基建」任务卡（complexity: S），acceptance 为「安装 vitest，配 vitest.config.ts，package.json 加 test 脚本，跑一个 smoke test 全绿」。Rust 项目同理，检查是否有 #[cfg(test)] 测试
6. 完成后更新 manifest.yaml 的 updated 日期为今天

请直接输出需要创建的文件内容。
"#,
        conventions = effective_conventions_content(),
        schema_hint = manifest_schema_hint(),
    )
}

fn build_sync_prompt(
    project_name: &str,
    tech_stack: &str,
    dir_tree: &str,
    config_files: &str,
    git_log: &str,
    blueprint_summary: &str,
) -> String {
    // 检测关键输入是否缺失，生成上下文感知的提示段落
    let no_blueprint = blueprint_summary.contains("无现有蓝图") || blueprint_summary.trim().is_empty();
    let no_git = git_log.contains("无法读取") || git_log.trim().is_empty();

    let context_note = match (no_blueprint, no_git) {
        (true, true) => "⚠️ 当前项目**尚无蓝图**且**无法读取 git 历史**。请根据目录结构和配置文件从零初始化蓝图，识别主要功能模块（建议 5-15 个），合理划分 area，为每个模块创建 modules/<id>.md。",
        (true, false) => "⚠️ 当前项目**尚无蓝图**。请根据目录结构、配置文件和 git 历史从零初始化蓝图，识别主要功能模块（建议 5-15 个），合理划分 area，已有代码的功能标记 ✅ done。",
        (false, true) => "⚠️ **无法读取 git 历史**，跳过「对照提交标记完成」步骤，仅根据目录结构与现有蓝图的差异补充缺失模块和任务卡。",
        (false, false) => "",
    };

    let tasks = if no_blueprint {
        // 无蓝图：退化为 init 风格的任务列表
        "1. 识别项目的主要功能模块（建议 5-15 个），合理划分 area（前端/后端/基础设施等）\n\
         2. 创建 `.blueprint/manifest.yaml`（含 areas、modules、edges）\n\
         3. 为每个模块创建 `.blueprint/modules/<id>.md`（含概述 + 任务卡，每张任务卡必须包含 files、acceptance、complexity 字段）\n\
         4. 已有代码的功能标记 ✅ done，规划中的标记 📋 todo 或 💭 concept\n\
         5. 检查测试基建：若缺失测试框架，额外创建一张 📋「补测试基建」任务卡（complexity: S）\n\
         6. 更新 manifest.yaml 的 updated 日期为今天"
    } else if no_git {
        // 有蓝图但无 git：只做结构对比，不做提交归因
        "1. （跳过 git 对照，无法读取提交历史）\n\
         2. 发现目录中存在但蓝图缺失的功能模块 → 补充对应模块和任务卡\n\
         3. 将目录中已消失的功能标记为 abandoned 或删除对应模块\n\
         4. 更新模块的 status 和 progress 使其与任务卡状态一致\n\
         5. 更新 manifest.yaml 的 updated 日期为今天\n\n\
         ⚠️ 状态保护规则：任务卡中 🔵 in_progress 状态由炼境根据 git 提交自动标记，同步时请保留，不要降回 📋 todo。"
    } else {
        // 正常 sync：有蓝图 + 有 git
        "1. 对照 git 提交记录，找出已完成但蓝图未标记的功能 → 更新对应任务卡为 ✅ done\n\
         2. 发现代码/目录中存在但蓝图缺失的功能模块 → 补充对应模块和任务卡\n\
         3. 将不再存在的功能标记为 abandoned 或删除\n\
         4. 更新模块的 status 和 progress 使其与任务卡状态一致\n\
         5. 更新 manifest.yaml 的 updated 日期为今天\n\n\
         ⚠️ 状态保护规则：任务卡中 🔵 in_progress 状态由炼境根据 git 提交自动标记，代表该任务的关联文件已有实际改动。\
         同步时请将其视为有效进度信号——可根据代码完成度升为 ✅ done，但无充分理由不要降回 📋 todo。"
    };

    let output_hint = if no_blueprint {
        "请直接输出需要创建的文件内容。"
    } else {
        "请直接输出需要修改的文件内容（只输出有变化的文件）。"
    };

    format!(r#"你是一个软件架构师。请根据项目最新状态，同步更新这个项目的 .blueprint/ 蓝图，使其准确反映代码现实。
{context_note_section}
## 项目信息
- 名称: {project_name}
- 技术栈: {tech_stack}

## 目录结构
```
{dir_tree}
```

## 项目配置文件
{config_files}

## 近期 Git 提交（最近 20 条）
```
{git_log}
```

## 现有蓝图内容
{blueprint_summary}

## 蓝图规则（CONVENTIONS.md 完整内容）
以下是本蓝图系统的格式规范，请严格遵循：

{conventions}

{schema_hint}

## 你的任务
{tasks}

{output_hint}
"#,
        context_note_section = if context_note.is_empty() {
            "\n".to_string()
        } else {
            format!("\n> {context_note}\n\n")
        },
        project_name = project_name,
        tech_stack   = tech_stack,
        dir_tree     = dir_tree,
        config_files = config_files,
        git_log      = git_log,
        blueprint_summary = blueprint_summary,
        tasks        = tasks,
        output_hint  = output_hint,
        conventions  = effective_conventions_content(),
        schema_hint  = manifest_schema_hint(),
    )
}

fn compute_stats(manifest: &BlueprintManifest) -> BlueprintStats {
    let mut stats = BlueprintStats {
        total_modules: manifest.modules.len() as u32,
        done: 0,
        in_progress: 0,
        planned: 0,
        concept: 0,
        blocked: 0,
        total_tasks: 0,
        tasks_done: 0,
        tasks_blocked: 0,
        tasks_locked: 0,
        dispatchable: 0,
    };

    for m in &manifest.modules {
        match m.status.as_str() {
            "done" => stats.done += 1,
            "in_progress" => stats.in_progress += 1,
            "planned" => stats.planned += 1,
            "concept" => stats.concept += 1,
            "blocked" => stats.blocked += 1,
            _ => {}
        }
        for t in &m.tasks {
            stats.total_tasks += 1;
            if t.status == "done" {
                stats.tasks_done += 1;
            }
            if t.status == "blocked" {
                stats.tasks_blocked += 1;
            }
            if t.locked {
                stats.tasks_locked += 1;
            }
            // 可派发：todo + 未锁定 + 有 files + 有 acceptance + complexity S 或 M
            if t.status == "todo"
                && !t.locked
                && t.files.is_some()
                && t.acceptance.is_some()
                && matches!(
                    t.complexity.as_deref(),
                    Some("S") | Some("M")
                )
            {
                stats.dispatchable += 1;
            }
        }
    }

    stats
}

// ── 蓝图治理 commands ────────────────────────────────────────────────────────

#[derive(Debug, Serialize)]
pub struct BlueprintStatus {
    pub status: String, // none | rules_outdated | content_stale | synced
    pub conventions_version: Option<String>,
    pub claude_md_managed: bool,
    pub claude_md_version: Option<String>,
    pub manifest_updated: Option<String>,
    pub master_version: String,
}

#[tauri::command]
pub fn get_blueprint_status(project_path: String) -> Result<BlueprintStatus, String> {
    let root = Path::new(&project_path);
    let bp_dir = root.join(".blueprint");

    if !bp_dir.exists() {
        return Ok(BlueprintStatus {
            status: "none".to_string(),
            conventions_version: None,
            claude_md_managed: false,
            claude_md_version: None,
            manifest_updated: None,
            master_version: master_conventions_version().to_string(),
        });
    }

    // 读取项目 CONVENTIONS.md 版本
    let conventions_version = read_frontmatter_version(&bp_dir.join("CONVENTIONS.md"));

    // 检测 CLAUDE.md MANAGED 区
    let claude_md_path = root.join("CLAUDE.md");
    let (claude_md_managed, claude_md_version) = if claude_md_path.exists() {
        let content = std::fs::read_to_string(&claude_md_path).unwrap_or_default();
        if let Some(ver) = extract_managed_version(&content) {
            (true, Some(ver))
        } else {
            (false, None)
        }
    } else {
        (false, None)
    };

    // 读取 manifest updated 日期
    let manifest_updated = read_manifest_updated(&bp_dir.join("manifest.yaml"));

    // 判断状态：对比当前生效版本（用户版优先，fallback 内置）
    let effective_ver = effective_conventions_version();
    // 项目版本 >= 内置生效版本时视为规则已同步（项目可能领先于内置基线）
    let project_ver_tuple = parse_semver_tuple(conventions_version.as_deref().unwrap_or("0.0.0"));
    let effective_ver_tuple = parse_semver_tuple(&effective_ver);
    let rules_current = project_ver_tuple >= effective_ver_tuple
        && claude_md_managed
        && claude_md_version.as_deref().map(|v| parse_semver_tuple(v) >= effective_ver_tuple).unwrap_or(false);

    let status = if !rules_current {
        "rules_outdated".to_string()
    } else if is_content_stale(&manifest_updated) {
        "content_stale".to_string()
    } else {
        "synced".to_string()
    };

    Ok(BlueprintStatus {
        status,
        conventions_version,
        claude_md_managed,
        claude_md_version,
        manifest_updated,
        master_version: effective_ver,
    })
}

#[derive(Debug, Serialize)]
pub struct SyncResult {
    pub success: bool,
    pub conventions_written: bool,
    pub claude_md_action: String, // created | appended | updated | skipped
    pub agents_md_action: String, // created | appended | updated
    pub message: String,
}

/// 在指定文件中写入/替换 BLUEPRINT_MANAGED 块：
/// 文件不存在 → 用 header + 模板创建；已有 MANAGED 区 → 原位替换；否则追加。
/// 返回动作：created | updated | appended
fn upsert_managed_block(
    path: &Path,
    template: &str,
    header_when_created: &str,
) -> Result<String, String> {
    let label = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
    if !path.exists() {
        let content = format!("{header_when_created}\n\n{template}\n");
        std::fs::write(path, content).map_err(|e| format!("创建 {label} 失败: {e}"))?;
        return Ok("created".to_string());
    }
    let content = std::fs::read_to_string(path).map_err(|e| format!("读取 {label} 失败: {e}"))?;
    if content.contains("<!-- BLUEPRINT_MANAGED_START") {
        let new_content = replace_managed_section(&content, template);
        std::fs::write(path, new_content).map_err(|e| format!("更新 {label} 失败: {e}"))?;
        Ok("updated".to_string())
    } else {
        let new_content = format!("{}\n\n{template}\n", content.trim_end());
        std::fs::write(path, new_content).map_err(|e| format!("追加 {label} 失败: {e}"))?;
        Ok("appended".to_string())
    }
}

#[tauri::command]
pub fn sync_blueprint_rules(project_path: String) -> Result<SyncResult, String> {
    let root = Path::new(&project_path);
    let bp_dir = root.join(".blueprint");
    let project_name = root.file_name().and_then(|n| n.to_str()).unwrap_or("Project").to_string();

    // 确保 .blueprint/ 存在
    std::fs::create_dir_all(&bp_dir)
        .map_err(|e| format!("创建 .blueprint/ 失败: {e}"))?;

    // 1. 写入 CONVENTIONS.md（使用当前生效版本：用户版优先，fallback 内置）
    std::fs::write(bp_dir.join("CONVENTIONS.md"), effective_conventions_content())
        .map_err(|e| format!("写入 CONVENTIONS.md 失败: {e}"))?;

    // 2. AGENTS.md：完整蓝图工作流（跨工具单一来源，Qwen/Cursor/Copilot 均读）
    let agents_action = upsert_managed_block(
        &root.join("AGENTS.md"),
        &agents_managed_template(),
        &format!("# AGENTS.md — {project_name}\n\n> 跨工具 AI 上下文入口（Claude / Qwen / Cursor / Copilot 等均读此文件）。"),
    )?;

    // 3. CLAUDE.md：薄引用（Claude 经 @AGENTS.md 接入单一来源）
    let claude_action = upsert_managed_block(
        &root.join("CLAUDE.md"),
        &claude_managed_template(),
        &format!("# {project_name}"),
    )?;

    Ok(SyncResult {
        success: true,
        conventions_written: true,
        claude_md_action: claude_action.clone(),
        agents_md_action: agents_action.clone(),
        message: format!(
            "CONVENTIONS.md 已同步至 v{}，AGENTS.md {agents_action}，CLAUDE.md {claude_action}",
            effective_conventions_version()
        ),
    })
}

// ── 用户版 CONVENTIONS 读写 commands ─────────────────────────────────────────

#[derive(Debug, Serialize)]
pub struct UserConventionsStatus {
    pub source: String,              // "builtin" | "user"
    pub version: String,
    pub content: String,
    pub builtin_version: String,
    pub outdated_from_builtin: bool, // 用户版本号低于内置时为 true
}

#[tauri::command]
pub fn get_user_conventions_status() -> UserConventionsStatus {
    let builtin_ver = master_conventions_version().to_string();
    let user_path = user_conventions_path();
    let has_user = user_path.as_ref()
        .map(|p| p.exists())
        .unwrap_or(false);

    if has_user {
        let content = effective_conventions_content();
        let version = effective_conventions_version();
        let outdated = parse_semver_tuple(&version) < parse_semver_tuple(&builtin_ver);
        UserConventionsStatus { source: "user".to_string(), version, content, builtin_version: builtin_ver, outdated_from_builtin: outdated }
    } else {
        UserConventionsStatus {
            source: "builtin".to_string(),
            version: builtin_ver.clone(),
            content: MASTER_CONVENTIONS.to_string(),
            builtin_version: builtin_ver,
            outdated_from_builtin: false,
        }
    }
}

#[derive(Debug, Serialize)]
pub struct SaveConventionsResult {
    pub version: String,
    pub synced_count: u32,
}

/// 从 DB 收集所有项目的可访问路径（Windows 路径或 WSL UNC 路径）
fn collect_all_sync_paths() -> Vec<String> {
    let conn = match crate::db::pool().get() {
        Ok(c) => c,
        Err(_) => return vec![],
    };
    let mut stmt = match conn.prepare(
        "SELECT win_path, wsl_path, platform FROM project_workspaces",
    ) {
        Ok(s) => s,
        Err(_) => return vec![],
    };
    let configured_distro: String = conn
        .query_row("SELECT value FROM settings WHERE key='wsl_distro'", [], |r| r.get(0))
        .unwrap_or_else(|_| "Ubuntu".to_string());

    stmt.query_map([], |row| {
        Ok((
            row.get::<_, Option<String>>(0)?,
            row.get::<_, Option<String>>(1)?,
            row.get::<_, Option<String>>(2)?,
        ))
    })
    .map(|iter| {
        iter.filter_map(|r| r.ok())
            .filter_map(|(win_path, wsl_path, platform)| {
                match platform.as_deref() {
                    Some("wsl") => {
                        let lp = wsl_path?;
                        if lp.starts_with("\\\\") {
                            // 已是 UNC，直接用
                            Some(lp)
                        } else if !lp.is_empty() {
                            // Linux 路径，转为 UNC
                            let inner = lp.trim_start_matches('/').replace('/', "\\");
                            Some(format!("\\\\wsl.localhost\\{}\\{}", configured_distro, inner))
                        } else {
                            None
                        }
                    }
                    _ => win_path.filter(|p| !p.is_empty()),
                }
            })
            .collect()
    })
    .unwrap_or_default()
}

#[tauri::command]
pub fn save_user_conventions(content: String) -> Result<SaveConventionsResult, String> {
    // 1. 解析版本号，非法内容拒绝写入
    let version = parse_frontmatter_version_str(&content)
        .ok_or_else(|| "内容缺少有效的 frontmatter version 字段，写入已拒绝".to_string())?;
    if version == "unknown" {
        return Err("version 字段值为 unknown，写入已拒绝".to_string());
    }

    // 2. 写入用户版文件
    let path = user_conventions_path()
        .ok_or_else(|| "用户版存储目录未初始化".to_string())?;
    std::fs::write(&path, &content)
        .map_err(|e| format!("写入用户版 CONVENTIONS 失败: {e}"))?;

    // 3. 清除缓存，使新版本立即生效
    invalidate_effective_cache();

    // 4. 自动同步所有项目（后端自行查询路径，含 WSL）
    let mut synced = 0u32;
    for path_str in collect_all_sync_paths() {
        if sync_blueprint_rules(path_str).is_ok() {
            synced += 1;
        }
    }

    Ok(SaveConventionsResult { version, synced_count: synced })
}

#[tauri::command]
pub fn reset_conventions_to_builtin() -> Result<SaveConventionsResult, String> {
    // 删除用户版文件
    if let Some(path) = user_conventions_path() {
        if path.exists() {
            std::fs::remove_file(&path)
                .map_err(|e| format!("删除用户版文件失败: {e}"))?;
        }
    }
    invalidate_effective_cache();

    // 同步所有项目回内置版本（后端自行查询路径，含 WSL）
    let mut synced = 0u32;
    for path_str in collect_all_sync_paths() {
        if sync_blueprint_rules(path_str).is_ok() {
            synced += 1;
        }
    }

    Ok(SaveConventionsResult {
        version: master_conventions_version().to_string(),
        synced_count: synced,
    })
}

fn parse_semver_tuple(v: &str) -> (u32, u32, u32) {
    let mut parts = v.splitn(3, '.');
    let major = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
    let minor = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
    let patch = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
    (major, minor, patch)
}

// ── 治理辅助函数 ─────────────────────────────────────────────────────────────

fn read_frontmatter_version(path: &Path) -> Option<String> {
    let content = std::fs::read_to_string(path).ok()?;
    parse_frontmatter_version_str(&content)
}

fn extract_managed_version(content: &str) -> Option<String> {
    let marker = "<!-- BLUEPRINT_MANAGED_START version=\"";
    if let Some(start) = content.find(marker) {
        let rest = &content[start + marker.len()..];
        if let Some(end) = rest.find('"') {
            return Some(rest[..end].to_string());
        }
    }
    None
}

fn read_manifest_updated(path: &Path) -> Option<String> {
    let content = std::fs::read_to_string(path).ok()?;
    for line in content.lines() {
        if let Some(v) = line.strip_prefix("updated: ") {
            return Some(v.trim().to_string());
        }
    }
    None
}

fn is_content_stale(updated: &Option<String>) -> bool {
    // 无 updated 字段视为 stale
    let Some(date_str) = updated else { return true };
    // 格式 YYYY-MM-DD，用字符串比较即可（lexicographic == chronological）
    // 计算 30 天前的日期：取当前系统时间减去 30 * 86400 秒
    let threshold = {
        use std::time::{SystemTime, UNIX_EPOCH};
        let secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let past = secs.saturating_sub(30 * 86400);
        let days_since_epoch = past / 86400;
        // 将 Unix 天数转为 YYYY-MM-DD（无需外部依赖）
        unix_days_to_date(days_since_epoch)
    };
    // updated < threshold 说明超过 30 天未更新
    date_str.as_str() < threshold.as_str()
}

/// 将 Unix 纪元天数转为 "YYYY-MM-DD" 字符串（无外部依赖）
fn unix_days_to_date(days: u64) -> String {
    // 参考：https://howardhinnant.github.io/date_algorithms.html civil_from_days
    let z = days as i64 + 719468;
    let era = if z >= 0 { z } else { z - 146096 } / 146097;
    let doe = (z - era * 146097) as u64;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    format!("{:04}-{:02}-{:02}", y, m, d)
}

fn replace_managed_section(content: &str, new_section: &str) -> String {
    let start_marker = "<!-- BLUEPRINT_MANAGED_START";
    let end_marker = "<!-- BLUEPRINT_MANAGED_END -->";

    if let (Some(start), Some(end_pos)) = (content.find(start_marker), content.find(end_marker)) {
        let end = end_pos + end_marker.len();
        format!("{}{}{}", &content[..start], new_section, &content[end..])
    } else {
        format!("{}\n\n{new_section}\n", content.trim_end())
    }
}

// ── 飞轮数据层：跨项目聚合 ───────────────────────────────────────────────────

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FlywheelCohort {
    pub conventions_version: String,
    pub project_count: u32,
    /// 可派发率：dispatched(可派发数) / total tasks（最新快照均值）
    pub dispatchable_rate: f64,
    /// 任务完成率：done / total tasks（最新快照均值，反映整体进度）
    pub completion_rate: f64,
    /// 飞轮持续率：30天内有快照的项目比例
    pub continuity_rate: f64,
    /// 阻塞密度：blocked / total tasks（最新快照均值）
    pub blocked_density: f64,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct BlockedReasonCount {
    pub reason: String,
    pub count: u32,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct StalledProject {
    pub name: String,
    /// UTC 日期字符串 "YYYY-MM-DD"，未知时为 "unknown"
    pub last_active: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct IterationJump {
    pub project: String,
    pub from_iteration: u32,
    pub to_iteration: u32,
    /// 跳变前最后一条快照的任务完成率（done/total）
    pub rate_before: f64,
    /// 跳变后第一条快照的任务完成率
    pub rate_after: f64,
}

/// 单条跨项目复盘笔记（Claude 通过 MCP 写入）
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectNoteEntry {
    pub project_name: String,
    pub content: String,
    pub created_at: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct FlywheelStats {
    pub cohorts: Vec<FlywheelCohort>,
    pub top_blocked_reasons: Vec<BlockedReasonCount>,
    /// 飞轮停转项目（60天无快照）
    pub stalled_projects: Vec<StalledProject>,
    /// AI文档休眠项目（90天无快照，推断 Claude 长期未读）
    pub ai_doc_stale_projects: Vec<StalledProject>,
    /// 迭代跳变记录（iteration 递增，说明发生了主动重构）
    pub iteration_jumps: Vec<IterationJump>,
    /// 停滞模块（in_progress 但无可推进任务，格式 "project_name/module_id"）
    pub stalled_modules: Vec<String>,
    pub total_projects_scanned: u32,
    pub projects_with_data: u32,
    /// 跨项目复盘笔记（最近 50 条，按时间倒序）
    pub project_notes: Vec<ProjectNoteEntry>,
    /// 跨项目执行质量指标（从各项目 usage.json 最新快照聚合）
    pub execution_quality: ExecutionQuality,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ExecutionQuality {
    /// 跨项目任务复杂度分布
    pub complexity_dist: std::collections::HashMap<String, u32>,
    /// 跨项目总返工次数
    pub total_rework_count: u32,
    /// M 级任务平均实际轮数（所有项目加权平均）
    pub avg_rounds_m: Option<f64>,
}

/// 内部：单项目最新快照的指标摘要
struct ProjectSnap {
    total_tasks: u64,
    done_tasks: u64,
    dispatched_tasks: u64,
    blocked_tasks: u64,
    has_recent_snapshot: bool, // 30天内
}

#[tauri::command]
pub fn get_flywheel_stats(project_paths: Vec<String>) -> Result<FlywheelStats, String> {
    // 去重：同一项目可能出现在多个产品组中，路径重复会导致指标重复计数
    let project_paths: Vec<String> = {
        let mut seen = std::collections::HashSet::new();
        project_paths.into_iter().filter(|p| seen.insert(p.clone())).collect()
    };

    let now = chrono::Utc::now();
    let thirty_days_ago  = now - chrono::Duration::days(30);
    let sixty_days_ago   = now - chrono::Duration::days(60);
    let ninety_days_ago  = now - chrono::Duration::days(90);

    let mut total_scanned   = 0u32;
    let mut projects_with_data = 0u32;

    // cohort_version → [ProjectSnap]
    let mut cohort_data: std::collections::HashMap<String, Vec<ProjectSnap>> = Default::default();
    // blocked_reason → 累计次数
    let mut reason_counts: std::collections::HashMap<String, u32> = Default::default();
    let mut stalled_projects: Vec<StalledProject> = Vec::new();
    let mut ai_doc_stale_projects: Vec<StalledProject> = Vec::new();
    let mut iteration_jumps: Vec<IterationJump> = Vec::new();
    let mut stalled_modules: Vec<String> = Vec::new();
    // 执行质量聚合
    let mut agg_complexity: std::collections::HashMap<String, u32> = Default::default();
    let mut agg_rework = 0u32;
    let mut agg_m_rounds_sum = 0.0f64;
    let mut agg_m_rounds_count = 0u32;

    for path_str in &project_paths {
        total_scanned += 1;
        let root = Path::new(path_str.as_str());
        let usage_path = root.join(".blueprint").join("usage.json");
        if !usage_path.exists() { continue; }

        let json_str = match std::fs::read_to_string(&usage_path) {
            Ok(s) => s,
            Err(_) => continue,
        };
        let root_val: serde_json::Value = match serde_json::from_str(&json_str) {
            Ok(v) => v,
            Err(_) => continue,
        };

        let project_name = root_val["project_name"].as_str()
            .map(|s| s.to_string())
            .unwrap_or_else(|| {
                root.file_name().and_then(|n| n.to_str()).unwrap_or("unknown").to_string()
            });

        let snapshots = match root_val["snapshots"].as_array() {
            Some(arr) if !arr.is_empty() => arr,
            _ => continue,
        };
        projects_with_data += 1;

        // 最新快照：按 at 字段取最大值，不依赖写入顺序
        let latest = snapshots.iter()
            .max_by_key(|s| s["at"].as_str().unwrap_or(""))
            .unwrap();

        let conventions_version = latest["conventions_version"]
            .as_str().unwrap_or("unknown").to_string();

        // conventions_version 无效时跳过（历史脏数据或未同步规则的项目）
        if conventions_version == "unknown" { continue; }

        // blocked_reasons 只取最新快照，避免历史快照反复计数导致虚高
        if let Some(reasons) = latest["blocked_reasons"].as_array() {
            for r in reasons {
                if let Some(s) = r.as_str() {
                    *reason_counts.entry(s.to_string()).or_insert(0) += 1;
                }
            }
        }

        let latest_dt = latest["at"].as_str()
            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
            .map(|dt| dt.with_timezone(&chrono::Utc));

        let last_active = latest_dt
            .map(|dt| dt.format("%Y-%m-%d").to_string())
            .unwrap_or_else(|| "unknown".to_string());

        // 停转检测（60天无快照）
        if latest_dt.map_or(true, |dt| dt < sixty_days_ago) {
            stalled_projects.push(StalledProject {
                name: project_name.clone(),
                last_active: last_active.clone(),
            });
        }

        // AI文档休眠检测（90天无快照）
        if latest_dt.map_or(true, |dt| dt < ninety_days_ago) {
            ai_doc_stale_projects.push(StalledProject {
                name: project_name.clone(),
                last_active: last_active.clone(),
            });
        }

        let has_recent = latest_dt.map_or(false, |dt| dt >= thirty_days_ago);

        let snap = ProjectSnap {
            total_tasks:     latest["tasks"]["total"].as_u64().unwrap_or(0),
            done_tasks:      latest["tasks"]["done"].as_u64().unwrap_or(0),
            dispatched_tasks:latest["tasks"]["dispatched"].as_u64().unwrap_or(0),
            blocked_tasks:   latest["tasks"]["blocked"].as_u64().unwrap_or(0),
            has_recent_snapshot: has_recent,
        };
        cohort_data.entry(conventions_version).or_default().push(snap);

        // ── 迭代跳变检测 ──────────────────────────────────────────────────────
        {
            let mut prev_iter: Option<u32> = None;
            let mut prev_rate: f64 = 0.0;
            for s in snapshots.iter() {
                let iter = s["iteration"].as_u64().unwrap_or(1) as u32;
                let total = s["tasks"]["total"].as_u64().unwrap_or(0);
                let done  = s["tasks"]["done"].as_u64().unwrap_or(0);
                let rate  = if total > 0 { done as f64 / total as f64 } else { 0.0 };
                if let Some(pi) = prev_iter {
                    if iter > pi {
                        let round3 = |v: f64| (v * 1000.0).round() / 1000.0;
                        iteration_jumps.push(IterationJump {
                            project: project_name.clone(),
                            from_iteration: pi,
                            to_iteration: iter,
                            rate_before: round3(prev_rate),
                            rate_after:  round3(rate),
                        });
                    }
                }
                prev_iter = Some(iter);
                prev_rate = rate;
            }
        }

        // ── 停滞模块收集（最新快照） ──────────────────────────────────────────
        if let Some(stalled) = latest["modules"]["stalled"].as_array() {
            for s in stalled {
                if let Some(module_id) = s.as_str() {
                    stalled_modules.push(format!("{}/{}", project_name, module_id));
                }
            }
        }

        // ── 执行质量指标聚合（阶段二新增字段） ──────────────────────────────────
        if let Some(dist) = latest["tasks"]["complexity_dist"].as_object() {
            for (k, v) in dist {
                if let Some(n) = v.as_u64() {
                    *agg_complexity.entry(k.clone()).or_insert(0) += n as u32;
                }
            }
        }
        if let Some(rc) = latest["tasks"]["rework_count"].as_u64() {
            agg_rework += rc as u32;
        }
        if let Some(ar) = latest["tasks"]["avg_rounds_M"].as_f64() {
            agg_m_rounds_sum += ar;
            agg_m_rounds_count += 1;
        }
    }

    // 构建 cohorts
    let round3 = |v: f64| (v * 1000.0).round() / 1000.0;

    let avg = |snaps: &[ProjectSnap], f: fn(&ProjectSnap) -> Option<f64>| -> f64 {
        let vals: Vec<f64> = snaps.iter().filter_map(f).collect();
        if vals.is_empty() { 0.0 } else { vals.iter().sum::<f64>() / vals.len() as f64 }
    };

    let mut cohorts: Vec<FlywheelCohort> = cohort_data.into_iter().map(|(version, snaps)| {
        let count = snaps.len() as u32;

        let dispatchable_rate = avg(&snaps, |s| {
            if s.total_tasks > 0 { Some(s.dispatched_tasks as f64 / s.total_tasks as f64) } else { None }
        });
        let completion_rate = avg(&snaps, |s| {
            if s.total_tasks > 0 { Some(s.done_tasks as f64 / s.total_tasks as f64) } else { None }
        });
        let continuity_rate = snaps.iter().filter(|s| s.has_recent_snapshot).count() as f64 / count as f64;
        let blocked_density = avg(&snaps, |s| {
            if s.total_tasks > 0 { Some(s.blocked_tasks as f64 / s.total_tasks as f64) } else { None }
        });

        FlywheelCohort {
            conventions_version: version,
            project_count: count,
            dispatchable_rate: round3(dispatchable_rate),
            completion_rate:   round3(completion_rate),
            continuity_rate:   round3(continuity_rate),
            blocked_density:   round3(blocked_density),
        }
    }).collect();

    // 按语义版本降序（解析 "1.4.10" 为 (1,4,10) 再比较，避免字符串排序的 "1.4.9" > "1.4.10" 问题）
    let parse_semver = |s: &str| -> (u32, u32, u32) {
        let mut parts = s.splitn(3, '.');
        let major = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
        let minor = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
        let patch = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
        (major, minor, patch)
    };
    cohorts.sort_by(|a, b| {
        parse_semver(&b.conventions_version).cmp(&parse_semver(&a.conventions_version))
    });

    // TOP10 blocked reasons
    let mut sorted_reasons: Vec<_> = reason_counts.into_iter().collect();
    sorted_reasons.sort_by(|a, b| b.1.cmp(&a.1));
    let top_blocked_reasons = sorted_reasons.into_iter().take(10)
        .map(|(reason, count)| BlockedReasonCount { reason, count })
        .collect();

    let project_notes = collect_flywheel_notes(&project_paths);

    let avg_rounds_m_agg = if agg_m_rounds_count > 0 {
        Some(((agg_m_rounds_sum / agg_m_rounds_count as f64) * 10.0).round() / 10.0)
    } else {
        None
    };

    Ok(FlywheelStats {
        cohorts,
        top_blocked_reasons,
        stalled_projects,
        ai_doc_stale_projects,
        iteration_jumps,
        stalled_modules,
        total_projects_scanned: total_scanned,
        projects_with_data,
        project_notes,
        execution_quality: ExecutionQuality {
            complexity_dist: agg_complexity,
            total_rework_count: agg_rework,
            avg_rounds_m: avg_rounds_m_agg,
        },
    })
}

/// 从 SQLite 收集各项目的 Claude 复盘笔记。
/// 通过 project_workspaces.win_path / wsl_path 匹配路径，
/// 再关联 project_notes（project_id = workspace_id），取最近 50 条。
/// DB 访问失败时静默返回空列表，不影响主流程。
fn collect_flywheel_notes(project_paths: &[String]) -> Vec<ProjectNoteEntry> {
    let pool = match crate::db::try_pool() {
        Some(p) => p,
        None => return vec![],
    };
    let conn = match pool.get() {
        Ok(c) => c,
        Err(_) => return vec![],
    };
    collect_flywheel_notes_inner(&conn, project_paths)
}

/// 可测试的内层实现，接受注入的连接（支持 in-memory DB）。
fn collect_flywheel_notes_inner(conn: &rusqlite::Connection, project_paths: &[String]) -> Vec<ProjectNoteEntry> {
    let mut notes: Vec<ProjectNoteEntry> = Vec::new();

    for path_str in project_paths {
        // project_notes.project_id 引用 project_workspaces.id（迁移后）
        // 同时 JOIN project_profiles 拿项目显示名
        let proj: Result<(String, String), _> = conn.query_row(
            "SELECT pw.id, pp.name
             FROM project_workspaces pw
             JOIN project_profiles pp ON pp.id = pw.profile_id
             WHERE pw.win_path = ?1 OR pw.wsl_path = ?1
             LIMIT 1",
            rusqlite::params![path_str],
            |row| Ok((row.get(0)?, row.get(1)?)),
        );
        let (workspace_id, project_name) = match proj {
            Ok(v) => v,
            Err(_) => continue,
        };

        let mut stmt = match conn.prepare(
            "SELECT content, created_at FROM project_notes
             WHERE project_id = ?1
             ORDER BY id DESC LIMIT 20",
        ) {
            Ok(s) => s,
            Err(_) => continue,
        };

        let rows = stmt.query_map(
            rusqlite::params![workspace_id],
            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
        );
        if let Ok(iter) = rows {
            for row in iter.filter_map(|r| r.ok()) {
                notes.push(ProjectNoteEntry {
                    project_name: project_name.clone(),
                    content: row.0,
                    created_at: row.1,
                });
            }
        }
    }

    // 按时间倒序，限 50 条（避免 prompt 过长）
    notes.sort_by(|a, b| b.created_at.cmp(&a.created_at));
    notes.truncate(50);
    notes
}

// ── 梦核提示词生成 ────────────────────────────────────────────────────────────

/// 从完整 CONVENTIONS 中抽取对梦核有判断价值的规则段落。
/// 跳过：任务卡格式定义、manifest 字段规范、安装说明、/architect /splitter 流程等纯实现细节。
fn conventions_rules_summary() -> String {
    // 保留这些二级/三级标题对应的章节（前缀匹配）
    const KEEP: &[&str] = &[
        "## 何时更新蓝图",
        "## 模块状态定义",
        "### 可派发标准",
        "## 迭代管理",
        "## 飞轮外环",
        "## 注意事项",
    ];

    let source = effective_conventions_content();
    let lines: Vec<&str> = source.lines().collect();
    let mut result: Vec<&str> = Vec::new();
    let mut in_keep = false;
    let mut current_section_start = 0usize;

    for (i, &line) in lines.iter().enumerate() {
        let is_heading = line.starts_with("## ") || line.starts_with("### ");
        if is_heading {
            if in_keep {
                // 结束上一个保留段
                let chunk = &lines[current_section_start..i];
                result.extend_from_slice(chunk);
                result.push("");
            }
            in_keep = KEEP.iter().any(|&k| line.starts_with(k));
            if in_keep { current_section_start = i; }
        }
    }
    // 收尾
    if in_keep {
        result.extend_from_slice(&lines[current_section_start..]);
    }

    result.join("\n")
}

#[tauri::command]
pub fn generate_muhe_prompt(stats: FlywheelStats) -> String {
    // ── 队列对比表 ────────────────────────────────────────────────────────────
    let cohort_table = if stats.cohorts.is_empty() {
        "（暂无队列数据）".to_string()
    } else {
        let header = "| CONVENTIONS 版本 | 项目数 | 可派发率 | 任务完成率 | 飞轮持续率 | 阻塞密度 |";
        let sep    = "|------------------|--------|----------|------------|------------|----------|";
        let rows: Vec<String> = stats.cohorts.iter().map(|c| {
            format!(
                "| {} | {} | {:.1}% | {:.1}% | {:.1}% | {:.1}% |",
                c.conventions_version,
                c.project_count,
                c.dispatchable_rate * 100.0,
                c.completion_rate * 100.0,
                c.continuity_rate * 100.0,
                c.blocked_density * 100.0,
            )
        }).collect();
        format!("{header}\n{sep}\n{}", rows.join("\n"))
    };

    // ── TOP blocked_reasons ───────────────────────────────────────────────────
    let blocked_summary = if stats.top_blocked_reasons.is_empty() {
        "（暂无阻塞记录）".to_string()
    } else {
        stats.top_blocked_reasons.iter()
            .map(|r| format!("- {}（出现 {} 次）", r.reason, r.count))
            .collect::<Vec<_>>()
            .join("\n")
    };

    // ── 停转项目 ──────────────────────────────────────────────────────────────
    let stalled_summary = if stats.stalled_projects.is_empty() {
        "（无停转项目）".to_string()
    } else {
        stats.stalled_projects.iter()
            .map(|p| format!("- {} (最后活跃: {})", p.name, p.last_active))
            .collect::<Vec<_>>()
            .join("\n")
    };

    // ── AI文档休眠项目 ────────────────────────────────────────────────────────
    let ai_stale_summary = if stats.ai_doc_stale_projects.is_empty() {
        "（无休眠项目）".to_string()
    } else {
        stats.ai_doc_stale_projects.iter()
            .map(|p| format!("- {} (最后活跃: {})", p.name, p.last_active))
            .collect::<Vec<_>>()
            .join("\n")
    };

    // ── 迭代跳变项目 ──────────────────────────────────────────────────────────
    let iteration_summary = if stats.iteration_jumps.is_empty() {
        "（暂无迭代跳变记录）".to_string()
    } else {
        stats.iteration_jumps.iter()
            .map(|j| {
                let flag = if j.rate_after < j.rate_before - 0.30 { " ⚠️ 完成率大幅下降" } else { "" };
                format!(
                    "- {} 迭代 {} → {}（完成率 {:.1}% → {:.1}%）{}",
                    j.project, j.from_iteration, j.to_iteration,
                    j.rate_before * 100.0, j.rate_after * 100.0,
                    flag
                )
            })
            .collect::<Vec<_>>()
            .join("\n")
    };

    // ── 停滞模块 ──────────────────────────────────────────────────────────────
    let stalled_modules_summary = if stats.stalled_modules.is_empty() {
        "（暂无停滞模块）".to_string()
    } else {
        stats.stalled_modules.iter()
            .map(|m| format!("- {}", m))
            .collect::<Vec<_>>()
            .join("\n")
    };

    // ── 执行质量指标 ──────────────────────────────────────────────────────────
    let eq = &stats.execution_quality;
    let complexity_summary = {
        let s = eq.complexity_dist.get("S").copied().unwrap_or(0);
        let m = eq.complexity_dist.get("M").copied().unwrap_or(0);
        let l = eq.complexity_dist.get("L").copied().unwrap_or(0);
        if s + m + l == 0 {
            "（暂无复杂度标记数据）".to_string()
        } else {
            format!("S: {} 个 | M: {} 个 | L: {} 个", s, m, l)
        }
    };
    let rounds_summary = match eq.avg_rounds_m {
        Some(v) => format!("{:.1} 轮", v),
        None => "（暂无 M 级复盘数据）".to_string(),
    };
    let rework_summary = format!("{} 次", eq.total_rework_count);

    // ── 复盘笔记 ──────────────────────────────────────────────────────────────
    let notes_summary = if stats.project_notes.is_empty() {
        "（暂无复盘笔记——Claude 尚未在执行 M/L 任务后写入笔记）".to_string()
    } else {
        // 按项目分组展示
        let mut by_project: std::collections::BTreeMap<String, Vec<&ProjectNoteEntry>> = Default::default();
        for note in &stats.project_notes {
            by_project.entry(note.project_name.clone()).or_default().push(note);
        }
        let mut sections: Vec<String> = Vec::new();
        for (proj, entries) in &by_project {
            sections.push(format!("#### {}", proj));
            for e in entries {
                // 多行内容缩进，保持 prompt 可读性
                let indented = e.content.lines()
                    .map(|l| format!("  {}", l))
                    .collect::<Vec<_>>()
                    .join("\n");
                sections.push(format!("- `{}`\n{}", e.created_at, indented));
            }
        }
        sections.join("\n")
    };
    let notes_total = stats.project_notes.len();

    // ── 规则命中 × 返工交叉（Rules-Applied trailer，飞轮 R1）─────────────────
    let rule_hits_summary = crate::db::pool()
        .get()
        .ok()
        .and_then(|conn| crate::commands::commit_metrics::rule_hit_stats(&conn).ok())
        .filter(|v| !v.is_empty())
        .map(|v| {
            let header = "| 规则 | 命中 commit | 其中返工 | 覆盖项目 | 返工占比 |";
            let sep    = "|------|------------|----------|----------|----------|";
            let rows: Vec<String> = v.iter().map(|r| {
                let ratio = if r.hits > 0 { r.rework_hits as f64 / r.hits as f64 * 100.0 } else { 0.0 };
                format!("| {} | {} | {} | {} | {:.0}% |", r.rule_id, r.hits, r.rework_hits, r.project_count, ratio)
            }).collect();
            format!("{header}\n{sep}\n{}", rows.join("\n"))
        })
        .unwrap_or_else(|| "（暂无数据——commit message 附带 Rules-Applied trailer 后自动积累）".to_string());

    format!(r#"你是炼境（Dev Manager）的蓝图架构师，负责对飞轮运转数据进行记忆巩固分析。

## 飞轮运转数据（来自炼境跨项目聚合）

### 项目统计
- 已扫描项目：{total} 个
- 有蓝图数据：{with_data} 个

### CONVENTIONS 版本队列对比
{cohort_table}

### TOP 阻塞原因（跨项目累计）
{blocked_summary}

### 飞轮停转项目（60天无快照）
{stalled_summary}

### AI文档休眠项目（90天无快照，Claude 可能长期未介入）
{ai_stale_summary}

### 迭代跳变项目（iteration 递增，发生了主动重构）
{iteration_summary}

### 停滞模块（in_progress 但无可推进任务，格式：项目名/模块id）
{stalled_modules_summary}

### 执行质量指标（跨项目聚合，来自 usage.json 阶段二扩展）
- 任务复杂度分布：{complexity_summary}
- M 级任务平均实际轮数：{rounds_summary}
- 跨项目总返工次数：{rework_summary}

> 上述指标从各项目复盘笔记解析并写入 usage.json，用于量化执行效率变化趋势。

### 复盘笔记（跨项目 Claude 执行记录，共 {notes_total} 条）

> 笔记由 Claude 在完成 M/L 任务后通过 MCP 写入，结构化格式为【复盘】开头。
> 请重点识别：① 多次出现的相同踩坑；② 返工率高的任务类型；③ 有效策略可固化为规范。

{notes_summary}

### 规则命中 × 返工交叉（Rules-Applied trailer 被动自吸）

> commit message 附带的规则触发记录。返工占比高的规则 = 触发了但没防住问题，
> 是降置信度或按 R11 补 failure_case/boundaries 的信号；命中多且返工低 = 升置信度依据。

{rule_hits_summary}

---

## 当前 CONVENTIONS.md（v{conventions_version}）规则摘要

> 以下仅包含对「固化/遗忘」判断有价值的规则段落，格式定义和流程说明已省略。

{conventions}

---

## 你的任务：记忆巩固

请完成以下三件事：

### 【高频陷阱】复盘笔记中的执行模式分析

仅当复盘笔记数量 > 0 时执行，否则跳过本节。

分析维度：
1. 哪类任务（任务类型 × 复杂度）返工率最高？
2. 相同踩坑在多个项目重复出现了吗？根因是什么？
3. 有哪些有效策略已经在多个任务中奏效，可以固化为规范？
4. 有哪些遗留风险被多次提及，说明系统存在尚未解决的脆弱点？
5. M 级任务的实际轮数 vs 预估轮数偏差如何？是否存在系统性低估？

输出格式：
```
### 高频陷阱摘要（来自复盘笔记）
- **[任务类型]** <踩坑描述>，出现 N 次（涉及项目：A、B）
  → 建议：<一句话规避方式>
```
若笔记数不足以识别模式（< 5 条），输出"笔记积累不足，暂无高频模式"。

### 【固化】哪些模式值得写进 CONVENTIONS？

判断标准：
1. 高频陷阱分析中出现 2 次以上的踩坑 → 在 CONVENTIONS 增加预防性规范
2. 有队列数据显示某版本后可派发率/任务完成率明显提升 → 提炼有效实践
3. 当前 CONVENTIONS 未覆盖但数据反映的真实问题

输出格式：
- 直接给出可追加到 CONVENTIONS.md 的新章节或条目内容
- 每条改动说明：改了什么 + 为什么（对应哪个数据信号）

### 【遗忘】哪些内容可以归档或清理？

判断标准：
1. 飞轮停转项目（上方列出）是否有对应 AI 文档可以归档
2. CONVENTIONS 中是否有已过时或与数据矛盾的规则
3. 队列数据是否显示某版本规则效果差 → 对应规则可以删改

输出格式：
- 归档候选：项目名 + 建议归档的文件路径
- CONVENTIONS 删改候选：具体条目 + 理由

### 【规则置信度】哪些规则该升/降 confidence 或补边界？

仅当"规则命中 × 返工交叉"数据非空时执行，否则跳过本节。判断标准：
1. 命中多且返工占比低 → 建议提升该规则 confidence / evidence_count
2. 命中多且返工占比高 → 规则触发了但没防住问题：建议降 confidence，或按 R11 输出 failure_case 与 boundaries（把规则细化为带条件的变体，而非简单降分）
3. 长期零命中的规则 → 检查触发条件是否过窄或场景已消失

输出格式：
- 规则 ID + 建议动作（升 / 降 / 细化边界）+ 依据数据

---

请直接输出结构化分析结果，不要重复我的数据，聚焦在你的判断和建议上。
"#,
        total           = stats.total_projects_scanned,
        with_data       = stats.projects_with_data,
        cohort_table    = cohort_table,
        blocked_summary = blocked_summary,
        stalled_summary = stalled_summary,
        ai_stale_summary = ai_stale_summary,
        iteration_summary = iteration_summary,
        stalled_modules_summary = stalled_modules_summary,
        complexity_summary = complexity_summary,
        rounds_summary  = rounds_summary,
        rework_summary  = rework_summary,
        notes_total     = notes_total,
        notes_summary   = notes_summary,
        conventions_version = effective_conventions_version(),
        conventions     = conventions_rules_summary(),
    )
}

// ── 飞轮数据层：usage.json 快照 ──────────────────────────────────────────────

/// 从项目的 project_notes 解析返工次数和 M 级任务平均实际轮数。
/// 返回 (rework_count, avg_rounds_m)，avg_rounds_m 为 None 表示无 M 级复盘数据。
///
/// 旧路径：飞轮 L0 起 rework_count 改由 commit_metrics（git 自吸）提供，
/// 此函数保留作参考与未来回退，暂不调用。
#[allow(dead_code)]
fn parse_notes_metrics(bp_dir: &Path) -> (u32, Option<f64>) {
    let project_path = match bp_dir.parent().and_then(|p| p.to_str()) {
        Some(p) => p.to_string(),
        None => return (0, None),
    };

    let pool = match crate::db::try_pool() {
        Some(p) => p,
        None => return (0, None),
    };
    let conn = match pool.get() {
        Ok(c) => c,
        Err(_) => return (0, None),
    };

    // 查找此项目的 workspace_id
    let workspace_id: String = match conn.query_row(
        "SELECT id FROM project_workspaces WHERE win_path = ?1 OR wsl_path = ?1 LIMIT 1",
        rusqlite::params![project_path],
        |row| row.get(0),
    ) {
        Ok(id) => id,
        Err(_) => return (0, None),
    };

    // 读取所有复盘笔记
    let mut stmt = match conn.prepare(
        "SELECT content FROM project_notes WHERE project_id = ?1",
    ) {
        Ok(s) => s,
        Err(_) => return (0, None),
    };

    let rows = match stmt.query_map(rusqlite::params![workspace_id], |row| {
        row.get::<_, String>(0)
    }) {
        Ok(r) => r,
        Err(_) => return (0, None),
    };

    let mut rework_count = 0u32;
    let mut m_rounds: Vec<f64> = Vec::new();

    for row in rows.filter_map(|r| r.ok()) {
        let content = row;
        // 只解析【复盘】格式的笔记
        if !content.contains("【复盘】") {
            continue;
        }

        // 每条笔记只取首次出现的字段，避免格式异常导致重复计数
        let mut note_rework = false;
        let mut note_rounds: Option<f64> = None;

        for line in content.lines() {
            let trimmed = line.trim();

            // 解析"是否返工: 是"（仅取首次）
            if !note_rework
                && (trimmed.starts_with("是否返工:") || trimmed.starts_with("是否返工："))
            {
                let val = trimmed.splitn(2, |c| c == ':' || c == '：').nth(1).unwrap_or("").trim();
                if val == "是" {
                    note_rework = true;
                }
            }

            // 解析"实际轮数: N轮"（仅取首次）
            if note_rounds.is_none()
                && (trimmed.starts_with("实际轮数:") || trimmed.starts_with("实际轮数："))
            {
                let val = trimmed.splitn(2, |c| c == ':' || c == '：').nth(1).unwrap_or("").trim();
                // 提取数字部分，如 "3轮（预估2轮）" → 3.0
                note_rounds = val.chars()
                    .take_while(|c| c.is_ascii_digit() || *c == '.')
                    .collect::<String>()
                    .parse()
                    .ok();
            }
        }

        if note_rework {
            rework_count += 1;
        }

        // 仅对 M 级任务聚合轮数
        if let Some(r) = note_rounds {
            let is_m = content.lines().any(|l| {
                let t = l.trim();
                (t.starts_with("复杂度:") || t.starts_with("复杂度："))
                    && t.splitn(2, |c| c == ':' || c == '：')
                        .nth(1)
                        .map(|v| v.trim() == "M")
                        .unwrap_or(false)
            });
            if is_m {
                m_rounds.push(r);
            }
        }
    }

    let avg = if m_rounds.is_empty() {
        None
    } else {
        let sum: f64 = m_rounds.iter().sum();
        Some((sum / m_rounds.len() as f64 * 10.0).round() / 10.0) // 保留一位小数
    };

    (rework_count, avg)
}

/// 从项目路径反查 project_id，再聚合 commit_metrics 的有效返工次数。
/// 项目未施加 buff（查不到 project_id）时返回 None。
fn commit_rework_for_path(project_path: &str) -> Option<u32> {
    let conn = crate::db::pool().get().ok()?;
    let pid: String = conn
        .query_row(
            "SELECT project_id FROM blueprint_buffs WHERE project_path = ?1",
            rusqlite::params![project_path.trim_end_matches(['/', '\\'])],
            |r| r.get(0),
        )
        .ok()?;
    crate::commands::commit_metrics::rework_count(&conn, &pid).ok()
}

/// 向项目 .blueprint/usage.json 追加一条每日飞轮快照。
/// 节流：同一 UTC 日期只追加一次；写入失败静默忽略（不影响主流程）。
fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &BlueprintStats) {
    let usage_path = bp_dir.join("usage.json");

    // 项目名（bp_dir 是 <project>/.blueprint/，父目录即项目根）
    let project_name = bp_dir.parent()
        .and_then(|p| p.file_name())
        .and_then(|n| n.to_str())
        .unwrap_or("unknown")
        .to_string();

    // 读取 CONVENTIONS 版本；缺失时跳过快照写入，避免产生 vunknown 脏数据
    let conventions_version = match read_frontmatter_version(&bp_dir.join("CONVENTIONS.md")) {
        Some(v) => v,
        None => return,
    };

    // UTC 时间
    let now = chrono::Utc::now();
    let at = now.format("%Y-%m-%dT%H:%M:%SZ").to_string();
    let today = now.format("%Y-%m-%d").to_string();

    // 读取或初始化
    let json_str = std::fs::read_to_string(&usage_path).unwrap_or_default();
    let mut root: serde_json::Value = if json_str.is_empty() {
        serde_json::json!({ "project_name": project_name, "snapshots": [] })
    } else {
        let mut v: serde_json::Value = serde_json::from_str(&json_str)
            .unwrap_or_else(|_| serde_json::json!({ "snapshots": [] }));
        // 补写 project_name（兼容旧版本没有此字段的文件）
        if v["project_name"].is_null() {
            v["project_name"] = serde_json::json!(project_name);
        }
        v
    };

    // 节流：今天已有快照则跳过
    if let Some(arr) = root["snapshots"].as_array() {
        if arr.iter().any(|s| {
            s["at"].as_str().map_or(false, |t| t.starts_with(&today))
        }) {
            return;
        }
    }

    // 任务维度统计（BlueprintStats 未包含的字段）
    let mut tasks_in_progress = 0u32;
    let mut tasks_todo = 0u32;
    let mut blocked_reasons: Vec<String> = Vec::new();
    let mut complexity_s = 0u32;
    let mut complexity_m = 0u32;
    let mut complexity_l = 0u32;
    for module in &manifest.modules {
        for task in &module.tasks {
            // 复杂度分布统计
            if let Some(ref c) = task.complexity {
                match c.to_uppercase().as_str() {
                    "S" => complexity_s += 1,
                    "M" => complexity_m += 1,
                    "L" => complexity_l += 1,
                    _ => {}
                }
            }
            match task.status.as_str() {
                "in_progress" => tasks_in_progress += 1,
                "todo" => tasks_todo += 1,
                "blocked" => {
                    if let Some(ref reason) = task.blocked_reason {
                        let r = reason.trim().to_string();
                        if !r.is_empty() {
                            blocked_reasons.push(r);
                        }
                    }
                }
                _ => {}
            }
        }
    }

    // 飞轮 L0：rework_count 从 commit_metrics（git 自吸）取，取代枯竭的复盘笔记解析；
    // avg_rounds_M 需 PR 边界，基线档暂不出（见 gitea-integration 档2）
    let rework_count = bp_dir
        .parent()
        .and_then(|p| p.to_str())
        .and_then(commit_rework_for_path)
        .unwrap_or(0);
    let avg_rounds_m: Option<f64> = None;

    // 停滞模块：in_progress 且任务全为 in_progress/blocked（没有 todo 可推进）
    let stalled_modules: Vec<String> = manifest.modules.iter()
        .filter(|m| m.status == "in_progress" && !m.tasks.is_empty())
        .filter(|m| {
            // 没有 todo 任务卡可以推进
            !m.tasks.iter().any(|t| t.status == "todo" && !t.locked)
        })
        .map(|m| m.id.clone())
        .collect();

    // 当前所有任务的状态快照（用于 delta 计算和下次快照对比）
    let mut current_done: std::collections::HashSet<String> = Default::default();
    let mut current_blocked: std::collections::HashSet<String> = Default::default();
    for module in &manifest.modules {
        for task in &module.tasks {
            let key = format!("{}/{}", module.id, task.title);
            match task.status.as_str() {
                "done" => { current_done.insert(key); }
                "blocked" => { current_blocked.insert(key); }
                _ => {}
            }
        }
    }

    // 与上一条快照对比，生成 delta
    let (newly_done, newly_blocked, unblocked): (Vec<String>, Vec<String>, Vec<String>) = {
        let prev = root["snapshots"].as_array()
            .and_then(|arr| arr.iter().max_by_key(|s| s["at"].as_str().unwrap_or("")));

        if let Some(prev_snap) = prev {
            let prev_done: std::collections::HashSet<String> = prev_snap["task_ids"]["done"]
                .as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
                .unwrap_or_default();
            let prev_blocked: std::collections::HashSet<String> = prev_snap["task_ids"]["blocked"]
                .as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
                .unwrap_or_default();

            let mut nd: Vec<String> = current_done.difference(&prev_done).cloned().collect();
            let mut nb: Vec<String> = current_blocked.difference(&prev_blocked).cloned().collect();
            let mut ub: Vec<String> = prev_blocked.difference(&current_blocked).cloned().collect();
            nd.sort(); nb.sort(); ub.sort();
            (nd, nb, ub)
        } else {
            (Vec::new(), Vec::new(), Vec::new())
        }
    };

    let mut done_ids: Vec<String> = current_done.into_iter().collect();
    let mut blocked_ids: Vec<String> = current_blocked.into_iter().collect();
    done_ids.sort(); blocked_ids.sort();

    let snapshot = serde_json::json!({
        "at": at,
        "conventions_version": conventions_version,
        "iteration": manifest.iteration.unwrap_or(1),
        "modules": {
            "total": stats.total_modules,
            "done": stats.done,
            "in_progress": stats.in_progress,
            "planned": stats.planned,
            "concept": stats.concept,
            "blocked": stats.blocked,
            "stalled": stalled_modules
        },
        "tasks": {
            "total": stats.total_tasks,
            "done": stats.tasks_done,
            "in_progress": tasks_in_progress,
            "blocked": stats.tasks_blocked,
            "todo": tasks_todo,
            "dispatched": stats.dispatchable,
            "complexity_dist": { "S": complexity_s, "M": complexity_m, "L": complexity_l },
            "rework_count": rework_count,
            "avg_rounds_M": avg_rounds_m
        },
        "blocked_reasons": blocked_reasons,
        // 当前任务状态快照，供下次 delta 计算使用
        "task_ids": {
            "done": done_ids,
            "blocked": blocked_ids
        },
        // 与上一条快照的 delta（首次快照为空）
        "delta": {
            "newly_done": newly_done,
            "newly_blocked": newly_blocked,
            "unblocked": unblocked
        }
    });

    if let Some(arr) = root["snapshots"].as_array_mut() {
        arr.push(snapshot);
    }

    if let Ok(out) = serde_json::to_string_pretty(&root) {
        let _ = std::fs::write(&usage_path, out);
    }
}

// ── 梦核：归档 AI 文档 ───────────────────────────────────────────────────────

#[derive(Debug, Serialize)]
pub struct ArchiveResult {
    pub archived_files: Vec<String>,
    pub archive_dir: String,
}

/// 将项目 .blueprint/modules/ 下所有 .md 文件归档到 .blueprint/archive/<timestamp>/
/// 归档后写入 MANIFEST.txt，再删除原始文件。
/// 不归档 CONVENTIONS.md / usage.json / manifest.yaml（这些是运行时文件）。
#[tauri::command]
pub fn archive_project_docs(project_path: String) -> Result<ArchiveResult, String> {
    let root = Path::new(&project_path);
    let bp_dir = root.join(".blueprint");
    let modules_dir = bp_dir.join("modules");

    if !modules_dir.exists() {
        return Err("项目不存在 .blueprint/modules/ 目录".to_string());
    }

    // 收集待归档文件
    let entries = std::fs::read_dir(&modules_dir)
        .map_err(|e| format!("读取 modules/ 失败: {e}"))?;

    let mut files_to_archive: Vec<std::path::PathBuf> = entries
        .flatten()
        .filter(|e| {
            let name = e.file_name();
            let s = name.to_string_lossy();
            e.path().is_file() && s.ends_with(".md")
        })
        .map(|e| e.path())
        .collect();

    if files_to_archive.is_empty() {
        return Err("modules/ 目录下没有可归档的 .md 文件".to_string());
    }

    files_to_archive.sort();

    // 创建归档目录
    let timestamp = chrono::Utc::now().format("%Y-%m-%d-%H%M%S").to_string();
    let archive_dir = bp_dir.join("archive").join(&timestamp);
    std::fs::create_dir_all(&archive_dir)
        .map_err(|e| format!("创建归档目录失败: {e}"))?;

    let mut archived: Vec<String> = Vec::new();

    // 复制文件
    for src in &files_to_archive {
        let filename = src.file_name().unwrap();
        let dest = archive_dir.join(filename);
        std::fs::copy(src, &dest)
            .map_err(|e| format!("复制 {} 失败: {e}", src.display()))?;
        archived.push(filename.to_string_lossy().to_string());
    }

    // 写 MANIFEST.txt
    let manifest_content = format!(
        "归档时间: {}\n项目: {}\n归档文件:\n{}\n",
        timestamp,
        root.file_name().and_then(|n| n.to_str()).unwrap_or("unknown"),
        archived.iter().map(|f| format!("  - {f}")).collect::<Vec<_>>().join("\n")
    );
    std::fs::write(archive_dir.join("MANIFEST.txt"), manifest_content)
        .map_err(|e| format!("写入 MANIFEST.txt 失败: {e}"))?;

    // 删除原始文件
    for src in &files_to_archive {
        std::fs::remove_file(src)
            .map_err(|e| format!("删除 {} 失败: {e}", src.display()))?;
    }

    Ok(ArchiveResult {
        archived_files: archived,
        archive_dir: archive_dir.to_string_lossy().to_string(),
    })
}

// ── 测试 ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use rusqlite::Connection;
    use std::fs;

    /// 建立最小 schema 的 in-memory DB，仅包含 collect_flywheel_notes_inner 所需表。
    fn setup_db() -> Connection {
        let conn = Connection::open_in_memory().unwrap();
        conn.execute_batch(
            "CREATE TABLE project_profiles (
                id   TEXT PRIMARY KEY,
                name TEXT NOT NULL
            );
            CREATE TABLE project_workspaces (
                id         TEXT PRIMARY KEY,
                profile_id TEXT NOT NULL,
                win_path   TEXT,
                wsl_path   TEXT
            );
            CREATE TABLE project_notes (
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
                project_id TEXT NOT NULL,
                content    TEXT NOT NULL,
                created_at TEXT NOT NULL
            );",
        )
        .unwrap();
        conn
    }

    // ── collect_flywheel_notes_inner ─────────────────────────────────────────

    #[test]
    fn test_collect_notes_win_path_match() {
        let conn = setup_db();
        conn.execute("INSERT INTO project_profiles VALUES ('p1', 'MyProject')", []).unwrap();
        conn.execute(
            "INSERT INTO project_workspaces VALUES ('ws1', 'p1', 'C:/repos/my-project', NULL)",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO project_notes VALUES (NULL, 'ws1', '【复盘】win path test', '2026-01-01T00:00:00Z')",
            [],
        )
        .unwrap();

        let notes = collect_flywheel_notes_inner(&conn, &["C:/repos/my-project".to_string()]);
        assert_eq!(notes.len(), 1);
        assert_eq!(notes[0].project_name, "MyProject");
        assert!(notes[0].content.contains("win path test"));
    }

    #[test]
    fn test_collect_notes_wsl_path_match() {
        let conn = setup_db();
        conn.execute("INSERT INTO project_profiles VALUES ('p2', 'WslProject')", []).unwrap();
        conn.execute(
            "INSERT INTO project_workspaces VALUES ('ws2', 'p2', NULL, '/home/user/wsl-project')",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO project_notes VALUES (NULL, 'ws2', '【复盘】wsl path test', '2026-01-02T00:00:00Z')",
            [],
        )
        .unwrap();

        let notes = collect_flywheel_notes_inner(&conn, &["/home/user/wsl-project".to_string()]);
        assert_eq!(notes.len(), 1);
        assert_eq!(notes[0].project_name, "WslProject");
        assert!(notes[0].content.contains("wsl path test"));
    }

    #[test]
    fn test_collect_notes_project_not_found() {
        let conn = setup_db();
        // 空库，路径不存在
        let notes = collect_flywheel_notes_inner(&conn, &["/nonexistent/path".to_string()]);
        assert!(notes.is_empty());
    }

    // ── get_flywheel_stats ───────────────────────────────────────────────────

    fn write_usage(dir: &std::path::Path, json: serde_json::Value) {
        let bp = dir.join(".blueprint");
        fs::create_dir_all(&bp).unwrap();
        fs::write(bp.join("usage.json"), serde_json::to_string_pretty(&json).unwrap()).unwrap();
    }

    #[test]
    fn test_flywheel_stats_empty_paths() {
        let stats = get_flywheel_stats(vec![]).unwrap();
        assert_eq!(stats.total_projects_scanned, 0);
        assert_eq!(stats.projects_with_data, 0);
        assert!(stats.cohorts.is_empty());
        assert!(stats.iteration_jumps.is_empty());
    }

    #[test]
    fn test_flywheel_stats_normal_snapshot() {
        let tmp = std::env::temp_dir().join("blueprint_test_normal_snapshot");
        let _ = fs::remove_dir_all(&tmp);
        write_usage(
            &tmp,
            serde_json::json!({
                "project_name": "TestProject",
                "snapshots": [{
                    "at": "2026-01-01T00:00:00Z",
                    "conventions_version": "1.2.0",
                    "iteration": 1,
                    "tasks": { "total": 10, "done": 6, "dispatched": 8, "blocked": 1 },
                    "blocked_reasons": ["依赖未就绪"],
                    "modules": { "stalled": [] }
                }]
            }),
        );

        let stats = get_flywheel_stats(vec![tmp.to_string_lossy().to_string()]).unwrap();
        assert_eq!(stats.total_projects_scanned, 1);
        assert_eq!(stats.projects_with_data, 1);
        assert_eq!(stats.cohorts.len(), 1);
        assert_eq!(stats.cohorts[0].conventions_version, "1.2.0");
        assert_eq!(stats.cohorts[0].project_count, 1);
        // completion_rate = 6/10 = 0.6，四舍五入到 3 位小数
        assert!((stats.cohorts[0].completion_rate - 0.6).abs() < 1e-6);
        assert_eq!(stats.top_blocked_reasons.len(), 1);
        assert_eq!(stats.top_blocked_reasons[0].reason, "依赖未就绪");

        let _ = fs::remove_dir_all(&tmp);
    }

    #[test]
    fn test_flywheel_stats_iteration_jump() {
        let tmp = std::env::temp_dir().join("blueprint_test_iteration_jump");
        let _ = fs::remove_dir_all(&tmp);
        write_usage(
            &tmp,
            serde_json::json!({
                "project_name": "JumpProject",
                "snapshots": [
                    {
                        "at": "2026-01-01T00:00:00Z",
                        "conventions_version": "1.0.0",
                        "iteration": 1,
                        "tasks": { "total": 10, "done": 8, "dispatched": 9, "blocked": 0 },
                        "blocked_reasons": [],
                        "modules": { "stalled": [] }
                    },
                    {
                        "at": "2026-02-01T00:00:00Z",
                        "conventions_version": "1.0.0",
                        "iteration": 2,
                        "tasks": { "total": 15, "done": 3, "dispatched": 10, "blocked": 0 },
                        "blocked_reasons": [],
                        "modules": { "stalled": [] }
                    }
                ]
            }),
        );

        let stats = get_flywheel_stats(vec![tmp.to_string_lossy().to_string()]).unwrap();
        assert_eq!(stats.iteration_jumps.len(), 1);
        let jump = &stats.iteration_jumps[0];
        assert_eq!(jump.project, "JumpProject");
        assert_eq!(jump.from_iteration, 1);
        assert_eq!(jump.to_iteration, 2);
        // rate_before = 8/10 = 0.8, rate_after = 3/15 = 0.2
        assert!((jump.rate_before - 0.8).abs() < 1e-6);
        assert!((jump.rate_after - 0.2).abs() < 1e-6);

        let _ = fs::remove_dir_all(&tmp);
    }

    // ── upsert_managed_block（跨工具规则分发）────────────────────────────

    #[test]
    fn upsert_managed_block_three_states() {
        let dir = std::env::temp_dir().join(format!("lj-upsert-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let f = dir.join("AGENTS.md");
        let tpl_v1 = "<!-- BLUEPRINT_MANAGED_START version=\"1.0.0\" -->
内容A
<!-- BLUEPRINT_MANAGED_END -->";
        let tpl_v2 = "<!-- BLUEPRINT_MANAGED_START version=\"2.0.0\" -->
内容B
<!-- BLUEPRINT_MANAGED_END -->";

        // 1. 不存在 → created，含 header
        let a = upsert_managed_block(&f, tpl_v1, "# 头部").unwrap();
        assert_eq!(a, "created");
        let c = std::fs::read_to_string(&f).unwrap();
        assert!(c.starts_with("# 头部"));
        assert!(c.contains("内容A"));

        // 2. 已有 MANAGED 区 → updated（原位替换，header 保留，旧内容消失）
        let a = upsert_managed_block(&f, tpl_v2, "# 头部").unwrap();
        assert_eq!(a, "updated");
        let c = std::fs::read_to_string(&f).unwrap();
        assert!(c.contains("内容B") && !c.contains("内容A"));
        assert_eq!(c.matches("BLUEPRINT_MANAGED_START").count(), 1);

        // 3. 存在但无 MANAGED 区 → appended（既有内容保留）
        let g = dir.join("CLAUDE.md");
        std::fs::write(&g, "# 手写内容

自定义段落").unwrap();
        let a = upsert_managed_block(&g, tpl_v1, "# 不该出现").unwrap();
        assert_eq!(a, "appended");
        let c = std::fs::read_to_string(&g).unwrap();
        assert!(c.contains("自定义段落") && c.contains("内容A") && !c.contains("不该出现"));

        let _ = std::fs::remove_dir_all(&dir);
    }
}

