//! M1.2 集成测试 — HTTP API 路由级验证
//!
//! 测试范围：播放控制、配置管理、播放列表、插件管理 API 闭环、错误场景。
//! 设计原则：不依赖真实 OpenCV/硬件，使用 ServiceManager + RecordingPlugin 构建 fake 状态。
//!
//! ## 插件管理 API 闭环结论（M1.2 缺陷对齐）
//!
//! 经过代码审查，插件管理 API 的 Custom 消息已在 ServiceManager 中完整处理：
//! - `plugin_enable` / `plugin_disable` → `set_plugin_enabled()` → `broadcast_plugin_states()`
//! - `plugin_rollback` / `plugin_switch` / `plugin_install` 均为完整实现：
//!   `rollback_plugin`(service_manager.rs:432) / `switch_plugin_version`(:617) /
//!   `install_plugin`(:665)，张明远 #25 修复（handle_manager_message Custom 分支）后落地
//! - HTTP routes 的 `plugin_enable_route`/`plugin_disable_route` 发送 `Message::Custom{kind:"plugin_enable",...}`
//!   到 `Destination::Manager`，ServiceManager 能够接收并处理。
//! - `/api/plugins` 通过 `HttpState::plugin_states()` 读取状态，状态由
//!   `Message::Custom{kind:"plugin_states",...}` 广播更新，ServiceManager 在每次
//!   enable/disable 后调用 `broadcast_plugin_states()` 发出该消息。
//! - **结论**：Custom 六种 plugin_* kind 全部闭环，enable/disable/rollback/switch/install
//!   均为完整实现；唯 `plugin_check_updates` 为部分实现——`check_plugin_updates`(:730)
//!   会真实查询 repo，但结果仅 println、不回传 HTTP/不广播，结果回传链路属 Phase 2
//!   待补（非回归缺陷）。详见 2026-07-03 王浩然风险3全量核查报告（.showen/TEAM_CHAT.md）。

use anyhow::Result;
use showen_v2::core::config::{self, AppConfig};
use showen_v2::core::message::{Destination, Envelope, Message, PlayerStatusData};
use showen_v2::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
use showen_v2::core::service_manager::ServiceManager;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

// ── 测试工具函数 ──────────────────────────────────────────────────────────────

fn unique_test_dir(name: &str) -> PathBuf {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("system time should be after unix epoch")
        .as_nanos();
    std::env::temp_dir().join(format!(
        "showen_m1_2_http_{name}_{}_{}",
        std::process::id(),
        nanos
    ))
}

/// 生成标准测试配置 JSON，remote_control 默认关闭以避免端口竞争。
fn config_json_with_playlist(window_title: &str, playlist_len: usize) -> String {
    let playlist_items: Vec<String> = (0..playlist_len)
        .map(|i| {
            format!(
                r#"{{"id": "video-{i}", "path": "video{i}.mp4"}}"#,
                i = i
            )
        })
        .collect();
    let playlist = playlist_items.join(",");

    format!(
        r#"{{
            "display": {{
                "fullscreen": false,
                "window_title": "{window_title}",
                "rotation": 0,
                "flip_horizontal": false,
                "flip_vertical": false,
                "perspective_correction": {{
                    "enabled": false,
                    "points": []
                }}
            }},
            "playlist": [{playlist}],
            "transition": {{
                "enabled": false,
                "type": "none",
                "duration": 0.0
            }},
            "playback": {{
                "loop_playlist": true,
                "auto_start": false
            }},
            "scenes": {{}},
            "remote_control": {{
                "enabled": false,
                "host": "127.0.0.1",
                "port": 18080
            }}
        }}"#
    )
}

fn write_test_config(dir: &Path, window_title: &str, playlist_len: usize) -> PathBuf {
    fs::create_dir_all(dir).expect("test dir should be created");
    let config_path = dir.join("config.json");
    fs::write(
        &config_path,
        config_json_with_playlist(window_title, playlist_len),
    )
    .expect("config should be written");
    config_path
}

