//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema
//!
//! 不重复实现数据层逻辑,只做:① 工具元数据(name/description/inputSchema)声明;
//! ② 工具调用 → df-storage Repo 方法 → 序列化为 MCP CallToolResult。
//!
//! 安全降级:
//! - [`RiskLevel::High`]:默认拒绝,返「请在 DevFlow 应用内执行」
//! - [`RiskLevel::Medium`]:默认允许 + tracing::warn 审计日志
//! - [`RiskLevel::Low`]:默认允许,只读无副作用
//! - `--read-only`:dispatch 阶段过滤,仅 Low 工具可见
//!
//! handler 形态:`fn(&Ctx, Value) -> BoxFuture<CallToolResult>`(函数指针 + async 块),
//! 避免闭包捕获带来的 Box<dyn> 开销与生命周期问题。

use std::sync::Arc;

use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
use df_storage::db::Database;
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};
use futures::future::BoxFuture;
use serde_json::{json, Value};

use crate::protocol::{CallToolResult, Tool};

// ============================================================
// 风险等级
// ============================================================

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskLevel {
    /// 只读无副作用:list/get
    Low,
    /// 有副作用但可逆:create/update/bind/restore/advance
    Medium,
    /// 不可逆或高破坏:delete/purge/run_workflow → 默认拒绝
    High,
}

// ============================================================
// 工具元数据 + 上下文
// ============================================================

/// 工具元数据 + 风险等级 + handler 函数指针。
pub struct ToolSpec {
    pub tool: Tool,
    pub risk: RiskLevel,
    pub handler: HandlerFn,
}

/// handler 函数指针类型:接收上下文引用 + 参数,返回 boxed future。
pub type HandlerFn = fn(&Ctx, Value) -> BoxFuture<'static, CallToolResult>;

/// 工具执行上下文 — 持有所有 Repo 句柄。每个 handler 内部重新构造 Repo(零开销,Repo 仅持 Arc)。
///
/// 不预存 Repo 是因为 Repo::new 借用 &Database,生命周期管理麻烦;Arc<Database> clone 廉价。
pub struct Ctx {
    pub db: Arc<Database>,
}

impl Ctx {
    pub fn new(db: Arc<Database>) -> Self {
        Self { db }
    }
}

// ============================================================
// inputSchema 构造助手
// ============================================================

fn object_schema(properties: Value, required: &[&str]) -> Value {
    json!({
        "type": "object",
        "properties": properties,
        "required": required,
        "additionalProperties": false
    })
}

fn str_field(desc: &str) -> Value {
    json!({ "type": "string", "description": desc })
}

fn opt_str_field(desc: &str) -> Value {
    json!({ "type": "string", "description": desc })
}

fn int_field(desc: &str) -> Value {
    json!({ "type": "integer", "description": desc })
}

// ============================================================
// 工具清单
// ============================================================

/// 返回全部已注册工具(只读模式由 dispatch 过滤 High/Medium)。
pub fn all_tools() -> Vec<&'static ToolSpec> {
    use RiskLevel::*;
    vec![
        // ─── 项目 ───
        spec("list_projects", "列出所有未删除项目", object_schema(json!({}), &[]), Low, list_projects),
        spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project),
        spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 active)")}), &["name", "description"]), Medium, create_project),
        spec("update_project", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)")}), &["id"]), Medium, update_project),
        spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project),
        spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
        // ─── 任务 ───
        spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)")}), &[]), Low, list_tasks),
        spec("create_task", "创建任务(Medium 风险,默认允许+审计日志)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["project_id", "title", "description"]), Medium, create_task),
        spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), Medium, update_task),
        spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
        spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
        // ─── 灵感 ───
        spec("list_ideas", "列出所有想法/灵感", object_schema(json!({}), &[]), Low, list_ideas),
        spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea),
        spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), Medium, update_idea),
        spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
        spec("evaluate_idea", "对想法做启发式评估(只读:只返分数不写库,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
        spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Medium, score_idea),
        // ─── 工作流(High) ───
        spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow),
        // ─── 回收站 ───
        spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash),
        spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project),
    ]
}

/// 工具元数据构造助手 — Box::leak 静态化(进程生命周期,启动一次性构造)。
#[allow(clippy::too_many_arguments)]
fn spec(
    name: &'static str,
    description: &'static str,
    input_schema: Value,
    risk: RiskLevel,
    handler: HandlerFn,
) -> &'static ToolSpec {
    Box::leak(Box::new(ToolSpec {
        tool: Tool {
            name,
            description,
            input_schema,
        },
        risk,
        handler,
    }))
}

// ============================================================
// 工具查找
// ============================================================

/// 按 name 查找工具(线性扫描,工具数 20,O(n) 足够)。
pub fn find(name: &str) -> Option<&'static ToolSpec> {
    all_tools().into_iter().find(|t| t.tool.name == name)
}

// ============================================================
// handler 公共助手
// ============================================================

fn now_millis() -> String {
    df_types::now_millis().to_string()
}

fn json_ok(v: Value) -> CallToolResult {
    CallToolResult::text(serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()))
}

