//! 项目相关命令

use std::path::Path;

use serde::{Deserialize, Serialize};
use tauri::State;

use df_ai::provider::{ChatMessage, CompletionRequest};
// F-01 阶段5: 项目扫描描述路由 — TaskRequirements(采样含图时追加 Vision,
// 当前采样(ProjectSample)未含图像,故仅 Text)。池空/无匹配兜底 default_model。
// 注:路由已解耦(B-260618-03),原 Standard 智力约束已删除,纯 weight 选模型。
use df_ai::router::{
    select_model_id, Modality, TaskRequirements,
};
use df_types::types::{new_id, ProjectStatus};
use df_project::scan::{
    collect_sample, detect_stack, discover_projects, extract_description,
    normalize_path, DiscoveredProject,
};
use df_storage::crud::{ProjectActivityRecord, ProjectQuery};
use df_storage::models::{ProjectEventRecord, ProjectRecord};

use crate::state::AppState;

use super::{err_str, now_millis};

// ============================================================
// 知识图谱 Phase 2:事件流埋点辅助(best-effort,对标设计 §2.4 hook/after + §10.1)
// ============================================================

/// 追加一条项目事件到 project_events(best-effort)。失败仅 tracing::warn 不阻断主 IPC。
///
/// `source` 标 `"human"`(IPC 层)。AI 工具路径在 tool_registry 内自行标 `"ai"`。
async fn emit_project_event(
    state: &AppState,
    project_id: &str,
    event_type: &str,
    entity_type: Option<&str>,
    entity_id: Option<&str>,
) {
    let record = ProjectEventRecord {
        id: new_id(),
        project_id: project_id.to_string(),
        event_type: event_type.to_string(),
        entity_type: entity_type.map(|s| s.to_string()),
        entity_id: entity_id.map(|s| s.to_string()),
        from_state: None,
        to_state: Some("planning".to_string()),
        context_json: None,
        source: Some("human".to_string()),
        conversation_id: None,
        created_at: now_millis(),
    };
    if let Err(e) = state.project_events.insert(record).await {
        tracing::warn!(
            event_type = event_type,
            project_id = project_id,
            error = %e,
            "[事件流] 埋点写入失败(不阻断主操作)"
        );
    }
}

/// 创建项目入参
#[derive(Debug, Deserialize)]
pub struct CreateProjectInput {
    pub name: String,
    #[serde(default)]
    pub description: String,
    pub idea_id: Option<String>,
    /// 绑定的本地代码目录(可选,空=不绑定)
    #[serde(default)]
    pub path: Option<String>,
    /// 技术栈 JSON 数组字符串(可选,空则自动探测)
    #[serde(default)]
    pub stack: Option<String>,
}

/// 列出未删除项目(过滤回收站)。
///
/// F-260621-02:吃可选 query(关键词/排序/分页),向后兼容——不传(或 None)走 list_active
/// 全量(deleted_at IS NULL + created_at DESC),零破坏;传 query 走 list_by_query 动态 WHERE。
///
/// 问题3(项目最近活跃排序):默认无 query 路径改走 list_active_with_activity,返回
/// 带 `last_active_at` 字段的 `ProjectActivityRecord`(COALESCE project_events 最新事件,
/// projects.updated_at 回退),按业务活跃排序而非元信息修改时间。query 路径(关键词/分页)
/// 同样补 last_active_at 字段(查 map 填充,无事件回退 updated_at),保持前端契约统一。
#[tauri::command]
pub async fn list_projects(
    state: State<'_, AppState>,
    query: Option<ProjectQuery>,
) -> Result<Vec<ProjectActivityRecord>, String> {
    match query {
        // None 或全空 query(trim 后 keyword 空 + 无 order_by/limit/offset)→ 走 list_active_with_activity,
        // 按最近活跃排序(问题3)。list_active 分支语义已被 list_active_with_activity 覆盖
        // (后者亦返 deleted_at IS NULL 的全部项目,仅多了 last_active_at 字段 + 排序键)。
        Some(q) if q.keyword.as_deref().map(str::trim).is_some_and(|k| !k.is_empty())
            || q.order_by.is_some()
            || q.limit.is_some()
            || q.offset.is_some() =>
        {
            // query 路径:list_by_query 返 ProjectRecord,补 last_active_at 字段。
            // 单次拉全项目最新事件 map,逐条 COALESCE 填充,无事件回退 updated_at。
            let records = state.projects.list_by_query(q).await.map_err(err_str)?;
            let activity = state
                .project_events
                .latest_activity_per_project()
                .await
                .map_err(err_str)?;
            let result = records
                .into_iter()
                .map(|r| {
                    let last_active_at = activity
                        .get(&r.id)
                        .cloned()
                        .unwrap_or_else(|| r.updated_at.clone());
                    ProjectActivityRecord {
                        record: r,
                        last_active_at,
                    }
                })
                .collect();
            Ok(result)
        }
        _ => state.projects.list_active_with_activity().await.map_err(err_str),
    }
}