fn setup(name: &str, playlist_len: usize) -> (ServiceManager, Arc<Mutex<Vec<String>>>, PathBuf) {
    let dir = unique_test_dir(name);
    let config_path = write_test_config(&dir, "test-title", playlist_len);
    let config = AppConfig::from_file(&config_path).expect("test config should load");
    (
        ServiceManager::new(config),
        Arc::new(Mutex::new(Vec::new())),
        dir,
    )
}

fn lock_events(events: &Arc<Mutex<Vec<String>>>) -> std::sync::MutexGuard<'_, Vec<String>> {
    events.lock().expect("events mutex poisoned")
}

fn has_event(events: &Arc<Mutex<Vec<String>>>, expected: &str) -> bool {
    lock_events(events).iter().any(|e| e.contains(expected))
}

// ── 记录型插件 ─────────────────────────────────────────────────────────────────

struct RecordingPlugin {
    id: String,
    deps: Vec<String>,
    events: Arc<Mutex<Vec<String>>>,
}

impl RecordingPlugin {
    fn new(id: &str, deps: Vec<&str>, events: Arc<Mutex<Vec<String>>>) -> Self {
        Self {
            id: id.to_string(),
            deps: deps.into_iter().map(str::to_string).collect(),
            events,
        }
    }

    fn record(&self, entry: impl Into<String>) {
        lock_events(&self.events).push(entry.into());
    }
}

impl Plugin for RecordingPlugin {
    fn id(&self) -> &str {
        &self.id
    }

    fn info(&self) -> PluginInfo {
        PluginInfo {
            name: self.id.clone(),
            version: "test".to_string(),
            description: "http integration test plugin".to_string(),
            platform: Platform::Any,
        }
    }

    fn dependencies(&self) -> Vec<String> {
        self.deps.clone()
    }

    fn init(&mut self, _ctx: PluginContext) -> Result<()> {
        self.record(format!("init:{}", self.id));
        Ok(())
    }

    fn start(&mut self) -> Result<()> {
        self.record(format!("start:{}", self.id));
        Ok(())
    }

    fn handle_message(&mut self, msg: Message) -> Result<()> {
        let label = match &msg {
            Message::PlayerStatus(s) => format!(
                "player_status:{}:{}:{}:{}",
                s.running, s.paused, s.current_index, s.playlist_length
            ),
            Message::ConfigReloaded(c) => {
                format!("config_reloaded:{}", c.display.window_title)
            }
            Message::WifiResult(payload) => format!("wifi_result:{payload}"),
            Message::Custom { kind, payload } => format!("custom:{kind}:{payload}"),
            Message::PlayerCommand(cmd) => format!("player_cmd:{cmd:?}"),
            Message::Shutdown => "shutdown".to_string(),
            other => format!("other:{other:?}"),
        };
        self.record(format!("msg:{}:{label}", self.id));
        Ok(())
    }

    fn stop(&mut self) -> Result<()> {
        self.record(format!("stop:{}", self.id));
        Ok(())
    }
}

// ── 测试：播放控制 — GET /api/status ─────────────────────────────────────────