fn err_str(e: impl std::fmt::Display) -> CallToolResult {
    CallToolResult::error(format!("内部错误: {e}"))
}

fn medium_audit(name: &str, args_summary: &str) {
    tracing::warn!(target: "df_mcp_audit", tool = name, args = %args_summary, "MCP Medium 风险工具被外部调用");
}

/// 取必填字符串参数
fn arg_str(args: &Value, key: &str) -> Result<String, CallToolResult> {
    args.get(key)
        .and_then(|v| v.as_str())
        .map(|s| s.to_owned())
        .ok_or_else(|| CallToolResult::error(format!("缺少必填参数: {key}")))
}

/// 取可选字符串参数(默认值)
fn arg_str_or(args: &Value, key: &str, default: &str) -> String {
    args.get(key)
        .and_then(|v| v.as_str())
        .unwrap_or(default)
        .to_owned()
}

/// 取可选整数参数(默认值)
fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 {
    args.get(key)
        .and_then(|v| v.as_i64())
        .map(|i| i as i32)
        .unwrap_or(default)
}

// ============================================================
// 跨实体校验(防 B-260801-01:跨实体误操作)
// ============================================================
//
// 各实体表(projects / tasks / ideas)独立存储,Repo::get_by_id 只查本表。
// 当 id 实属另一实体(如把 idea id 传给 update_task),本表查询返回 None,
// 旧实现一律报「任务/项目/想法不存在」,错误信息具有误导性,且对 LLM/客户端
// 跨实体误操作无防护(误以为 id 拼错重试,实际是实体类型搞错)。
//
// 此函数在「本表未命中」时跨另两张表探测 id 归属,返回命中的实体名
// (Some("idea") / Some("task") / Some("project")),调用方据此报更精确错误:
// 「id 属于 idea,不能用 update_task 修改」。三表都未命中 → None(真不存在)。
//
// 仅在错误路径(本表 None)执行,正常路径零开销。
//
// 返回值命名约定:中文实体名(对齐 handler 中文错误信息风格),与 handler 名一致。

/// 跨实体探测:id 在另两张表中的归属(None=都不在)。
/// `excluding` 是调用方实体名(本表已查过,跳过避免重复查)。
async fn detect_entity_owner(db: &Arc<Database>, id: &str, excluding: &str) -> Option<&'static str> {
    // 顺序按调用方常见误操作倾向排列(task ↔ idea 互混最常见,project 较少跨)。
    // 三个候选用 if 链(非循环)以静态分发各 Repo,避免 dyn。
    if excluding != "task" {
        if let Ok(Some(_)) = TaskRepo::new(db).get_by_id(id).await {
            return Some("task");
        }
    }
    if excluding != "idea" {
        if let Ok(Some(_)) = IdeaRepo::new(db).get_by_id(id).await {
            return Some("idea");
        }
    }
    if excluding != "project" {
        if let Ok(Some(_)) = ProjectRepo::new(db).get_by_id(id).await {
            return Some("project");
        }
    }
    None
}

/// 跨实体错误信息构造:`excluding` 是当前 handler 期望的实体名,
/// `id` 是客户端传入的 id。若 id 属于其他实体,返回描述性错误串;否则 None。
async fn cross_entity_err(db: &Arc<Database>, id: &str, excluding: &str) -> Option<String> {
    match detect_entity_owner(db, id, excluding).await {
        Some(actual) => Some(format!(
            "id「{id}」属于 {actual},不能用 update_{excluding} 修改(跨实体误操作)"
        )),
        None => None,
    }
}

// ============================================================
// handler 实现 — 项目
// ============================================================

fn list_projects(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    Box::pin(async move {
        let repo = ProjectRepo::new(&db);
        match repo.list_active().await {
            Ok(list) => json_ok(json!({ "projects": list, "count": list.len() })),
            Err(e) => err_str(e),
        }
    })
}

fn get_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let id = match arg_str(&args, "id") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    Box::pin(async move {
        let repo = ProjectRepo::new(&db);
        match repo.get_by_id(&id).await {
            Ok(Some(p)) => json_ok(json!(p)),
            Ok(None) => CallToolResult::error(format!("项目不存在: {id}")),
            Err(e) => err_str(e),
        }
    })
}

fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let name = match arg_str(&args, "name") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    let description = arg_str_or(&args, "description", "");
    let status = arg_str_or(&args, "status", "planning");
    medium_audit("create_project", &name);
    Box::pin(async move {
        let now = now_millis();
        let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
        let rec = ProjectRecord {
            id: new_id(),
            name,
            description,
            status,
            idea_id: None,
            path: None,
            stack: None,
            created_at: now.clone(),
            updated_at: now,
        };
        let repo = ProjectRepo::new(&db);
        match repo.insert(rec).await {
            Ok(id) => {
                let created = repo.get_by_id(&id).await.ok().flatten();
                json_ok(json!({ "id": id, "project": created }))
            }
            Err(e) => err_str(e),
        }
    })
}

fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let id = match arg_str(&args, "id") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    medium_audit("update_project", &id);
    Box::pin(async move {
        let repo = ProjectRepo::new(&db);
        // 先读现有保留 path/stack/idea_id,以及未传字段的回退源(部分更新语义)
        let existing = match repo.get_by_id(&id).await {
            Ok(Some(p)) => p,
            Ok(None) => {
                // 跨实体校验(B-260801-01):id 可能属于 task/idea,给精确错误防误操作
                if let Some(msg) = cross_entity_err(&db, &id, "project").await {
                    return CallToolResult::error(msg);
                }
                return CallToolResult::error(format!("项目不存在: {id}"));
            }
            Err(e) => return err_str(e),
        };
        // 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据
        let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone());
        let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
        let status = arg_str(&args, "status").unwrap_or_else(|_| existing.status.as_str().to_owned());
        let now = now_millis();
        let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
        let rec = ProjectRecord {
            id: id.clone(),
            name,
            description,
            status,
            idea_id: existing.idea_id,
            path: existing.path,
            stack: existing.stack,
            created_at: existing.created_at,
            updated_at: now,
        };
        match repo.update_full(&rec).await {
            Ok(true) => {
                let updated = repo.get_by_id(&id).await.ok().flatten();
                json_ok(json!({ "id": id, "project": updated }))
            }
            Ok(false) => CallToolResult::error(format!("项目不存在: {id}")),
            Err(e) => err_str(e),
        }
    })
}

fn delete_project(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
    // High 风险:默认拒绝(dispatch 兜底 + 此处二次防御,防 dispatch 漏判)
    Box::pin(std::future::ready(CallToolResult::error(
        "High 风险操作(delete_project)默认拒绝,请在 DevFlow 应用内执行。",
    )))
}

fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let id = match arg_str(&args, "id") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    let path = match arg_str(&args, "path") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    medium_audit("bind_directory", &format!("{id} <- {path}"));
    Box::pin(async move {
        let repo = ProjectRepo::new(&db);
        // 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名,
        // 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。
        let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == "..");
        if has_traversal {
            return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path));
        }
        let norm = normalize_path(&path);
        // 路径冲突检测
        if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
            return CallToolResult::error(format!(
                "路径已被项目「{}」({})绑定,请先解绑",
                conflict.name, conflict.id
            ));
        }
        // 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
        if !repo.update_field(&id, "path", &norm).await.unwrap_or(false) {
            return CallToolResult::error(format!("项目不存在: {id}"));
        }
        let updated = repo.get_by_id(&id).await.ok().flatten();
        json_ok(json!({ "id": id, "project": updated }))
    })
}

// ============================================================
// handler 实现 — 任务
// ============================================================

fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let project_id_filter = args.get("project_id").and_then(|v| v.as_str()).map(|s| s.to_owned());
    let status_filter = args.get("status").and_then(|v| v.as_str()).map(|s| s.to_owned());
    Box::pin(async move {
        let repo = TaskRepo::new(&db);
        match repo.list_active().await {
            Ok(mut list) => {
                if let Some(pid) = &project_id_filter {
                    list.retain(|t| t.project_id == *pid);
                }
                if let Some(st) = &status_filter {
                    list.retain(|t| t.status.as_str() == st.as_str());
                }
                json_ok(json!({ "tasks": list, "count": list.len() }))
            }
            Err(e) => err_str(e),
        }
    })
}

fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let project_id = match arg_str(&args, "project_id") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    let title = match arg_str(&args, "title") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    let description = arg_str_or(&args, "description", "");
    let priority = arg_int_or(&args, "priority", 0);
    medium_audit("create_task", &format!("{project_id}/{title}"));
    Box::pin(async move {
        let now = now_millis();
        let rec = TaskRecord {
            id: new_id(),
            project_id,
            title,
            description,
            status: TaskStatus::Todo,
            priority,
            branch_name: None,
            assignee: None,
            workflow_def_id: None,
            base_branch: None,
            review_rounds: 0,
            output_json: None,
            idea_id: None,
            queue: "todo".to_string(),
            parent_id: None,
            content_json: None,
            created_at: now.clone(),
            updated_at: now,
        };
        let repo = TaskRepo::new(&db);
        match repo.insert(rec).await {
            Ok(id) => {
                let created = repo.get_by_id(&id).await.ok().flatten();
                json_ok(json!({ "id": id, "task": created }))
            }
            Err(e) => err_str(e),
        }
    })
}

fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let id = match arg_str(&args, "id") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    medium_audit("update_task", &id);
    Box::pin(async move {
        let repo = TaskRepo::new(&db);
        let existing = match repo.get_by_id(&id).await {
            Ok(Some(t)) => t,
            Ok(None) => {
                // 跨实体校验(B-260801-01):id 可能属于 project/idea,给精确错误防误操作
                if let Some(msg) = cross_entity_err(&db, &id, "task").await {
                    return CallToolResult::error(msg);
                }
                return CallToolResult::error(format!("任务不存在: {id}"));
            }
            Err(e) => return err_str(e),
        };
        // 部分更新:project_id/title/description 缺省回退 existing,避免空默认清空数据
        let project_id = arg_str(&args, "project_id").unwrap_or_else(|_| existing.project_id.clone());
        let title = arg_str(&args, "title").unwrap_or_else(|_| existing.title.clone());
        let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
        let now = now_millis();
        let rec = TaskRecord {
            id: id.clone(),
            project_id,
            title,
            description,
            // 不允许经 MCP 改状态(状态机收口,须走 advance_task)
            status: existing.status,
            priority: existing.priority,
            branch_name: existing.branch_name,
            assignee: existing.assignee,
            workflow_def_id: existing.workflow_def_id,
            base_branch: existing.base_branch,
            review_rounds: existing.review_rounds,
            output_json: existing.output_json,
            idea_id: existing.idea_id,
            queue: existing.queue,
            parent_id: existing.parent_id,
            content_json: existing.content_json,
            created_at: existing.created_at,
            updated_at: now,
        };
        match repo.update_full(&rec).await {
            Ok(true) => {
                let updated = repo.get_by_id(&id).await.ok().flatten();
                json_ok(json!({ "id": id, "task": updated }))
            }
            Ok(false) => CallToolResult::error(format!("任务不存在: {id}")),
            Err(e) => err_str(e),
        }
    })
}