/// 创建项目，返回完整记录
///
/// 绑定目录时(path 非空):校验目录存在 + 防重复绑定 + 自动探测技术栈(stack 为空时)。
#[tauri::command]
pub async fn create_project(
    state: State<'_, AppState>,
    input: CreateProjectInput,
) -> Result<ProjectRecord, String> {
    create_with_binding(&state, input.name, input.description, input.idea_id, input.path, input.stack).await
}

/// 共用「校验 + 防重 + 探测 + insert」核心 — create_project 与 import_projects_batch 共用。
///
/// 对称收敛(决策记录:217 create/bind 去重):绑定逻辑单一实现,
/// 绑定目录时统一走「校验存在 + 防重复 + 自动探测 stack(stack 入参为空时)」。
/// relocate 不并入(走 update_field 非 insert)。
///
/// 返回 insert 后的完整记录。
async fn create_with_binding(
    state: &AppState,
    name: String,
    description: String,
    idea_id: Option<String>,
    path: Option<String>,
    stack: Option<String>,
) -> Result<ProjectRecord, String> {
    // 绑定目录:校验存在 + 防重复 + 自动探测技术栈
    let (path, stack) = match path.as_deref().map(str::trim).filter(|p| !p.is_empty()) {
        Some(p) => {
            // 拒绝原始路径含 `..`(防穿越),存入前调用 normalize_path 规范化
            if p.contains("..") {
                return Err(format!("路径不得包含 '..': {}", p));
            }
            if !Path::new(p).is_dir() {
                // 目录不存在则自动创建(消除建项目→建目录死锁)
                std::fs::create_dir_all(p).map_err(|e| format!("目录创建失败: {}", e))?;
            }
            let normalized = normalize_path(p);
            if let Some(conflict) = find_binding_conflict(state, &normalized, None).await? {
                return Err(format!("目录已被项目「{}」绑定", conflict.name));
            }
            // stack 优先用入参,否则自动探测(spawn_blocking 防 IO 阻塞 tokio runtime)
            let stack_json = match stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
                Some(s) => s.to_string(),
                None => {
                    let root = std::path::PathBuf::from(&normalized);
                    let detected = tokio::task::spawn_blocking(move || detect_stack(&root))
                        .await
                        .map_err(err_str)?
                        .map_err(err_str)?;
                    serde_json::to_string(&detected).map_err(err_str)?
                }
            };
            (Some(normalized), Some(stack_json))
        }
        None => (None, None),
    };

    let now = now_millis();
    let record = ProjectRecord {
        id: new_id(),
        name,
        description,
        status: ProjectStatus::Planning,
        idea_id,
        path,
        stack,
        created_at: now.clone(),
        updated_at: now,
    };
    state.projects.insert(record.clone()).await.map_err(err_str)?;
    // 工程系统:创建项目时自动建一个工程(path = 绑定目录,stack = 探测结果)。
    // 单仓库项目退化:项目下只有一个工程,用户无感工程概念。
    // 多仓库场景用户后续可添加更多工程(add_project_module IPC)。
    if let Some(ref module_path) = record.path {
        let now_str = df_types::now_millis().to_string();
        let module = df_storage::models::ProjectModuleRecord {
            id: df_types::types::new_id(),
            project_id: record.id.clone(),
            name: record.name.clone(),
            path: module_path.clone(),
            git_url: None,           // 探测 git remote 补充(失败为 None,不影响)
            stack: record.stack.clone(),  // 复用项目级技术栈探测结果
            auto_detected: true,     // 自动创建,重新探测时可覆盖
            sort_order: 0,
            created_at: now_str.clone(),
            updated_at: now_str,
        };
        if let Err(e) = state.project_modules.insert(module).await {
            tracing::warn!("创建项目时自动建工程失败(非阻断): {}", e);
        }
    }
    // F-260620: 绑定目录变更后立即 reload 白名单(reload_allowed_dirs 读 projects.path 合并 persistent),
    // 否则新绑定目录需重启/改 Settings 才生效 → AI 访问误弹窗(已绑定重复弹窗主因,agent3 问题5)
    if record.path.is_some() {
        state.reload_allowed_dirs().await;
    }
    // 知识图谱 Phase 2(对标设计 §2.4 hook/after):项目创建事件。best-effort 不阻断。
    //
    // 设计 §2.4 event_type 枚举未列 project_created(枚举非穷尽,Repo 不做白名单校验——
    // 见 project_event_repo.rs 注释「埋点层是唯一生产者,字符串字面量自带约束」)。此处沿用
    // task_created/idea_created 命名约定新增 project_created,语义自洽。entity_type=project。
    //
    // 注:灵感晋升(create_from_idea)在 promote_idea 单独记 idea_promoted 事件(更精确),
    // 此处一律记 project_created(项目实体自身视角),两事件互补不冲突。
    emit_project_event(
        state,
        &record.id,
        "project_created",
        Some("project"),
        Some(&record.id),
    )
    .await;
    Ok(record)
}