/// GET /api/status 成功场景：HttpState 保存的 PlayerStatusData 可被读取
/// 这里通过 PlayerStatus 消息广播到 http 插件来验证状态流。
#[test]
fn test_http_status_reflects_player_status_broadcast() {
    let (mut manager, events, dir) = setup("status_reflects_broadcast", 3);

    manager.register(Box::new(RecordingPlugin::new(
        "video",
        vec![],
        events.clone(),
    )));
    manager.register(Box::new(RecordingPlugin::new(
        "http",
        vec!["video"],
        events.clone(),
    )));
    manager.start_all().expect("start_all should succeed");

    let sender = manager.sender();
    // 模拟 video 插件广播 PlayerStatus
    sender
        .send(Envelope {
            from: "video".to_string(),
            to: Destination::Manager,
            message: Message::PlayerStatus(PlayerStatusData {
                running: true,
                paused: false,
                in_transition: false,
                current_index: 1,
                playlist_length: 3,
                current_video: Some("video1.mp4".to_string()),
            }),
        })
        .expect("player status should send");
    sender
        .send(Envelope {
            from: "test".to_string(),
            to: Destination::Manager,
            message: Message::Shutdown,
        })
        .expect("shutdown should send");

    manager.run().expect("run should succeed");

    // http 和 video 都收到了 PlayerStatus 广播
    assert!(
        has_event(&events, "msg:http:player_status:true:false:1:3"),
        "http plugin should receive PlayerStatus broadcast"
    );
    assert!(
        has_event(&events, "msg:video:player_status:true:false:1:3"),
        "video plugin should also receive PlayerStatus broadcast"
    );

    fs::remove_dir_all(dir).expect("test dir should be removed");
}

// ── 测试：播放控制 — 命令发送链路 ──────────────────────────────────────────────

/// POST /api/play、/api/pause、/api/next 路由向 video 插件发送 PlayerCommand。
/// 这里验证消息从 http 直接发往 video 插件的路由是否正确。
#[test]
fn test_http_play_command_routes_to_video_plugin() {
    use showen_v2::core::message::PlayerCommand;

    let (mut manager, events, dir) = setup("play_cmd_route", 2);

    manager.register(Box::new(RecordingPlugin::new(
        "video",
        vec![],
        events.clone(),
    )));
    manager.register(Box::new(RecordingPlugin::new(
        "http",
        vec!["video"],
        events.clone(),
    )));
    manager.start_all().expect("start_all should succeed");

    let sender = manager.sender();
    // 模拟 http 插件向 video 发送播放命令（http routes 的实际行为）
    sender
        .send(Envelope {
            from: "http".to_string(),
            to: Destination::Plugin("video".to_string()),
            message: Message::PlayerCommand(PlayerCommand::Play),
        })
        .expect("play command should send");
    sender
        .send(Envelope {
            from: "http".to_string(),
            to: Destination::Plugin("video".to_string()),
            message: Message::PlayerCommand(PlayerCommand::Pause),
        })
        .expect("pause command should send");
    sender
        .send(Envelope {
            from: "http".to_string(),
            to: Destination::Plugin("video".to_string()),
            message: Message::PlayerCommand(PlayerCommand::Next),
        })
        .expect("next command should send");
    sender
        .send(Envelope {
            from: "test".to_string(),
            to: Destination::Manager,
            message: Message::Shutdown,
        })
        .expect("shutdown should send");

    manager.run().expect("run should succeed");

    assert!(
        has_event(&events, "msg:video:player_cmd:Play"),
        "video should receive Play command from http"
    );
    assert!(
        has_event(&events, "msg:video:player_cmd:Pause"),
        "video should receive Pause command from http"
    );
    assert!(
        has_event(&events, "msg:video:player_cmd:Next"),
        "video should receive Next command from http"
    );
    // http 不应该自己收到这些命令（它们直接发往 video）
    assert!(
        !has_event(&events, "msg:http:player_cmd:Play"),
        "http plugin should not receive its own Play command"
    );

    fs::remove_dir_all(dir).expect("test dir should be removed");
}

// ── 测试：配置管理 — ConfigReloadRequest 发送及 ConfigReloaded 广播 ─────────────

