//! api_key 密钥管理 — 真实密钥存 OS keyring,DB `api_key` 列迁移后存空串。
//!
//! **下沉层(方案 B,2026-06-16)**：原位于 `src-tauri/src/commands/ai/secret.rs`,
//! 下沉纯密钥逻辑(get/set/delete/resolve/ensure/migrate + failcount sidecar)到 df-storage,
//! 供 df-nodes AiNode 与 src-tauri 转发壳共用(密钥解析唯一源,DRY)。
//!
//! **不下沉项**:`build_provider_for`(依赖 `df_ai::build_provider`)——df-storage 不依赖
//! df-ai,下沉会引 df-storage→df-ai 反向依赖。`build_provider_for` 留 src-tauri 转发壳,
//! 内部调本模块 `resolve_provider_secret` + `ensure_resolved_key` + `df_ai::build_provider`。
//!
//! 设计:
//! - keyring entry: service=`devflow-ai-provider`, username=provider_id
//! - DB `api_key` 列恒空(迁移后/新建均空),真实密钥唯一源 = OS keyring
//! - 启动一次性迁移:`migrate_secrets_to_keyring` 读老明文 → keyring → DB 置空
//!   (失败累计达阈值前保留明文下次重试;达阈值后清除明文 + 强制重配,防长期滞留)
//! - 消费点(build_provider)经 `resolve_provider_secret` 取:DB 优先,fallback keyring(兼容未迁移)
//! - 跨平台:Windows Credential Manager / macOS Keychain / Linux Secret Service

use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

use crate::crud::AiProviderRepo;
use crate::models::AiProviderRecord;
use keyring::Entry;

const KEYRING_SERVICE: &str = "devflow-ai-provider";

/// 迁移失败计数器阈值:同一 provider 累计失败到此次数 → **清除 DB 明文 + error 告警**(强制重配)。
/// 跨启动持久化(sidecar 文件)。阈值前仅累计 + warn + 保留明文下次重试(兼容临时性 keyring 故障);
/// 达阈值后清明文防长期滞留(devflow.db 无文件级加密,明文 = 持续暴露)。
const MIGRATION_FAIL_THRESHOLD: u32 = 3;

/// 迁移失败计数 sidecar 文件(<cwd>/.devflow-keyring-failcount):逐行 `provider_id=count`。
/// cwd 未必是稳定路径,但 R-PD-4 目标仅是「检测到反复失败/滞留时告警」,误读为 0 即按未达阈值处理,无副作用。
fn failcount_path() -> PathBuf {
    std::env::current_dir()
        .unwrap_or_else(|_| PathBuf::from("."))
        .join(".devflow-keyring-failcount")
}

/// 读取全部失败计数(id → count)。文件缺失/损坏 → 空 map(按未达阈值处理)。
fn read_failcounts() -> HashMap<String, u32> {
    let mut map = HashMap::new();
    if let Ok(text) = fs::read_to_string(failcount_path()) {
        for line in text.lines() {
            let mut parts = line.splitn(2, '=');
            let id = parts.next().unwrap_or("").trim();
            let cnt = parts.next().and_then(|s| s.trim().parse::<u32>().ok());
            if !id.is_empty() {
                if let Some(c) = cnt {
                    map.insert(id.to_string(), c);
                }
            }
        }
    }
    map
}

/// 持久化全部失败计数。写入失败仅 log,不阻断迁移主流程。
fn write_failcounts(map: &HashMap<String, u32>) {
    let mut text = String::new();
    let mut entries: Vec<_> = map.iter().collect();
    entries.sort_by(|a, b| a.0.cmp(b.0)); // 稳定顺序,减少无谓 diff
    for (id, cnt) in entries {
        text.push_str(id);
        text.push('=');
        text.push_str(&cnt.to_string());
        text.push('\n');
    }
    if let Err(e) = fs::write(failcount_path(), text) {
        tracing::debug!("[密钥迁移] 迁移失败计数文件写入失败(忽略): {}", e);
    }
}