/// 导入历史项目入参
#[derive(Debug, Deserialize)]
pub struct ImportProjectInput {
    /// 待导入的本地目录绝对路径(已存在)
    pub path: String,
    /// 项目名(可选,空=用目录名)
    #[serde(default)]
    pub name: Option<String>,
    /// 描述(可选,空=自动读 README 首段)
    #[serde(default)]
    pub description: Option<String>,
    /// 技术栈 JSON 数组字符串(可选,空=自动探测)
    #[serde(default)]
    pub stack: Option<String>,
}

/// 导入历史项目 — 用户选已存在的本地目录,复用 scan 探测 + 绑定一步创建项目记录。
///
/// 与 `create_project` 的区别:import 直接给 path,创建实体 + 绑定目录 + 探测栈 +
/// (可选)读 README 首段填 description 一次性完成,无需先建空项目再绑定。
///
/// 流程:校验目录存在 → normalize_path 防重复绑定 → detect_stack + extract_description
/// (spawn_blocking 防 IO 阻塞 tokio runtime)→ 走 create_with_binding insert → 返回。
#[tauri::command]
pub async fn import_project(
    state: State<'_, AppState>,
    input: ImportProjectInput,
) -> Result<ProjectRecord, String> {
    let path = input.path.trim().to_string();
    if path.is_empty() {
        return Err("导入路径不能为空".to_string());
    }
    if !Path::new(&path).is_dir() {
        return Err(format!("目录不存在: {}", path));
    }

    // 解析 name/desc/stack(入参优先,缺省时从目录探测/读 README)。
    // spawn_blocking 防 IO 阻塞 tokio runtime。stack 解析后透传给 create_with_binding
    // (不再重复探测,与原行为一致)。
    let root = std::path::PathBuf::from(&path);
    let want_name = input.name.clone();
    let want_desc = input.description.clone();
    let want_stack = input.stack.clone();
    let (name, description, stack_json) = tokio::task::spawn_blocking(move || -> Result<_, String> {
        let name = match want_name.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
            Some(n) => n.to_string(),
            None => root
                .file_name()
                .and_then(|n| n.to_str())
                .map(|s| s.to_string())
                .ok_or_else(|| "无法从路径解析项目名".to_string())?,
        };
        let description = match want_desc.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
            Some(d) => d.to_string(),
            None => extract_description(&root).unwrap_or_default(),
        };
        let stack_json = match want_stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
            Some(s) => s.to_string(),
            None => {
                let detected = detect_stack(&root).map_err(err_str)?;
                serde_json::to_string(&detected).map_err(err_str)?
            }
        };
        Ok((name, description, stack_json))
    })
    .await
    .map_err(err_str)??;

    create_with_binding(&state, name, description, None, Some(path), Some(stack_json)).await
}

/// 按 ID 查询项目
#[tauri::command]
pub async fn get_project(
    state: State<'_, AppState>,
    id: String,
) -> Result<Option<ProjectRecord>, String> {
    state
        .projects
        .get_by_id(&id)
        .await
        .map_err(err_str)
}

/// 更新项目单个字段（字段名走 df-storage 白名单校验）
///
/// B-260801-01(P0-1):0 行(id 不存在 / 已软删)→ 返 Err 而非假成功。原实现无视
/// update_field 返回值恒返 Ok(true),前端误以为成功但库无变更(UI/DB 不一致)。
///
/// 返回值保持 bool(成功恒 true):前端 api 包装 `Promise<boolean>` 契约不变零破坏,
/// title 不在 IPC 返回值——IPC 调用点(projects store)不展示带返回值的 toast,
/// title 仅供 AI 工具卡片路径(ai/tools/project.rs),IPC 路径返 title 为冗余。
#[tauri::command]
pub async fn update_project(
    state: State<'_, AppState>,
    id: String,
    field: String,
    value: String,
) -> Result<bool, String> {
    // B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。
    let updated = state
        .projects
        .update_field(&id, &field, &value)
        .await
        .map_err(err_str)?;
    if !updated {
        return Err(format!("项目 ID {id} 不存在或已删除"));
    }
    // F-260620: path 字段变更后立即 reload 白名单(新绑定/重绑目录即时生效,防 AI 误弹窗)
    if field == "path" {
        state.reload_allowed_dirs().await;
    }
    Ok(true)
}