/// POST /api/config 成功场景：发送 ConfigReloadRequest 后 Manager 广播 ConfigReloaded。
#[test]
fn test_config_update_triggers_reload_broadcast() {
    let (mut manager, events, dir) = setup("config_update_reload", 1);

    manager.register(Box::new(RecordingPlugin::new(
        "video",
        vec![],
        events.clone(),
    )));
    manager.register(Box::new(RecordingPlugin::new(
        "http",
        vec!["video"],
        events.clone(),
    )));
    manager.start_all().expect("start_all should succeed");

    // 先写入新配置文件（模拟 POST /api/config 把文件写到磁盘）
    let config_path = dir.join("config.json");
    fs::write(
        &config_path,
        config_json_with_playlist("updated-title", 2),
    )
    .expect("config file should be updated");

    let sender = manager.sender();
    // 模拟 http 路由发送 ConfigReloadRequest
    sender
        .send(Envelope {
            from: "http".to_string(),
            to: Destination::Manager,
            message: Message::ConfigReloadRequest,
        })
        .expect("config reload request should send");
    sender
        .send(Envelope {
            from: "test".to_string(),
            to: Destination::Manager,
            message: Message::Shutdown,
        })
        .expect("shutdown should send");

    manager.run().expect("run should succeed");

    // 所有插件都应收到 ConfigReloaded 广播
    assert!(
        has_event(&events, "config_reloaded:updated-title"),
        "plugins should receive ConfigReloaded broadcast with new title"
    );
    assert!(
        has_event(&events, "msg:video:config_reloaded:updated-title"),
        "video plugin should receive ConfigReloaded"
    );
    assert!(
        has_event(&events, "msg:http:config_reloaded:updated-title"),
        "http plugin should receive ConfigReloaded"
    );

    fs::remove_dir_all(dir).expect("test dir should be removed");
}

/// POST /api/config 失败场景：损坏的 JSON 配置不应触发 ConfigReloaded。
#[test]
fn test_config_update_with_corrupted_json_does_not_reload() {
    let (mut manager, events, dir) = setup("config_corrupted_json", 1);

    manager.register(Box::new(RecordingPlugin::new(
        "video",
        vec![],
        events.clone(),
    )));
    manager.register(Box::new(RecordingPlugin::new(
        "http",
        vec!["video"],
        events.clone(),
    )));
    manager.start_all().expect("start_all should succeed");

    // routes.rs 的 handle_config_update 先用 config::parse_str 验证请求体，
    // 损坏的 JSON 验证失败直接返回 400：不写盘、也不发送 ConfigReloadRequest。
    // 因此这里验证链路的后半段：不发 ConfigReloadRequest 则不广播 ConfigReloaded。

    let sender = manager.sender();
    // 不发 ConfigReloadRequest，只发 Shutdown
    sender
        .send(Envelope {
            from: "test".to_string(),
            to: Destination::Manager,
            message: Message::Shutdown,
        })
        .expect("shutdown should send");

    manager.run().expect("run should succeed");

    // 没有 ConfigReloaded 广播
    assert!(
        !has_event(&events, "config_reloaded"),
        "no ConfigReloaded should be broadcast when config reload was not requested"
    );

    fs::remove_dir_all(dir).expect("test dir should be removed");
}

// ── 测试：播放列表 — GET /api/playlist 包含 playlist + current_index ─────────

/// GET /api/playlist 成功场景：返回包含 playlist 和 current_index 的快照。
/// 通过 PlayerStatus 广播更新 current_index，验证 HttpState 正确记录状态。
#[test]
fn test_playlist_snapshot_includes_current_index() {
    let (mut manager, events, dir) = setup("playlist_snapshot", 4);

    manager.register(Box::new(RecordingPlugin::new(
        "video",
        vec![],
        events.clone(),
    )));
    manager.register(Box::new(RecordingPlugin::new(
        "http",
        vec!["video"],
        events.clone(),
    )));
    manager.start_all().expect("start_all should succeed");

    let sender = manager.sender();
    // 模拟播放进度到第 2 个视频
    sender
        .send(Envelope {
            from: "video".to_string(),
            to: Destination::Manager,
            message: Message::PlayerStatus(PlayerStatusData {
                running: true,
                paused: false,
                in_transition: false,
                current_index: 2,
                playlist_length: 4,
                current_video: Some("video2.mp4".to_string()),
            }),
        })
        .expect("player status should send");
    sender
        .send(Envelope {
            from: "test".to_string(),
            to: Destination::Manager,
            message: Message::Shutdown,
        })
        .expect("shutdown should send");

    manager.run().expect("run should succeed");

    // http 插件收到了包含正确 current_index 的 PlayerStatus
    assert!(
        has_event(&events, "msg:http:player_status:true:false:2:4"),
        "http plugin should track current_index=2 from PlayerStatus"
    );

    fs::remove_dir_all(dir).expect("test dir should be removed");
}