fn advance_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let id = match arg_str(&args, "id") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    // 不再接收 from:推进链内部读当前态 + 三层校验(is_valid_state / can_transition /
    // 同态拒绝),避免外部客户端直调 advance_status_atomic 绕过状态机非法跳态(todo→done)。
    let to = match arg_str(&args, "to") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    medium_audit("advance_task", &format!("{id} -> {to}"));
    Box::pin(async move {
        let repo = TaskRepo::new(&db);
        // 复用推进链唯一 status 写入路径(与 IPC advance_task 同源):
        //   - is_valid_state + can_transition + 同态拒绝三层校验
        //   - CAS 防 TOCTOU
        //   - is_regression 自动判定 bump review_rounds(取代旧内联 bump 副本)
        //   - 错误类型(NotFound/Validation/InvalidState)由 thiserror Display 串化
        match df_nodes::task_advance_node::advance_task_atomic(&repo, &id, &to).await {
            Ok(updated) => json_ok(json!({ "id": id, "task": updated })),
            Err(e) => err_str(e),
        }
    })
}

fn delete_task(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
    Box::pin(std::future::ready(CallToolResult::error(
        "High 风险操作(delete_task)默认拒绝,请在 DevFlow 应用内执行。",
    )))
}

// ============================================================
// handler 实现 — 灵感
// ============================================================

fn list_ideas(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    Box::pin(async move {
        let repo = IdeaRepo::new(&db);
        match repo.list_all().await {
            Ok(list) => json_ok(json!({ "ideas": list, "count": list.len() })),
            Err(e) => err_str(e),
        }
    })
}

fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let title = match arg_str(&args, "title") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    let description = arg_str_or(&args, "description", "");
    let priority = arg_int_or(&args, "priority", 0);
    medium_audit("create_idea", &title);
    Box::pin(async move {
        let now = now_millis();
        let rec = IdeaRecord {
            id: new_id(),
            title,
            description,
            status: IdeaStatus::Draft,
            priority,
            score: None,
            tags: None,
            source: Some("mcp".to_owned()),
            promoted_to: None,
            ai_analysis: None,
            scores: None,
            related_ids: None,
            created_at: now.clone(),
            updated_at: now,
        };
        let repo = IdeaRepo::new(&db);
        match repo.insert(rec).await {
            Ok(id) => {
                let created = repo.get_by_id(&id).await.ok().flatten();
                json_ok(json!({ "id": id, "idea": created }))
            }
            Err(e) => err_str(e),
        }
    })
}

fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let id = match arg_str(&args, "id") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    medium_audit("update_idea", &id);
    Box::pin(async move {
        let repo = IdeaRepo::new(&db);
        let existing = match repo.get_by_id(&id).await {
            Ok(Some(i)) => i,
            Ok(None) => {
                // 跨实体校验(B-260801-01):id 可能属于 project/task,给精确错误防误操作
                if let Some(msg) = cross_entity_err(&db, &id, "idea").await {
                    return CallToolResult::error(msg);
                }
                return CallToolResult::error(format!("想法不存在: {id}"));
            }
            Err(e) => return err_str(e),
        };
        // 部分更新:title/description 缺省回退 existing,避免空默认清空数据
        let title = arg_str(&args, "title").unwrap_or_else(|_| existing.title.clone());
        let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
        let now = now_millis();
        let rec = IdeaRecord {
            id: id.clone(),
            title,
            description,
            status: existing.status,
            priority: existing.priority,
            score: existing.score,
            tags: existing.tags,
            source: existing.source,
            promoted_to: existing.promoted_to,
            ai_analysis: existing.ai_analysis,
            scores: existing.scores,
            related_ids: existing.related_ids.clone(),
            created_at: existing.created_at,
            updated_at: now,
        };
        match repo.update_full(&rec).await {
            Ok(true) => {
                let updated = repo.get_by_id(&id).await.ok().flatten();
                json_ok(json!({ "id": id, "idea": updated }))
            }
            Ok(false) => CallToolResult::error(format!("想法不存在: {id}")),
            Err(e) => err_str(e),
        }
    })
}

fn delete_idea(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
    Box::pin(std::future::ready(CallToolResult::error(
        "High 风险操作(delete_idea)默认拒绝,请在 DevFlow 应用内执行。",
    )))
}