/// 删除项目(软删 → 回收站,可恢复)
#[tauri::command]
pub async fn delete_project(state: State<'_, AppState>, id: String) -> Result<bool, String> {
    state.projects.soft_delete(&id).await.map_err(err_str)
}

/// 列出回收站项目(deleted_at IS NOT NULL)
#[tauri::command]
pub async fn list_deleted_projects(
    state: State<'_, AppState>,
) -> Result<Vec<ProjectRecord>, String> {
    state.projects.list_deleted().await.map_err(err_str)
}

/// 恢复项目(从回收站还原,清 deleted_at)
#[tauri::command]
pub async fn restore_project(state: State<'_, AppState>, id: String) -> Result<bool, String> {
    state.projects.restore(&id).await.map_err(err_str)
}

/// 彻底删除项目(级联物理删 branches/releases/tasks,不可恢复)
#[tauri::command]
pub async fn purge_project(state: State<'_, AppState>, id: String) -> Result<bool, String> {
    state
        .projects
        .purge_with_descendants(&id)
        .await
        .map_err(err_str)
}

// ============================================================
// 项目目录绑定 — 探测 / 防重复 / 重定位 / 有效性检查
// ============================================================

/// 查找已绑定该目录的项目(排除 exclude_id 自身)。无冲突返回 None。
///
/// 委托 `ProjectRepo::find_path_conflict`(DRY R-PD-11:与 tool_registry::bind_dir_to_project
/// 共用同一防重复绑定实现)。normalize_path 内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过。
async fn find_binding_conflict(
    state: &AppState,
    path: &str,
    exclude_id: Option<&str>,
) -> Result<Option<ProjectRecord>, String> {
    let norm = normalize_path(path);
    state
        .projects
        .find_path_conflict(&norm, exclude_id)
        .await
        .map_err(err_str)
}

/// 探测目录技术栈(前端选目录后实时预览)
#[tauri::command]
pub async fn scan_project_stack(path: String) -> Result<Vec<String>, String> {
    let root = std::path::PathBuf::from(&path);
    tokio::task::spawn_blocking(move || detect_stack(&root).map_err(err_str))
        .await
        .map_err(err_str)?
}

/// 检查目录是否已被其他项目绑定(防重复绑定)。返回占用项目(若有)。
/// exclude_id 用于编辑/重定位时排除自身。
#[tauri::command]
pub async fn check_path_binding(
    state: State<'_, AppState>,
    path: String,
    exclude_id: Option<String>,
) -> Result<Option<ProjectRecord>, String> {
    find_binding_conflict(&state, &path, exclude_id.as_deref()).await
}

/// 重定位项目目录(目录移动后重新指向)。校验存在 + 防重复 + 重探测 stack,返回最新记录。
#[tauri::command]
pub async fn relocate_project_path(
    state: State<'_, AppState>,
    id: String,
    new_path: String,
) -> Result<ProjectRecord, String> {
    if !Path::new(&new_path).is_dir() {
        return Err(format!("目录不存在: {}", new_path));
    }
    if let Some(conflict) = find_binding_conflict(&state, &new_path, Some(&id)).await? {
        return Err(format!("目录已被项目「{}」绑定", conflict.name));
    }
    // 重探测技术栈(spawn_blocking 防 IO 阻塞 tokio runtime)
    let root = std::path::PathBuf::from(&new_path);
    let stack = tokio::task::spawn_blocking(move || detect_stack(&root))
        .await
        .map_err(err_str)?
        .map_err(err_str)?;
    let stack_json = serde_json::to_string(&stack).map_err(err_str)?;
    state
        .projects
        .update_field(&id, "path", &new_path)
        .await
        .map_err(err_str)?;
    // F-260620: 重定位目录后立即 reload 白名单(新路径即时授权,防 AI 误弹窗)
    state.reload_allowed_dirs().await;
    state
        .projects
        .update_field(&id, "stack", &stack_json)
        .await
        .map_err(err_str)?;
    state
        .projects
        .get_by_id(&id)
        .await
        .map_err(err_str)?
        .ok_or_else(|| "项目不存在".to_string())
}