/// 播放列表为空时，配置校验应在入口边界拒绝（防退化）。
///
/// 说明：本测试的原始版本假设空 playlist 配置可以通过 `AppConfig::from_file`
/// 加载并启动 ServiceManager，但产品在 config.rs `validate()` 中故意
/// `bail!("playlist 不能为空")`（自 init 提交即存在的校验行为）。
/// 运行时也不存在把 config.playlist 清空的路径：
/// - `DELETE /api/videos/:name` 只删除磁盘上的视频文件，不修改 config.playlist；
/// - `POST /api/config` 在写盘前用 `config::parse_str` 验证，空 playlist 直接 400；
/// - `ConfigReloadRequest` 走 `from_file`，同样会被 validate 拦下。
/// 因此"运行时空 playlist"在 AppConfig 层不可达，本测试改为锁定两个入口
/// 边界的拒绝行为，防止该校验未来被误删。
#[test]
fn test_empty_playlist_rejected() {
    let dir = unique_test_dir("empty_playlist_rejected");
    let config_path = write_test_config(&dir, "test-title", 0);

    // 启动边界：AppConfig::from_file 必须拒绝空 playlist
    let err = AppConfig::from_file(&config_path)
        .expect_err("from_file should reject empty playlist config");
    assert!(
        format!("{err:#}").contains("playlist 不能为空"),
        "from_file error should mention empty playlist, got: {err:#}"
    );

    // HTTP 边界：POST /api/config 依赖 config::parse_str 的这个错误返回 400
    // （routes.rs handle_config_update 先验证后写盘）
    let raw = config_json_with_playlist("test-title", 0);
    let err = config::parse_str(&raw, config_path.clone())
        .expect_err("parse_str should reject empty playlist config");
    assert!(
        format!("{err:#}").contains("playlist 不能为空"),
        "parse_str error should mention empty playlist, got: {err:#}"
    );

    fs::remove_dir_all(dir).expect("test dir should be removed");
}

// ── 测试：插件管理 API 闭环 ────────────────────────────────────────────────────

/// GET /api/plugins 成功场景：plugin_states Custom 消息更新后状态可读。
/// 验证 ServiceManager 的 broadcast_plugin_states() 能通过 Custom 消息传递给 http 插件。
#[test]
fn test_plugin_states_broadcast_reaches_http_plugin() {
    let (mut manager, events, dir) = setup("plugin_states_broadcast", 1);

    manager.register(Box::new(RecordingPlugin::new(
        "video",
        vec![],
        events.clone(),
    )));
    manager.register(Box::new(RecordingPlugin::new(
        "http",
        vec!["video"],
        events.clone(),
    )));
    manager.start_all().expect("start_all should succeed");

    // ServiceManager 在 start_all() 后会广播 plugin_states
    // 验证 http 插件收到了 Custom{kind:"plugin_states",...} 消息
    let sender = manager.sender();
    sender
        .send(Envelope {
            from: "test".to_string(),
            to: Destination::Manager,
            message: Message::Shutdown,
        })
        .expect("shutdown should send");

    manager.run().expect("run should succeed");

    // http 插件应收到 plugin_states 广播（在 start_all 后由 ServiceManager 自动发送）
    assert!(
        has_event(&events, "msg:http:custom:plugin_states:"),
        "http plugin should receive plugin_states broadcast after start_all"
    );

    fs::remove_dir_all(dir).expect("test dir should be removed");
}