/// 对想法做启发式评估(**只读,纯计算**):基于 description/title 计算
/// feasibility/impact/urgency/overall,只返分数不写库(对齐 Low=只读契约)。
///
/// 需要把分数写回 DB 的,用 [`score_idea`](Medium 风险,写库)。
fn evaluate_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let id = match arg_str(&args, "id") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    Box::pin(async move {
        let repo = IdeaRepo::new(&db);
        let idea = match repo.get_by_id(&id).await {
            Ok(Some(i)) => i,
            Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
            Err(e) => return err_str(e),
        };
        // 启发式评分(本地确定性纯函数,不调 LLM,不写库)
        let scores = heuristic_scores(&idea.title, &idea.description);
        // 原样回 idea(未改库),仅供客户端预览;写库请走 score_idea
        json_ok(json!({ "id": id, "idea": idea, "scores": scores }))
    })
}

/// 评分并写库(Medium 风险):对想法做启发式评估,把 scores 写回 DB,
/// 返回更新后的记录 + scores。read-only 模式会被 dispatch 拒绝。
///
/// 评分逻辑与 [`evaluate_idea`](Low 只读)共用 [`heuristic_scores`] 纯函数,
/// 唯一差异是这里做 `update_full`(写副作用 → Medium)。
fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let id = match arg_str(&args, "id") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    medium_audit("score_idea", &id);
    Box::pin(async move {
        let repo = IdeaRepo::new(&db);
        let idea = match repo.get_by_id(&id).await {
            Ok(Some(i)) => i,
            Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
            Err(e) => return err_str(e),
        };
        // 与 evaluate_idea 共用的纯函数评分
        let scores = heuristic_scores(&idea.title, &idea.description);
        let now = now_millis();
        // 写回 scores 字段(整体更新)
        let mut rec = idea.clone();
        rec.scores = Some(serde_json::to_string(&scores).unwrap_or_default());
        rec.updated_at = now;
        if let Err(e) = repo.update_full(&rec).await {
            return err_str(e);
        }
        json_ok(json!({ "id": id, "idea": rec, "scores": scores }))
    })
}

/// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。
/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai。
fn heuristic_scores(title: &str, description: &str) -> Value {
    let desc_len = description.chars().count();
    let title_len = title.chars().count();
    // feasibility:描述越详细越可行(评估前已有思考)
    let feasibility = ((desc_len as f64 / 200.0).min(1.0) * 6.0 + 3.0).min(9.0);
    // impact:含「核心/关键/重要」等关键词加权
    let impact_keywords = ["核心", "关键", "重要", "紧急", "blocker", "critical", "core"];
    let kw_hits = impact_keywords.iter().filter(|k| title.contains(*k) || description.contains(*k)).count();
    let impact = (5.0 + kw_hits as f64 * 1.5).min(9.0);
    // urgency:priority 字段不在此,用关键词近似
    let urgency_kw = ["紧急", "urgent", "asap", "立即", "马上"];
    let urgency_hits = urgency_kw.iter().filter(|k| title.contains(*k) || description.contains(*k)).count();
    let urgency = (4.0 + urgency_hits as f64 * 2.0).min(9.0);
    // overall:加权平均(feasibility/impact/urgency = 0.4/0.4/0.2)
    let overall = feasibility * 0.4 + impact * 0.4 + urgency * 0.2;
    let _ = title_len; // 标题长度暂不入分(避免短标题被低估)
    json!({
        "feasibility": (feasibility * 10.0).round() / 10.0,
        "impact": (impact * 10.0).round() / 10.0,
        "urgency": (urgency * 10.0).round() / 10.0,
        "overall": (overall * 10.0).round() / 10.0
    })
}

// ============================================================
// handler 实现 — 工作流(High,拒绝)
// ============================================================

fn run_workflow(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
    Box::pin(std::future::ready(CallToolResult::error(
        "High 风险操作(run_workflow)默认拒绝。工作流涉及代码生成/审查/分支操作,请在 DevFlow 应用内执行。",
    )))
}

// ============================================================
// handler 实现 — 回收站
// ============================================================

fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    Box::pin(async move {
        let projects = match ProjectRepo::new(&db).list_deleted().await {
            Ok(v) => v,
            Err(e) => return err_str(e),
        };
        let tasks = match TaskRepo::new(&db).list_deleted().await {
            Ok(v) => v,
            Err(e) => return err_str(e),
        };
        json_ok(json!({
            "projects": projects,
            "tasks": tasks,
            "project_count": projects.len(),
            "task_count": tasks.len()
        }))
    })
}

fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
    let db = ctx.db.clone();
    let id = match arg_str(&args, "id") {
        Ok(v) => v,
        Err(r) => return Box::pin(std::future::ready(r)),
    };
    medium_audit("restore_project", &id);
    Box::pin(async move {
        match ProjectRepo::new(&db).restore(&id).await {
            Ok(true) => json_ok(json!({ "id": id, "restored": true })),
            Ok(false) => CallToolResult::error(format!("恢复失败(项目不在回收站): {id}")),
            Err(e) => err_str(e),
        }
    })
}

// ============================================================
// 路径规范化(镜像 df_project::scan::normalize_path,本 crate 不依赖 df-project)
// ============================================================