/// 检查目录是否存在(详情页「目录是否还在」用)
#[tauri::command]
pub async fn check_path_exists(path: String) -> Result<bool, String> {
    Ok(Path::new(&path).is_dir())
}

// ============================================================
// 批量扫描/导入历史项目 — F-260614-06(scan 第二步)
// ============================================================

/// 扫描发现的候选项目(规则发现,无 LLM)。前端预览表格只读展示。
#[derive(Debug, Serialize)]
pub struct ScannedProjectItem {
    pub path: String,
    pub name: String,
    pub stack: Vec<String>,
    pub is_monorepo: bool,
    /// 该目录是否已被某个项目绑定(防重复,前端标记禁选)
    pub already_bound: bool,
}

/// 扫描根目录发现候选项目(规则发现,快、不跑 LLM)。
///
/// 调 `discover_projects`(monorepo 一层展开 + detect_stack 非空过滤),
/// 标记每个候选是否已被项目绑定。前端用预览表格勾选后调 import_projects_batch。
#[tauri::command]
pub async fn scan_directory_for_projects(
    state: State<'_, AppState>,
    root_path: String,
) -> Result<Vec<ScannedProjectItem>, String> {
    let root = Path::new(&root_path);
    if !root.is_dir() {
        return Err(format!("目录不存在: {}", root_path));
    }

    // 1. 规则发现(spawn_blocking 防 IO 阻塞 tokio runtime)
    let scan_root = std::path::PathBuf::from(&root_path);
    let discovered: Vec<DiscoveredProject> = tokio::task::spawn_blocking(move || {
        discover_projects(&scan_root)
    })
    .await
    .map_err(err_str)?
    .map_err(err_str)?;

    // 2. 标已绑定项(逐项 normalize_path 查重)
    let mut out = Vec::with_capacity(discovered.len());
    for d in discovered {
        let already_bound = find_binding_conflict(&state, &d.path, None)
            .await?
            .is_some();
        out.push(ScannedProjectItem {
            path: d.path,
            name: d.name,
            stack: d.stack,
            is_monorepo: d.is_monorepo,
            already_bound,
        });
    }
    Ok(out)
}

/// 批量导入历史项目单条结果
#[derive(Debug, Serialize)]
pub struct ImportBatchItemResult {
    /// 入参 path(回显,前端按 path 对齐结果)
    pub path: String,
    /// 成功:导入的项目名;失败:None
    pub name: Option<String>,
    /// 失败原因(成功为 None)
    pub error: Option<String>,
}

/// 批量导入历史项目结果(前端 toast 汇总)
#[derive(Debug, Serialize)]
pub struct ImportBatchResult {
    pub imported: usize,
    pub skipped: usize,
    pub items: Vec<ImportBatchItemResult>,
}

/// 单条批量导入入参
#[derive(Debug, Clone, Deserialize)]
pub struct ImportBatchItemInput {
    pub path: String,
    #[serde(default)]
    pub name: Option<String>,
}