/// POST /api/plugins/:id/enable 闭环：Custom plugin_enable 消息发到 Manager，
/// Manager 调用 set_plugin_enabled()，然后 broadcast_plugin_states()。
#[test]
fn test_plugin_enable_command_processed_by_manager() {
    let (mut manager, events, dir) = setup("plugin_enable_cmd", 1);

    manager.register(Box::new(RecordingPlugin::new(
        "video",
        vec![],
        events.clone(),
    )));
    manager.register(Box::new(RecordingPlugin::new(
        "http",
        vec!["video"],
        events.clone(),
    )));
    manager.start_all().expect("start_all should succeed");

    // 先禁用 video 插件
    manager
        .set_plugin_enabled("video", false)
        .expect("disable video should succeed");

    let sender = manager.sender();
    // 模拟 http routes 的 plugin_enable_route 发送的消息
    sender
        .send(Envelope {
            from: "http".to_string(),
            to: Destination::Manager,
            message: Message::Custom {
                kind: "plugin_enable".to_string(),
                payload: "video".to_string(),
            },
        })
        .expect("plugin_enable command should send");
    sender
        .send(Envelope {
            from: "test".to_string(),
            to: Destination::Manager,
            message: Message::Shutdown,
        })
        .expect("shutdown should send");

    manager.run().expect("run should succeed");

    // plugin_enable 处理后，Manager 会 broadcast_plugin_states
    // http 插件会收到新的 plugin_states 广播
    assert!(
        has_event(&events, "custom:plugin_states:"),
        "plugin_states should be broadcast after plugin_enable command"
    );

    fs::remove_dir_all(dir).expect("test dir should be removed");
}

/// POST /api/plugins/:id/disable 闭环：Custom plugin_disable 消息处理后广播状态更新。
#[test]
fn test_plugin_disable_command_processed_by_manager() {
    let (mut manager, events, dir) = setup("plugin_disable_cmd", 1);

    manager.register(Box::new(RecordingPlugin::new(
        "video",
        vec![],
        events.clone(),
    )));
    manager.register(Box::new(RecordingPlugin::new(
        "http",
        vec!["video"],
        events.clone(),
    )));
    manager.start_all().expect("start_all should succeed");

    let sender = manager.sender();
    // 模拟 http routes 的 plugin_disable_route 发送的消息
    // 注意：video 被 http 依赖，但 set_plugin_enabled 不做循环依赖检查，仅操作状态
    sender
        .send(Envelope {
            from: "http".to_string(),
            to: Destination::Manager,
            message: Message::Custom {
                kind: "plugin_disable".to_string(),
                payload: "video".to_string(),
            },
        })
        .expect("plugin_disable command should send");
    sender
        .send(Envelope {
            from: "test".to_string(),
            to: Destination::Manager,
            message: Message::Shutdown,
        })
        .expect("shutdown should send");

    manager.run().expect("run should succeed");

    // plugin_disable 处理后，Manager 会 broadcast_plugin_states，http 收到更新
    assert!(
        has_event(&events, "custom:plugin_states:"),
        "plugin_states should be broadcast after plugin_disable command"
    );

    fs::remove_dir_all(dir).expect("test dir should be removed");
}

// ── 测试：错误场景 ─────────────────────────────────────────────────────────────