fn normalize_path(p: &str) -> String {
    match std::path::Path::new(p).canonicalize() {
        Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
        Err(_) => p
            .trim_end_matches(['\\', '/'])
            .replace('\\', "/")
            .to_lowercase(),
    }
}

// ============================================================
// 单测:evaluate_idea(只读,不写库)/ score_idea(写库)/ 风险契约
// ============================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::protocol::ContentBlock;
    use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
    use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
    use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};

    /// 构造内存 DB + Ctx
    async fn test_ctx() -> Ctx {
        let db = Arc::new(Database::open_in_memory().await.unwrap());
        Ctx::new(db)
    }

    /// 从 CallToolResult 提取文本内容
    fn text_of(r: &CallToolResult) -> &str {
        match &r.content[0] {
            ContentBlock::Text { text } => text,
        }
    }

    /// 取 CallToolResult 的 JSON 文本并解析为 Value
    fn json_of(r: &CallToolResult) -> Value {
        serde_json::from_str(text_of(r)).unwrap()
    }

    /// 插入一条想法,返回 (id, 原始 scores)
    async fn seed_idea(ctx: &Ctx, title: &str, desc: &str) -> String {
        let repo = IdeaRepo::new(&ctx.db);
        let now = now_millis();
        let rec = IdeaRecord {
            id: new_id(),
            title: title.to_owned(),
            description: desc.to_owned(),
            status: IdeaStatus::Draft,
            priority: 0,
            score: None,
            tags: None,
            source: Some("test".to_owned()),
            promoted_to: None,
            ai_analysis: None,
            scores: None,
            related_ids: None,
            created_at: now.clone(),
            updated_at: now,
        };
        repo.insert(rec).await.unwrap()
    }

    /// 插入一条项目,返回 id(跨实体校验测试用)
    async fn seed_project(ctx: &Ctx, name: &str) -> String {
        let repo = ProjectRepo::new(&ctx.db);
        let now = now_millis();
        let rec = ProjectRecord {
            id: new_id(),
            name: name.to_owned(),
            description: String::new(),
            status: ProjectStatus::Planning,
            idea_id: None,
            path: None,
            stack: None,
            created_at: now.clone(),
            updated_at: now,
        };
        repo.insert(rec).await.unwrap()
    }

    /// 插入一条任务,返回 id(跨实体校验测试用)
    async fn seed_task(ctx: &Ctx, project_id: &str, title: &str) -> String {
        let repo = TaskRepo::new(&ctx.db);
        let now = now_millis();
        let rec = TaskRecord {
            id: new_id(),
            project_id: project_id.to_owned(),
            title: title.to_owned(),
            description: String::new(),
            status: TaskStatus::Todo,
            priority: 0,
            branch_name: None,
            assignee: None,
            workflow_def_id: None,
            base_branch: None,
            review_rounds: 0,
            output_json: None,
            idea_id: None,
            queue: "todo".to_string(),
            parent_id: None,
            content_json: None,
            created_at: now.clone(),
            updated_at: now,
        };
        repo.insert(rec).await.unwrap()
    }

    /// 读当前 DB 中的 idea.scores(原始字符串)
    async fn db_scores(ctx: &Ctx, id: &str) -> Option<String> {
        IdeaRepo::new(&ctx.db)
            .get_by_id(id)
            .await
            .unwrap()
            .and_then(|i| i.scores)
    }

    // ── evaluate_idea:Low 只读契约 ──────────────────────────────────

    #[tokio::test]
    async fn evaluate_idea_returns_scores_without_writing_db() {
        let ctx = test_ctx().await;
        let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块以解除阻塞").await;

        let r = evaluate_idea(&ctx, json!({ "id": id })).await;
        assert!(r.is_error.is_none(), "evaluate_idea 不应返回错误");

        let v = json_of(&r);
        assert_eq!(v["id"], id);
        // scores 维度齐
        assert!(v["scores"]["feasibility"].is_number());
        assert!(v["scores"]["impact"].is_number());
        assert!(v["scores"]["urgency"].is_number());
        assert!(v["scores"]["overall"].is_number());

        // 契约核心:DB 中 scores 仍为 None(没写库)
        assert!(
            db_scores(&ctx, &id).await.is_none(),
            "evaluate_idea 违反只读契约:DB scores 被写"
        );
    }

    #[tokio::test]
    async fn evaluate_idea_missing_id_arg_errors() {
        let ctx = test_ctx().await;
        let r = evaluate_idea(&ctx, json!({})).await;
        assert_eq!(r.is_error, Some(true));
        assert!(text_of(&r).contains("缺少必填参数"));
    }

    #[tokio::test]
    async fn evaluate_idea_unknown_id_errors() {
        let ctx = test_ctx().await;
        let r = evaluate_idea(&ctx, json!({ "id": "no-such-id" })).await;
        assert_eq!(r.is_error, Some(true));
        assert!(text_of(&r).contains("想法不存在"));
    }

    // ── score_idea:Medium 写库契约 ──────────────────────────────────

    #[tokio::test]
    async fn score_idea_writes_scores_to_db() {
        let ctx = test_ctx().await;
        let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块以解除阻塞").await;
        // 前置:写前 DB scores 为空
        assert!(db_scores(&ctx, &id).await.is_none());

        let r = score_idea(&ctx, json!({ "id": id })).await;
        assert!(r.is_error.is_none(), "score_idea 不应返回错误");

        let v = json_of(&r);
        assert_eq!(v["id"], id);
        let scores_str = v["idea"]["scores"].as_str();
        assert!(scores_str.is_some(), "返回的 idea.scores 应非空(已写库)");
        let persisted = db_scores(&ctx, &id).await;
        assert!(persisted.is_some(), "DB scores 应已写入");
        // 返回值里的 scores 字符串 == DB 持久化的字符串(一致性)
        assert_eq!(scores_str.unwrap(), persisted.as_deref().unwrap());
    }

    #[tokio::test]
    async fn score_idea_unknown_id_errors() {
        let ctx = test_ctx().await;
        let r = score_idea(&ctx, json!({ "id": "no-such-id" })).await;
        assert_eq!(r.is_error, Some(true));
        assert!(text_of(&r).contains("想法不存在"));
    }

    // ── 风险契约(工具注册表)──────────────────────────────────────
    //
    // 锁定拆分的根本契约:evaluate_idea=Low(只读,read-only 放行),
    // score_idea=Medium(写库,read-only 拒)。改回合并即此测会红。

    #[test]
    fn evaluate_idea_is_low_and_score_idea_is_medium() {
        let eval = find("evaluate_idea").expect("evaluate_idea 必须注册");
        let score = find("score_idea").expect("score_idea 必须注册");
        assert_eq!(
            eval.risk,
            RiskLevel::Low,
            "evaluate_idea 必须 Low(只读契约)"
        );
        assert_eq!(
            score.risk,
            RiskLevel::Medium,
            "score_idea 必须 Medium(写库 → read-only 拒)"
        );
    }

    /// read-only 可见性:end-to-end 验证 dispatch 层对两个工具的过滤。
    /// (与 server.rs 测试呼应,锁定 read-only 放 evaluate / 拒 score 的契约)
    #[test]
    fn read_only_visibility_splits_evaluate_and_score() {
        // read-only:evaluate(Low)可见,score(Medium)不可见
        assert!(visible_for_test(true, "evaluate_idea"));
        assert!(!visible_for_test(true, "score_idea"));
        // 非 read-only:两者都可见
        assert!(visible_for_test(false, "evaluate_idea"));
        assert!(visible_for_test(false, "score_idea"));
    }

    // 辅助:复用 server.rs 的 visible 谓词语义(本地重写,避免跨模块私有依赖)
    fn visible_for_test(read_only: bool, name: &str) -> bool {
        let spec = find(name).expect("工具存在");
        if read_only {
            spec.risk == RiskLevel::Low
        } else {
            spec.risk != RiskLevel::High
        }
    }

    // ── heuristic_scores 纯函数:两工具共用,确定性 ──────────────────

    #[test]
    fn heuristic_scores_is_deterministic_and_bounded() {
        let a = heuristic_scores("核心功能", "这是非常重要的关键模块,需要紧急处理");
        let b = heuristic_scores("核心功能", "这是非常重要的关键模块,需要紧急处理");
        assert_eq!(a, b, "相同输入应得相同分数(纯函数)");

        let s = &a;
        for k in ["feasibility", "impact", "urgency", "overall"] {
            let v = s[k].as_f64().unwrap();
            assert!(
                (0.0..=9.0).contains(&v),
                "{k} 分数 {v} 越界 [0,9]"
            );
        }
    }

    // ── 跨实体校验(B-260801-01):update_* 检测 id 属于其他实体时报精确错误 ──
    //
    // 各实体表独立,Repo::get_by_id 只查本表。当 id 实属另一实体时,
    // 旧实现只报「任务/项目/想法不存在」(误导),改后报「id 属于 X,不能用 update_Y 修改」。

    /// update_task 传入 idea id → 报跨实体错误,不报「任务不存在」。
    #[tokio::test]
    async fn update_task_with_idea_id_reports_cross_entity() {
        let ctx = test_ctx().await;
        let idea_id = seed_idea(&ctx, "灵感A", "误传给 update_task").await;

        let r = update_task(&ctx, json!({ "id": idea_id, "title": "x" })).await;
        assert_eq!(r.is_error, Some(true));
        let msg = text_of(&r);
        assert!(
            msg.contains("属于 idea") && msg.contains("update_task"),
            "应报跨实体错误,实际: {msg}"
        );
        // 不应回退到模糊的「任务不存在」
        assert!(!msg.contains("任务不存在"), "不应是模糊错误: {msg}");
    }

    /// update_task 传入 project id → 报跨实体错误。
    #[tokio::test]
    async fn update_task_with_project_id_reports_cross_entity() {
        let ctx = test_ctx().await;
        let pid = seed_project(&ctx, "项目P").await;

        let r = update_task(&ctx, json!({ "id": pid, "title": "x" })).await;
        assert_eq!(r.is_error, Some(true));
        let msg = text_of(&r);
        assert!(
            msg.contains("属于 project") && msg.contains("update_task"),
            "应报跨实体错误,实际: {msg}"
        );
    }

    /// update_project 传入 idea id → 报跨实体错误。
    #[tokio::test]
    async fn update_project_with_idea_id_reports_cross_entity() {
        let ctx = test_ctx().await;
        let idea_id = seed_idea(&ctx, "灵感B", "误传给 update_project").await;

        let r = update_project(&ctx, json!({ "id": idea_id, "name": "x" })).await;
        assert_eq!(r.is_error, Some(true));
        let msg = text_of(&r);
        assert!(
            msg.contains("属于 idea") && msg.contains("update_project"),
            "应报跨实体错误,实际: {msg}"
        );
        assert!(!msg.contains("项目不存在"), "不应是模糊错误: {msg}");
    }

    /// update_project 传入 task id → 报跨实体错误。
    #[tokio::test]
    async fn update_project_with_task_id_reports_cross_entity() {
        let ctx = test_ctx().await;
        let pid = seed_project(&ctx, "项目宿主").await;
        let tid = seed_task(&ctx, &pid, "任务T").await;

        let r = update_project(&ctx, json!({ "id": tid, "name": "x" })).await;
        assert_eq!(r.is_error, Some(true));
        let msg = text_of(&r);
        assert!(
            msg.contains("属于 task") && msg.contains("update_project"),
            "应报跨实体错误,实际: {msg}"
        );
    }

    /// update_idea 传入 task id → 报跨实体错误。
    #[tokio::test]
    async fn update_idea_with_task_id_reports_cross_entity() {
        let ctx = test_ctx().await;
        let pid = seed_project(&ctx, "项目宿主").await;
        let tid = seed_task(&ctx, &pid, "任务T2").await;

        let r = update_idea(&ctx, json!({ "id": tid, "title": "x" })).await;
        assert_eq!(r.is_error, Some(true));
        let msg = text_of(&r);
        assert!(
            msg.contains("属于 task") && msg.contains("update_idea"),
            "应报跨实体错误,实际: {msg}"
        );
        assert!(!msg.contains("想法不存在"), "不应是模糊错误: {msg}");
    }

    /// update_idea 传入 project id → 报跨实体错误。
    #[tokio::test]
    async fn update_idea_with_project_id_reports_cross_entity() {
        let ctx = test_ctx().await;
        let pid = seed_project(&ctx, "项目P2").await;

        let r = update_idea(&ctx, json!({ "id": pid, "title": "x" })).await;
        assert_eq!(r.is_error, Some(true));
        let msg = text_of(&r);
        assert!(
            msg.contains("属于 project") && msg.contains("update_idea"),
            "应报跨实体错误,实际: {msg}"
        );
    }

    /// 三表都不存在的真随机 id → 仍报原「不存在」错误(跨实体校验不应改变此行为)。
    #[tokio::test]
    async fn update_task_with_unknown_id_still_reports_not_found() {
        let ctx = test_ctx().await;
        let r = update_task(&ctx, json!({ "id": "ghost-id-12345", "title": "x" })).await;
        assert_eq!(r.is_error, Some(true));
        let msg = text_of(&r);
        assert!(
            msg.contains("任务不存在"),
            "三表都无此 id 应回退到原「不存在」错误,实际: {msg}"
        );
    }

    /// 正常路径:update_task 传入真实 task id → 成功(校验不应破坏正常路径)。
    #[tokio::test]
    async fn update_task_with_real_task_id_succeeds() {
        let ctx = test_ctx().await;
        let pid = seed_project(&ctx, "项目宿主").await;
        let tid = seed_task(&ctx, &pid, "原标题").await;

        let r = update_task(&ctx, json!({ "id": tid, "title": "新标题" })).await;
        assert!(r.is_error.is_none(), "正常路径不应报错: {:?}", text_of(&r));
        let v = json_of(&r);
        assert_eq!(v["task"]["title"], "新标题");
    }

    /// 跨实体探测纯逻辑:三实体互查正确性。
    #[tokio::test]
    async fn detect_entity_owner_returns_correct_entity() {
        let ctx = test_ctx().await;
        let pid = seed_project(&ctx, "项目").await;
        let tid = seed_task(&ctx, &pid, "任务").await;
        let iid = seed_idea(&ctx, "灵感", "x").await;

        // 各 id 跨表探测应返回其真实所属实体(注意:不包括 excluding 自身)
        assert_eq!(detect_entity_owner(&ctx.db, &tid, "task").await, None, "task 自身被排除");
        assert_eq!(detect_entity_owner(&ctx.db, &tid, "project").await, Some("task"));
        assert_eq!(detect_entity_owner(&ctx.db, &tid, "idea").await, Some("task"));

        assert_eq!(detect_entity_owner(&ctx.db, &iid, "idea").await, None, "idea 自身被排除");
        assert_eq!(detect_entity_owner(&ctx.db, &iid, "task").await, Some("idea"));

        assert_eq!(detect_entity_owner(&ctx.db, &pid, "project").await, None, "project 自身被排除");
        assert_eq!(detect_entity_owner(&ctx.db, &pid, "task").await, Some("project"));

        // 三表都没有 → None
        assert_eq!(detect_entity_owner(&ctx.db, "ghost", "task").await, None);
    }
}