/// 记录一次迁移失败并返回累计失败次数。持久化失败也不影响返回值(仍递增内存计数用于本次告警)。
fn record_migration_fail(id: &str) -> u32 {
    let mut map = read_failcounts();
    let next = map.get(id).copied().unwrap_or(0).saturating_add(1);
    map.insert(id.to_string(), next);
    write_failcounts(&map);
    next
}

/// 清零某 provider 的失败计数(迁移成功后调用,避免历史失败在后续再触发误告警)。
fn clear_migration_failcount(id: &str) {
    let mut map = read_failcounts();
    if map.remove(id).is_some() {
        write_failcounts(&map);
    }
}

fn entry_for(id: &str) -> anyhow::Result<Entry> {
    Entry::new(KEYRING_SERVICE, id).map_err(|e| anyhow::anyhow!("keyring entry 创建失败(provider={}): {}", id, e))
}

/// 读取 provider 密钥(优先 keyring;无则 None)
pub fn get_provider_secret(id: &str) -> Option<String> {
    let entry = entry_for(id).ok()?;
    match entry.get_password() {
        Ok(s) if !s.is_empty() => Some(s),
        _ => None,
    }
}

/// [`get_provider_secret`] 的 async 版本:`spawn_blocking` 隔离同步 keyring 调用。
///
/// keyring 在 Linux secret-service D-Bus / Windows COM / macOS Keychain 同步阻塞,直接在
/// async 上下文调用会卡 tokio runtime(Tauri 单线程 runtime 尤其敏感)。async 路径优先用此版本;
/// 同步版保留供 block_on / 非 async 路径(迁移、测试)使用。
pub async fn get_provider_secret_async(id: String) -> Option<String> {
    tokio::task::spawn_blocking(move || get_provider_secret(&id))
        .await
        .ok()
        .flatten()
}

/// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库)
///
/// **不静默压空**:keyring 无记录/读取故障(None 已合并 keyring Err)时,这里**不**用 `unwrap_or_default()`
/// 静默吞成空串 —— 那会让调用方拿空 api_key 发请求吃 401,错误伪装成「密钥无效」且无线索。改为
/// warn 留痕(区分「真正未配置密钥」与「keyring 后端故障」),仍返空串交由下游 `ensure_resolved_key`
/// 早失败给出用户可读错误 —— 签名不变,调用方零改动。
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
    if !record.api_key.is_empty() {
        return record.api_key.clone();
    }
    match get_provider_secret(&record.id) {
        Some(k) => k,
        None => {
            tracing::warn!(
                "[密钥解析] provider {} (id={}) 系统钥匙串无密钥或读取故障 —— \
                 下游 ensure_resolved_key 将报「密钥缺失」。排查:1) 设置中是否保存过密钥;\
                 2) OS 钥匙串后端是否可用(Win Credential Manager / macOS Keychain / Linux Secret Service)",
                record.name, record.id
            );
            String::new()
        }
    }
}

/// [`resolve_provider_secret`] 的 async 版本 — DB 有明文时同步返(不触 keyring),
/// 否则 `spawn_blocking` 调 keyring 防 D-Bus / COM 阻塞 tokio runtime。
///
/// 注:DB 明文路径直接 clone 同步返,只有 fallback keyring 才走 spawn_blocking。
///
/// **不静默压空**:同同步版,keyring 无记录/读取故障时 warn 留痕(不吞成空串致 401 难定位),
/// 仍返空串交由下游 `ensure_resolved_key` 早失败给用户可读错误 —— 签名不变,调用方零改动。
pub async fn resolve_provider_secret_async(record: AiProviderRecord) -> String {
    if !record.api_key.is_empty() {
        return record.api_key;
    }
    // 先取 id/name 再 await,避免 record 部分移动后无法在 warn 中引用。
    let id = record.id.clone();
    let name = record.name.clone();
    match get_provider_secret_async(id.clone()).await {
        Some(k) => k,
        None => {
            tracing::warn!(
                "[密钥解析] provider {} (id={}) 系统钥匙串无密钥或读取故障 —— \
                 下游 ensure_resolved_key 将报「密钥缺失」。排查:1) 设置中是否保存过密钥;\
                 2) OS 钥匙串后端是否可用(Win Credential Manager / macOS Keychain / Linux Secret Service)",
                name, id
            );
            String::new()
        }
    }
}