/// 批量导入历史项目 — 对用户勾选项并发 LLM 抽 description + 入库绑定。
///
/// F-260614-06 决策⑤:扫描(scan_directory_for_projects)纯规则发现;此命令对勾选项
/// 并发跑 LLM(复用 scan_project_with_ai 的 complete 调用)抽 description。每项独立,
/// 非原子 —— 单项失败不影响其它项,逐项结果回传。LLM 全失败 description 留空(不喂噪音),
/// 用户可在详情页手填。
///
/// 限流:llm_concurrency 双层 permit(global + per_conv)防止批量扫描打满 provider。
/// 默认 planning 状态(对齐 create_project),不关联 idea。
///
/// L16:并发上限分块(CHUNK_SIZE=4)——LLM 调用经双层 permit 限流,但 create_with_binding 内
/// 非 LLM IO(detect_stack/canonicalize/DB 查询/insert/reload_allowed_dirs 读全表)无全局限流,
/// 全量并发会产生调度/DB 锁竞争。分块串行处理 chunk、chunk 内并发,结果等价仅削峰。
#[tauri::command]
pub async fn import_projects_batch(
    state: State<'_, AppState>,
    items: Vec<ImportBatchItemInput>,
) -> Result<ImportBatchResult, String> {
    if items.is_empty() {
        return Ok(ImportBatchResult {
            imported: 0,
            skipped: 0,
            items: Vec::new(),
        });
    }

    // 取默认 provider(优先 is_default,否则首个)。无 provider 直接报错(批量无降级路径,
    // 因为 description 是核心目的,无 LLM 与单 import_project 行为不同 —— 那走 import_project)
    let providers = state.ai_providers.list_all().await.map_err(err_str)?;
    let pc = providers
        .iter()
        .find(|p| p.is_default)
        .cloned()
        .or_else(|| providers.into_iter().next())
        .ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string())?;
    // build_provider_for 返回 Box<dyn LlmProvider>(非 Clone);多 future 共享需 Arc 包装。
    // LlmProvider: Send + Sync + complete(&self) → Arc 共享安全。
    let boxed = crate::commands::ai::secret::build_provider_for(&pc)
        .map_err(|e| format!("provider 密钥不可用: {e}"))?;
    let provider: std::sync::Arc<dyn df_ai::provider::LlmProvider> = std::sync::Arc::from(boxed);

    // 每项独立 future,并发 join。失败逐项记录不影响其它。
    // 注:provider 通过 Arc clone 在各 future 间共享(零拷贝,引用计数)。
    //
    // L16:批量上限分块(chunk)。原 join_all 全量并发,虽 LLM 调用经 llm_concurrency 双层 permit
    // 限流(global + per_conv),但 create_with_binding 内仍有重 IO(spawn_blocking detect_stack /
    // normalize_path canonicalize / find_binding_conflict DB 查询 / insert project+module /
    // reload_allowed_dirs 读全表 projects.path)。勾选数十项时全量并发会让这些非 LLM 操作同时
    // 入队,产生 tokio 任务调度压力 + DB 锁竞争排队(reload_allowed_dirs 读全量 projects 表 × N)。
    // 改分块串行处理各 chunk、chunk 内并发:结果与全量并发等价(每项独立无依赖,顺序不影响结果),
    // 仅削平调度/DB 压力峰值。CHUNK_SIZE=4(对齐常见 4 核,与 LLM 限流槽位数同量级)。
    const IMPORT_BATCH_CHUNK_SIZE: usize = 4;
    let mut results: Vec<ImportBatchItemResult> = Vec::with_capacity(items.len());
    // 分块:chunk 内并发 join,chunk 间串行 await,结果按原顺序聚合(与全量 join_all 等价顺序)。
    for chunk in items.chunks(IMPORT_BATCH_CHUNK_SIZE) {
        let chunk_futures: Vec<_> = chunk
            .iter()
            .cloned()
            .map(|item| {
                let state_ref = state.inner();
                let provider = provider.clone();
                let pc = pc.clone();
                async move {
                    let path = item.path.trim().to_string();
                    if path.is_empty() {
                        return ImportBatchItemResult {
                            path,
                            name: None,
                            error: Some("路径为空".to_string()),
                        };
                    }
                    // 走 scan_project_with_ai 同款「探测+采样+LLM 抽 description」(轻量子代理)
                    let desc = match extract_description_via_llm(state_ref, &provider, &pc, &path).await {
                        Ok(d) => d,
                        Err(e) => {
                            // LLM 失败/降级:description 留空,但仍入库(用户手填)。记录原因。
                            tracing::warn!("批量导入 LLM 抽 description 失败 path={path} err={e}");
                            String::new()
                        }
                    };
                    let want_name = item.name.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
                    match create_with_binding(state_ref, resolve_name(&path, want_name), desc, None, Some(path.clone()), None).await {
                        Ok(rec) => ImportBatchItemResult {
                            path,
                            name: Some(rec.name),
                            error: None,
                        },
                        Err(e) => ImportBatchItemResult {
                            path,
                            name: None,
                            error: Some(e),
                        },
                    }
                }
            })
            .collect();
        let chunk_results = futures::future::join_all(chunk_futures).await;
        results.extend(chunk_results);
    }

    let imported = results.iter().filter(|r| r.name.is_some()).count();
    let skipped = results.len() - imported;
    Ok(ImportBatchResult {
        imported,
        skipped,
        items: results,
    })
}

/// 名字解析:入参优先,否则取目录名
fn resolve_name(path: &str, want: Option<String>) -> String {
    if let Some(n) = want {
        return n;
    }
    Path::new(path)
        .file_name()
        .and_then(|n| n.to_str())
        .map(|s| s.to_string())
        .unwrap_or_else(|| path.to_string())
}