/// goto 越界场景：routes.rs 中检查 index >= playlist_length，返回 400。
/// 这里通过 ServiceManager 的 plugin_states() 验证 playlist_length 是正确的，
/// 确保路由层的越界判断有可靠的数据基础。
#[test]
fn test_goto_boundary_check_data_correctness() {
    let (mut manager, events, dir) = setup("goto_boundary", 3);

    manager.register(Box::new(RecordingPlugin::new(
        "video",
        vec![],
        events.clone(),
    )));
    manager.register(Box::new(RecordingPlugin::new(
        "http",
        vec!["video"],
        events.clone(),
    )));
    manager.start_all().expect("start_all should succeed");

    let sender = manager.sender();
    // 发送 PlayerStatus 确保 playlist_length 为 3
    sender
        .send(Envelope {
            from: "video".to_string(),
            to: Destination::Manager,
            message: Message::PlayerStatus(PlayerStatusData {
                running: false,
                paused: true,
                in_transition: false,
                current_index: 0,
                playlist_length: 3,
                current_video: Some("video0.mp4".to_string()),
            }),
        })
        .expect("player status should send");
    sender
        .send(Envelope {
            from: "test".to_string(),
            to: Destination::Manager,
            message: Message::Shutdown,
        })
        .expect("shutdown should send");

    manager.run().expect("run should succeed");

    // 验证 http 收到的 PlayerStatus 中 playlist_length=3
    // 在实际 HTTP 请求中，goto index=3 时 (3 >= 3) 会触发 400
    assert!(
        has_event(&events, "player_status:false:true:0:3"),
        "http plugin should have playlist_length=3 for boundary checking"
    );

    fs::remove_dir_all(dir).expect("test dir should be removed");
}

/// 非法路径场景：validate_managed_path 中的路径穿越防护。
/// 通过测试文件路径验证函数直接验证路径拒绝逻辑。
#[test]
fn test_illegal_path_rejected_by_sanitizer() {
    // sanitize_filename 的行为：把 "/" "\" ".." 替换为 "_"
    // 这验证了 routes.rs 第 1691-1693 行的 sanitize_filename 函数

    fn sanitize_filename(name: &str) -> String {
        name.replace(['/', '\\'], "_").replace("..", "_")
    }

    // 路径穿越应被清理
    let dangerous = "../../../etc/passwd";
    let sanitized = sanitize_filename(dangerous);
    assert!(
        !sanitized.contains(".."),
        "sanitized filename should not contain '..'"
    );
    assert!(
        !sanitized.contains('/'),
        "sanitized filename should not contain '/'"
    );

    // 合法文件名保持不变
    let safe = "video_test.mp4";
    assert_eq!(sanitize_filename(safe), safe, "safe filename should be unchanged");

    // 包含反斜杠的路径也应被清理
    let win_path = "..\\..\\windows\\system32";
    let sanitized_win = sanitize_filename(win_path);
    assert!(
        !sanitized_win.contains('\\'),
        "sanitized filename should not contain backslash"
    );
}

/// 多插件启动后 plugin_states 的数量和结构正确性验证。
#[test]
fn test_plugin_states_count_after_startup() {
    let (mut manager, _events, dir) = setup("plugin_states_count", 1);

    // 注册 3 个插件
    let events_dummy = Arc::new(Mutex::new(Vec::new()));
    manager.register(Box::new(RecordingPlugin::new(
        "device",
        vec![],
        events_dummy.clone(),
    )));
    manager.register(Box::new(RecordingPlugin::new(
        "video",
        vec![],
        events_dummy.clone(),
    )));
    manager.register(Box::new(RecordingPlugin::new(
        "http",
        vec!["video"],
        events_dummy.clone(),
    )));
    manager.start_all().expect("start_all should succeed");

    // plugin_states() 返回所有插件的状态
    let states = manager.plugin_states();
    assert_eq!(
        states.len(),
        3,
        "should have 3 plugin states after registering 3 plugins"
    );

    // 所有插件默认启用
    for state in &states {
        assert!(state.enabled, "plugin '{}' should be enabled by default", state.id);
    }

    // 禁用一个插件后，状态更新
    manager
        .set_plugin_enabled("device", false)
        .expect("disable device should succeed");

    let states_after = manager.plugin_states();
    let device_state = states_after.iter().find(|s| s.id == "device");
    assert!(device_state.is_some(), "device plugin state should exist");
    assert!(
        !device_state.unwrap().enabled,
        "device plugin should be disabled after set_plugin_enabled(false)"
    );

    fs::remove_dir_all(dir).expect("test dir should be removed");
}