/// 写入密钥到 keyring(覆盖)
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
    let entry = entry_for(id)?;
    entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e))
}

/// [`set_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
pub async fn set_provider_secret_async(id: String, key: String) -> anyhow::Result<()> {
    tokio::task::spawn_blocking(move || set_provider_secret(&id, &key))
        .await
        .map_err(|e| anyhow::anyhow!("set_provider_secret join 失败: {}", e))?
}

/// 删除 keyring 密钥(provider 删除时清理)
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
    let entry = entry_for(id)?;
    entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e))
}

/// [`delete_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
pub async fn delete_provider_secret_async(id: String) -> anyhow::Result<()> {
    tokio::task::spawn_blocking(move || delete_provider_secret(&id))
        .await
        .map_err(|e| anyhow::anyhow!("delete_provider_secret join 失败: {}", e))?
}

/// 启动一次性迁移:DB 明文 → keyring → DB 置空。
/// 失败累计达阈值前:warn + 保留明文下次重试(兼容临时性 keyring 后端故障);
/// 达阈值后:清除 DB 明文 + error 告警(防明文长期滞留无加密 SQLite,强制用户重配走即时迁移)。
/// 非阻断:整个迁移函数本身不因单条失败而 Err。
pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> {
    let providers = repo.list_all().await?;
    let mut migrated = 0;
    for mut p in providers {
        if p.api_key.is_empty() {
            continue; // 已迁移或无密钥
        }
        if let Err(e) = set_provider_secret(&p.id, &p.api_key) {
            // 累计失败次数:未达阈值 → warn + 保留明文下次重试(给临时性 keyring 后端故障恢复机会);
            // 达阈值 → **安全兜底:清除 DB 明文 api_key + error 告警**。
            //
            // 安全考量:devflow.db 落在用户 AppData 目录无文件级加密,OS keyring 长期不可用时
            // 明文 api_key 无限滞留 = 持续暴露风险(P1)。项目无内置加密栈(无 aes/chacha/argon2 依赖),
            // 引入需解决密钥派生 + 密钥存储位置(又会回到 keyring,自相矛盾)——成本/收益不划算。
            // 故达阈值后选「清除明文 + 强制用户重配」:DB 不再保留明文,用户下次进设置保存时
            // 走 ai_save_provider 即时迁移路径(provider.rs set_provider_secret_async)重新写入 keyring。
            // 阈值 3 次已足够覆盖临时性故障(单次启动 keyring 后端未就绪/COM 未初始化等)。
            let n = record_migration_fail(&p.id);
            if n >= MIGRATION_FAIL_THRESHOLD {
                let pid = p.id.clone();
                let pname = p.name.clone();
                // 清除 DB 明文:复用下方成功路径同款 clear + insert 模式。
                p.api_key.clear();
                if let Err(clear_err) = repo.insert(p).await {
                    tracing::error!(
                        "[密钥迁移] provider {} ({}) keyring 连续失败 {} 次后清除 DB 明文失败({}) —— \
                         明文仍滞留 SQLite!请立即手动处理:进设置删除该 provider 或修复 OS 钥匙串后重启",
                        pname, pid, n, clear_err
                    );
                } else {
                    tracing::error!(
                        "[密钥迁移] provider {} ({}) keyring 连续失败 {} 次,已清除 DB 明文 api_key 防长期滞留。\
                     该 provider 密钥需重新配置:进设置 → 编辑该提供商 → 重新填写 API Key 并保存\
                     (走即时迁移写入系统钥匙串)。失败原因: {}",
                        pname, pid, n, e
                    );
                }
                // 已清除明文 → 不再计入「待迁移」,清零失败计数(下次若重新出现明文从 1 起算)。
                clear_migration_failcount(&pid);
            } else {
                tracing::warn!(
                    "[密钥迁移] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}",
                    p.id, n, MIGRATION_FAIL_THRESHOLD, e
                );
            }
            continue;
        }
        let pid = p.id.clone();
        p.api_key.clear();
        if let Err(e) = repo.insert(p).await {
            tracing::warn!("[密钥迁移] 迁移后清空 DB api_key 失败 {}: {}", pid, e);
        }
        // 迁移成功 → 清零该 provider 的失败计数(下次若再出现失败从 1 重新累计)
        clear_migration_failcount(&pid);
        migrated += 1;
    }
    if migrated > 0 {
        tracing::info!("[密钥迁移] {} 条 provider 密钥迁移至 OS keyring", migrated);
    }
    Ok(migrated)
}