/// 复用 scan_project_with_ai 路径抽 description(轻量子代理)。
/// 双层 llm_concurrency permit 限流 + LLM 失败/解析失败返回空 description(不报错)。
async fn extract_description_via_llm(
    state: &AppState,
    provider: &std::sync::Arc<dyn df_ai::provider::LlmProvider>,
    pc: &df_storage::models::AiProviderRecord,
    path: &str,
) -> Result<String, String> {
    let root = std::path::PathBuf::from(path);
    let (rule_stack, sample) = tokio::task::spawn_blocking(move || {
        let stack = detect_stack(&root)?;
        let sample = collect_sample(&root)?;
        Ok::<_, anyhow::Error>((stack, sample))
    })
    .await
    .map_err(err_str)?
    .map_err(err_str)?;

    // F-01 阶段5: 项目扫描描述路由 — 无工具;采样未含图故仅 Text。
    // select_model_id None(池空/无匹配)→ 兜底 default_model(行为不变)。
    let scan_req = TaskRequirements {
        modalities: vec![Modality::Text],
        needs_tool_use: false,
        estimated_context: 0,
        tier: None,
    };
    let scan_model = select_model_id(&scan_req, &pc.model_configs)
        .unwrap_or_else(|| pc.default_model.clone());
    let request = CompletionRequest {
        model: scan_model,
        messages: build_scan_prompt(&sample, &rule_stack),
        temperature: Some(0.2),
        max_tokens: Some(400),
        stream: false,
        tools: None,
        tool_choice: None,
        reasoning_content: None,
    };

    let _g = state.llm_concurrency.acquire_global().await;
    // F-09 B 批5: 项目扫描无 conv_id(非会话内 LLM 调用),用合成 key 共享一槽(扫描低频,同类聚合限流)。
    let _c = state.llm_concurrency.acquire_per_conv("__project_scan__").await;
    let resp = provider.complete(request).await.map_err(err_str)?;
    // 只取 description,其它字段丢弃(批量场景不需要 project_type/stack 细化)
    let desc = parse_scan_result(&resp.text)
        .map(|p| p.description)
        .unwrap_or_default();
    Ok(desc)
}

// ============================================================
// AI 扫描项目 — LLM 分析采样自动填基础信息
// ============================================================

/// AI 扫描项目结果(预览用,用户确认后填入 ProjectRecord)
#[derive(Debug, Serialize)]
pub struct AiScanResult {
    /// LLM 产出的项目摘要(空=LLM 未得出,前端提示手填)
    pub description: String,
    /// 技术栈(规则探测 ∪ LLM 推断,去重小写)
    pub stack: Vec<String>,
    /// 项目类型(web/api/cli/library/desktop/mobile/monorepo/other)
    pub project_type: Option<String>,
    /// LLM 原始返回(降级时含错误信息,前端可展示)
    pub raw: Option<String>,
}

/// AI 扫描项目目录,自动分析基础信息(description/stack/project_type)
///
/// 规则探测(detect_stack)兜底 + LLM 分析采样产出摘要。LLM 失败/解析失败降级纯规则。
/// 需已配置默认 AI provider(无则报错提示去设置)。
#[tauri::command]
pub async fn scan_project_with_ai(
    state: State<'_, AppState>,
    path: String,
) -> Result<AiScanResult, String> {
    let root = Path::new(&path);
    if !root.is_dir() {
        return Err(format!("目录不存在: {}", path));
    }

    // 1. 规则探测(兜底)+ 采样(纯 IO 轻量,spawn_blocking 防 IO 阻塞 tokio runtime)
    let scan_root = std::path::PathBuf::from(&path);
    let (rule_stack, sample) = tokio::task::spawn_blocking(move || {
        let stack = detect_stack(&scan_root)?;
        let sample = collect_sample(&scan_root)?;
        Ok::<_, anyhow::Error>((stack, sample))
    })
    .await
    .map_err(err_str)?
    .map_err(err_str)?;

    // 2. 取默认 provider(优先 is_default,否则首个)
    let providers = state.ai_providers.list_all().await.map_err(err_str)?;
    let pc = providers
        .iter()
        .find(|p| p.is_default)
        .cloned()
        .or_else(|| providers.into_iter().next())
        .ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string())?;

    // 3. 构造 provider + LLM 调用(非流式)
    // build_provider_for 含空 key 早失败:Err → 走纯规则降级(与下方 LLM 失败降级行为一致)
    let provider = match crate::commands::ai::secret::build_provider_for(&pc) {
        Ok(p) => p,
        Err(e) => {
            return Ok(AiScanResult {
                description: String::new(),
                stack: rule_stack,
                project_type: None,
                raw: Some(format!("LLM 调用失败: provider 密钥不可用: {e}")),
            });
        }
    };
    // F-01 阶段5: 项目扫描描述路由 — 无工具;采样未含图故仅 Text。
    // select_model_id None(池空/无匹配)→ 兜底 default_model(行为不变)。
    let scan_req = TaskRequirements {
        modalities: vec![Modality::Text],
        needs_tool_use: false,
        estimated_context: 0,
        tier: None,
    };
    let scan_model = select_model_id(&scan_req, &pc.model_configs)
        .unwrap_or_else(|| pc.default_model.clone());
    let request = CompletionRequest {
        model: scan_model,
        messages: build_scan_prompt(&sample, &rule_stack),
        temperature: Some(0.2),
        max_tokens: Some(400),
        stream: false,
        tools: None,
        tool_choice: None,
        reasoning_content: None,
    };

    // 4. 双层限流 + complete
    let _global_permit = state.llm_concurrency.acquire_global().await;
    // F-09 B 批5: 项目扫描无 conv_id,用合成 key 共享一槽(扫描低频,同类聚合限流)。
    let _per_conv_permit = state.llm_concurrency.acquire_per_conv("__project_scan__").await;
    let llm_result = provider.complete(request).await;

    // 5. 解析 + 合并 stack(LLM 失败降级纯规则)
    match llm_result {
        Ok(resp) => {
            let raw = resp.text.clone();
            match parse_scan_result(&resp.text) {
                Some(p) => {
                    let mut stack = rule_stack;
                    for s in p.stack {
                        let s = s.trim().to_lowercase();
                        if !s.is_empty() && !stack.contains(&s) {
                            stack.push(s);
                        }
                    }
                    Ok(AiScanResult { description: p.description, stack, project_type: p.project_type, raw: Some(raw) })
                }
                None => Ok(AiScanResult { description: String::new(), stack: rule_stack, project_type: None, raw: Some(raw) }),
            }
        }
        Err(e) => Ok(AiScanResult {
            description: String::new(),
            stack: rule_stack,
            project_type: None,
            raw: Some(format!("LLM 调用失败: {e}")),
        }),
    }
}