/// 校验已解析的密钥是否可用并归一化:空(含纯空白)→明确错误信息;非空→返回归一化后的 String。
///
/// 归一化 = trim → 剥首尾配对引号(`"`/`'`)→ 再 trim。覆盖用户粘贴脏 key 的常见场景:
/// 复制带前后引号/换行/全角空格/尾部空白,校验能过但原样发 provider 致 401 误报"API Key 无效"。
/// 调用方应用返回值(归一化后的 key)替代原 resolved 传给 provider。
///
/// 用于消费点(build_provider 前)早失败,避免空 key 发请求吃 401,错误伪装成"API Key 无效"。
pub fn ensure_resolved_key(provider_name: &str, resolved: &str) -> Result<String, String> {
    // 先 trim 判空(纯空白视为无密钥,防粘贴时只有空格)。
    if resolved.trim().is_empty() {
        return Err(format!(
            "未读取到「{}」的 API 密钥(系统钥匙串无记录或已损坏),请在设置中重新填写并保存",
            provider_name
        ));
    }
    // 归一化:trim → 剥首尾引号(成对,支持 ""xxx"" / ''xxx'' 多层)→ 再 trim。
    let mut cleaned = resolved.trim().to_string();
    while cleaned.len() >= 2 {
        let first = cleaned.chars().next().unwrap();
        let last = cleaned.chars().last().unwrap();
        if (first == '"' || first == '\'') && first == last {
            cleaned = cleaned[first.len_utf8()..cleaned.len() - last.len_utf8()].trim().to_string();
        } else {
            break;
        }
    }
    // 剥引号后可能变空(如粘贴仅一对引号)→ 视为无密钥。
    if cleaned.is_empty() {
        return Err(format!(
            "未读取到「{}」的 API 密钥(系统钥匙串无记录或已损坏),请在设置中重新填写并保存",
            provider_name
        ));
    }
    Ok(cleaned)
}

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

    #[test]
    fn ensure_resolved_key_rejects_empty() {
        assert!(ensure_resolved_key("GLM", "").is_err());
    }

    #[test]
    fn ensure_resolved_key_rejects_whitespace() {
        // 纯空白也视为无密钥(防粘贴时只有空格)
        assert!(ensure_resolved_key("GLM", "   ").is_err());
    }

    #[test]
    fn ensure_resolved_key_accepts_nonempty() {
        assert_eq!(ensure_resolved_key("GLM", "sk-abc").unwrap(), "sk-abc");
    }

    #[test]
    fn ensure_resolved_key_error_mentions_provider_name() {
        let err = ensure_resolved_key("我的提供商", "").unwrap_err();
        assert!(err.contains("我的提供商"), "错误信息应含 provider 名便于定位");
    }

    #[test]
    fn ensure_resolved_key_trims_surrounding_whitespace() {
        // 粘贴带前后空白/换行 → 归一化为干净 key
        assert_eq!(ensure_resolved_key("GLM", "  sk-abc  \n").unwrap(), "sk-abc");
    }

    #[test]
    fn ensure_resolved_key_strips_surrounding_quotes() {
        // 粘贴带前后引号(双引号/单引号,多层嵌套)→ 剥引号
        assert_eq!(ensure_resolved_key("GLM", "\"sk-abc\"").unwrap(), "sk-abc");
        assert_eq!(ensure_resolved_key("GLM", "'sk-abc'").unwrap(), "sk-abc");
        assert_eq!(ensure_resolved_key("GLM", "\"\"sk-abc\"\"").unwrap(), "sk-abc");
        assert_eq!(ensure_resolved_key("GLM", " \"sk-abc\"\n").unwrap(), "sk-abc");
    }

    #[test]
    fn ensure_resolved_key_rejects_only_quotes() {
        // 仅一对引号(剥后为空)→ 视为无密钥
        assert!(ensure_resolved_key("GLM", "\"\"").is_err());
        assert!(ensure_resolved_key("GLM", " '' ").is_err());
    }

    #[test]
    fn resolve_prefers_db_when_non_empty() {
        // DB api_key 非空 → 直接返回 DB 值,不触发 keyring(兼容未迁移老库)
        // 纯逻辑路径,不碰 OS keyring,CI 任意 OS 安全。
        let rec = AiProviderRecord {
            id: "t1".into(), name: "t".into(), provider_type: "openai_compat".into(),
            api_key: "sk-db-fallback".into(), base_url: "https://x".into(),
            default_model: "m".into(), models: None, model_configs: Vec::new(), is_default: false,
            config: None, created_at: "0".into(), updated_at: "0".into(),
            enabled: true, weight: 50,
        };
        assert_eq!(resolve_provider_secret(&rec), "sk-db-fallback");
    }

    /// keyring 相关单测(cfg-gate):避 CI OS keyring 副作用(无后端/无 GUI 会话报错)。
    /// 仅在「桌面 OS + 本地手动」跑(Win/macOS/Linux 桌面环境)。
    #[cfg(any(target_os = "windows", target_os = "macos"))]
    #[test]
    fn set_get_delete_roundtrip_on_os_keyring() {
        use std::time::{SystemTime, UNIX_EPOCH};
        // 用纳秒戳造唯一 id,避与真实 provider 冲突 + 测后清理。
        let id = format!(
            "df-test-{}",
            SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
        );
        // 清理历史残留(上次测试崩溃留下)
        let _ = delete_provider_secret(&id);
        assert_eq!(get_provider_secret(&id), None, "清理后应读不到");
        assert!(set_provider_secret(&id, "sk-roundtrip").is_ok());
        assert_eq!(get_provider_secret(&id).as_deref(), Some("sk-roundtrip"));
        assert!(delete_provider_secret(&id).is_ok());
        assert_eq!(get_provider_secret(&id), None, "删除后应读不到");
    }

    // ============================================================
    // failcount sidecar 逻辑测试 + 迁移达阈值后清明文(DB 安全保证)测试
    // ============================================================
    //
    // 涉及 cwd(failcount_path 读 current_dir)的测试用全局 Mutex 串行化,
    // 避免并发测试互相污染 sidecar 文件。不引入 serial_test 依赖(零新依赖)。
    use std::sync::Mutex as StdMutex;
    static CWD_GUARD: StdMutex<()> = StdMutex::new(());

    /// RAII 守卫:持有全局锁 + 切到唯一临时 cwd,Drop 时恢复原 cwd 并清理临时目录。
    /// 即使持锁期间 panic 也能恢复(PoisonError 用 into_inner 兜底)。
    /// 用纳秒戳造唯一临时目录,测后清理,不残留 sidecar 文件污染其他测试。
    struct IsolatedCwd {
        // 持有 MutexGuard 直到 IsolatedCwd drop → 跨整个测试作用域串行化 cwd 操作。
        // CWD_GUARD 是 static,guard 借用 'static,可存入 struct 字段。
        _guard: std::sync::MutexGuard<'static, ()>,
        orig: PathBuf,
        tmp: PathBuf,
    }
    impl IsolatedCwd {
        fn new() -> Self {
            let guard = CWD_GUARD.lock().unwrap_or_else(|e| e.into_inner());
            let orig = std::env::current_dir().expect("读 cwd");
            let nano = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos();
            let tmp = std::env::temp_dir().join(format!("df-secret-test-{}", nano));
            std::fs::create_dir_all(&tmp).expect("建临时目录");
            std::env::set_current_dir(&tmp).expect("切到临时 cwd");
            IsolatedCwd { _guard: guard, orig, tmp }
        }
    }
    impl Drop for IsolatedCwd {
        fn drop(&mut self) {
            let _ = std::env::set_current_dir(&self.orig);
            let _ = std::fs::remove_dir_all(&self.tmp);
        }
    }

    #[test]
    fn failcount_read_write_record_clear_roundtrip() {
        let _cwd = IsolatedCwd::new();
        // 空文件 → 空 map
        assert!(read_failcounts().is_empty());
        // record 累加
        assert_eq!(record_migration_fail("p1"), 1);
        assert_eq!(record_migration_fail("p1"), 2);
        assert_eq!(record_migration_fail("p2"), 1);
        let map = read_failcounts();
        assert_eq!(map.get("p1"), Some(&2));
        assert_eq!(map.get("p2"), Some(&1));
        // clear 清零指定 id,不影响其他
        clear_migration_failcount("p1");
        let map = read_failcounts();
        assert!(!map.contains_key("p1"), "clear 后 p1 应不存在");
        assert_eq!(map.get("p2"), Some(&1), "p2 不受影响");
        // clear 不存在的 id → 无副作用
        clear_migration_failcount("nope");
        assert_eq!(read_failcounts().get("p2"), Some(&1));
    }

    #[test]
    fn failcount_threshold_constant_is_3() {
        // 锁定阈值常量值(测试依赖此值构造「阈值-1」预置场景)。若将来调整需同步更新测试。
        assert_eq!(MIGRATION_FAIL_THRESHOLD, 3);
    }

    #[test]
    fn failcount_persists_across_reads() {
        let _cwd = IsolatedCwd::new();
        // 验证 sidecar 真落盘(跨 read 实例持久化)——这是「跨启动累计失败」的语义基础。
        record_migration_fail("pp");
        record_migration_fail("pp");
        // 模拟「重启」:重新读一次(新 HashMap 实例),计数应保留
        assert_eq!(read_failcounts().get("pp"), Some(&2));
    }

    /// **核心安全保证**:迁移函数跑完后,DB 中 provider 的明文 api_key 必须被清除。
    ///
    /// 覆盖两条路径(都断言同一不变量):
    /// - keyring 可用 → 走迁移成功路径,api_key.clear() + insert
    /// - keyring 不可用 → 走失败路径:
    ///   · 预置 failcount 到 THRESHOLD-1(=2),本次失败恰好达阈值 → 清明文分支
    ///   · 若 keyring 在 CI 上恰好成功,则走成功路径,断言同样成立
    ///
    /// 无论哪条路径,DB api_key 最终必为空 = 安全保证(P1:不保留明文)。
    #[tokio::test]
    async fn migrate_clears_db_plaintext_after_threshold() {
        use crate::crud::AiProviderRepo;
        use crate::db::Database;
        let db = Database::open_in_memory().await.expect("open_in_memory");
        let repo = AiProviderRepo::new(&db);

        // 构造带明文 api_key 的 provider。id 用唯一纳秒戳避与 keyring 真实 provider 冲突。
        let nano = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let pid = format!("df-migrate-test-{}", nano);
        let rec = AiProviderRecord {
            id: pid.clone(), name: "迁移测试".into(), provider_type: "openai_compat".into(),
            api_key: "sk-plaintext-secret".into(), base_url: "https://x".into(),
            default_model: "m".into(), models: None, model_configs: Vec::new(), is_default: false,
            config: None, created_at: "0".into(), updated_at: "0".into(),
            enabled: true, weight: 50,
        };
        repo.insert(rec).await.expect("insert provider");

        // 关键:迁移函数内部读 sidecar(failcount_path 用 cwd),所以整个测试逻辑必须
        // 在临时 cwd 下执行(IsolatedCwd 存活期间 + async 迁移在同一作用域)。
        let _cwd = IsolatedCwd::new();

        // 预置 failcount 到 THRESHOLD-1(=2),使本次失败恰好达阈值触发清除分支。
        // (若 keyring 在此 CI 环境恰好可用,迁移直接成功清明文,断言同样成立。)
        let mut m = std::collections::HashMap::new();
        m.insert(pid.clone(), MIGRATION_FAIL_THRESHOLD - 1);
        write_failcounts(&m);
        assert_eq!(
            read_failcounts().get(&pid),
            Some(&(MIGRATION_FAIL_THRESHOLD - 1)),
            "预置 failcount 应写入"
        );

        // 跑迁移(非阻断,内部已处理失败)。
        let _ = migrate_secrets_to_keyring(&repo).await;

        // 核心断言:无论 keyring 成败,迁移后 DB 不应保留明文 api_key。
        let got = repo.get_by_id(&pid).await.expect("get").expect("row exists");
        assert!(
            got.api_key.is_empty(),
            "[P1 安全] 迁移后 DB api_key 必须为空(成功迁移清空 / 失败达阈值清明文),\
             实际残留: {:?}。provider={}",
            got.api_key, pid
        );

        // 测后清理 keyring(若迁移成功写入了测试 provider 的密钥)
        let _ = delete_provider_secret(&pid);
    }

    /// 阈值前(keyring 失败 + 未达阈值):计数递增但保留明文下次重试。
    /// 此测试只在 keyring 实际失败时验证「保留明文」分支;keyring 可用时跳过(不算失败)。
    /// 用 cfg-gate 避 CI 不可控 keyring 后端导致断言不稳。
    #[cfg(not(target_os = "linux"))]
    #[tokio::test]
    async fn migrate_keeps_plaintext_below_threshold_when_keyring_fails() {
        use crate::crud::AiProviderRepo;
        use crate::db::Database;
        let db = Database::open_in_memory().await.expect("open_in_memory");
        let repo = AiProviderRepo::new(&db);

        let nano = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        // 用极长 id 让 Entry::new 在多数平台失败(keyring 后端对超长 username 行为不一,常报错)。
        // 若恰好成功则视为 keyring 可用,跳过断言(不算回归)。
        let pid = format!("df-migrate-fail-{}-{}", nano, "x".repeat(200));
        let rec = AiProviderRecord {
            id: pid.clone(), name: "阈值前保留".into(), provider_type: "openai_compat".into(),
            api_key: "sk-keep-me".into(), base_url: "https://x".into(),
            default_model: "m".into(), models: None, model_configs: Vec::new(), is_default: false,
            config: None, created_at: "0".into(), updated_at: "0".into(),
            enabled: true, weight: 50,
        };
        repo.insert(rec).await.expect("insert");

        // 隔离 cwd + failcount 从 0 开始(无预置)。迁移函数内部读 sidecar 用 cwd,必须同作用域。
        let _cwd = IsolatedCwd::new();
        let _ = migrate_secrets_to_keyring(&repo).await;

        let got = repo.get_by_id(&pid).await.expect("get").expect("row exists");
        // 仅在 keyring 真的失败(Entry::new/set_password 报错)时才能验证「保留明文」。
        // keyring 可用时此测试无意义(走成功清明文),跳过。
        if get_provider_secret(&pid).is_none() {
            // keyring 无值 = 本次迁移失败 → 阈值前(首次失败,count=1<3)应保留明文
            assert_eq!(
                got.api_key, "sk-keep-me",
                "[阈值前] 首次失败未达阈值,应保留明文下次重试(兼容临时性故障)"
            );
        }
        let _ = delete_provider_secret(&pid);
    }
}