struct ParsedScan {
    description: String,
    stack: Vec<String>,
    project_type: Option<String>,
}

/// 拼 LLM 扫描 prompt(system + 采样信息)
fn build_scan_prompt(sample: &df_project::scan::ProjectSample, rule_stack: &[String]) -> Vec<ChatMessage> {
    let system = "你是项目分析助手。根据给定的项目采样信息,分析并输出项目基础信息。\n\
严格只输出一个 JSON 对象,不要任何解释、markdown 代码块或额外文字。格式:\n\
{\"description\":\"一句话中文项目摘要,描述项目做什么,30-60字\",\"stack\":[\"技术栈标签(小写英文,如 vue/rust/go)\"],\"project_type\":\"web|api|cli|library|desktop|mobile|monorepo|other\"}\n\
规则:stack 用小写英文标签且去重;description 中文;project_type 从给定枚举选最接近的。若无足够信息,description 填空字符串。";
    let rule = if rule_stack.is_empty() { "(无)".to_string() } else { rule_stack.join(", ") };
    let tree = if sample.tree.is_empty() { "(无)".to_string() } else { sample.tree.join("\n") };
    let readme = sample.readme.clone().unwrap_or_else(|| "(无)".to_string());
    let manifests = if sample.manifests.is_empty() {
        "(无)".to_string()
    } else {
        sample.manifests.iter().map(|(n, c)| format!("### {n}\n{c}")).collect::<Vec<_>>().join("\n\n")
    };
    let user = format!(
        "## 已探测技术栈(规则)\n{rule}\n\n## 目录结构(2层)\n{tree}\n\n## README\n{readme}\n\n## 清单文件\n{manifests}"
    );
    vec![ChatMessage::system(system), ChatMessage::user(user)]
}

/// 解析 LLM 返回的 JSON(容错:直接解析失败则提取首个 {...} 再解析)
fn parse_scan_result(text: &str) -> Option<ParsedScan> {
    let extract = |v: &serde_json::Value| -> Option<ParsedScan> {
        let description = v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string();
        let stack = v
            .get("stack")
            .and_then(|x| x.as_array())
            .map(|arr| arr.iter().filter_map(|x| x.as_str().map(String::from)).collect())
            .unwrap_or_default();
        let project_type = v.get("project_type").and_then(|x| x.as_str()).map(String::from);
        Some(ParsedScan { description, stack, project_type })
    };
    if let Ok(v) = serde_json::from_str::<serde_json::Value>(text) {
        return extract(&v);
    }
    // 提取首个 {...}(LLM 可能裹 markdown 代码块或前后文字)
    let start = text.find('{')?;
    let end = text.rfind('}')?;
    if end <= start {
        return None;
    }
    let v = serde_json::from_str::<serde_json::Value>(&text[start..=end]).ok()?;
    extract(&v)
}
