use super::HttpState;
use crate::core::config::{self, AppConfig};
use crate::core::dispatch;
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand};
use bytes::Buf;
use futures_util::{SinkExt, StreamExt, TryStreamExt};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::{mpsc, Arc};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tokio::io::AsyncWriteExt;
use warp::http::StatusCode;
use warp::multipart::FormData;
use warp::{Filter, Reply};

const MAX_UPLOAD_FILE_SIZE: u64 = 100 * 1024 * 1024;
static UPLOAD_TMP_COUNTER: AtomicU64 = AtomicU64::new(0);

#[derive(Deserialize)]
struct WifiConnectRequest {
    ssid: String,
    password: String,
}

#[derive(Default, Deserialize)]
struct WifiApStartRequest {
    ssid: Option<String>,
    password: Option<String>,
}

#[derive(Default, Deserialize)]
struct BleStartRequest {
    device_name: Option<String>,
}

#[derive(Serialize)]
struct ApiMessage<'a> {
    status: &'a str,
    message: String,
}

#[derive(Serialize)]
struct VideoFileInfo {
    name: String,
    size: u64,
}

#[derive(Serialize)]
struct FileEntry {
    name: String,
    is_dir: bool,
    size: u64,
}

#[derive(Deserialize)]
struct FileMoveRequest {
    from_dir: String,
    from_path: String,
    to_dir: String,
    to_path: String,
}

#[derive(Serialize)]
struct WifiStatusResponse {
    connected: bool,
    ssid: String,
    ip: String,
}

#[derive(Serialize)]
struct BleStatusResponse {
    running: bool,
    embedded: bool,
    device_name: String,
}

#[derive(Serialize)]
struct PlaylistSnapshot {
    playlist: Vec<crate::core::config::VideoItem>,
    current_index: usize,
}

#[derive(Serialize)]
struct AppInfoResponse {
    version: String,
    apk_available: bool,
    apk_size: Option<u64>,
    download_url: &'static str,
}

pub(crate) fn build_routes(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    // 使用 boxed() 分段避免 warp 递归类型溢出
    let core_api = status_route(Arc::clone(&state))
        .or(play_route(tx.clone()))
        .or(pause_route(tx.clone()))
        .or(next_route(tx.clone()))
        .or(previous_route(tx.clone()))
        .or(goto_route(tx.clone(), Arc::clone(&state)))
        .or(playlist_route(Arc::clone(&state)))
        .or(scene_route(tx.clone()))
        .or(trigger_route(tx.clone()))
        .or(config_get_route(Arc::clone(&state)))
        .or(config_display_route(Arc::clone(&state)))
        .or(config_update_route(tx.clone(), Arc::clone(&state)))
        .or(config_available_route(Arc::clone(&state)))
        .or(config_switch_route(tx.clone(), Arc::clone(&state)))
        .boxed();

    let media_api = video_list_route(Arc::clone(&state))
        .or(video_upload_route(Arc::clone(&state)))
        .or(video_delete_route(Arc::clone(&state)))
        .or(app_info_route(Arc::clone(&state)))
        .or(wifi_status_route(tx.clone(), Arc::clone(&state)))
        .or(wifi_scan_route(tx.clone(), Arc::clone(&state)))
        .or(wifi_connect_route(tx.clone(), Arc::clone(&state)))
        .or(wifi_ap_start_route(tx.clone(), Arc::clone(&state)))
        .or(wifi_ap_stop_route(tx.clone(), Arc::clone(&state)))
        .or(ble_start_route(Arc::clone(&state)))
        .or(ble_stop_route())
        .or(ble_status_route(Arc::clone(&state)))
        .boxed();

    let plugin_api = plugins_list_route(Arc::clone(&state))
        .or(plugin_detail_route(Arc::clone(&state)))
        .or(plugin_enable_route(tx.clone()))
        .or(plugin_disable_route(tx.clone()))
        .or(plugin_rollback_route(tx.clone()))
        .or(plugin_switch_route(tx.clone()))
        .or(plugin_install_route(tx.clone()))
        .or(plugin_check_updates_route(tx.clone()))
        .boxed();

    let file_api = file_list_route(Arc::clone(&state))
        .or(file_upload_route(Arc::clone(&state)))
        .or(file_download_route(Arc::clone(&state)))
        .or(file_delete_route(Arc::clone(&state)))
        .or(file_move_route(Arc::clone(&state)))
        .or(file_mkdir_route(Arc::clone(&state)))
        .boxed();

    let api = core_api.or(media_api).or(plugin_api).or(file_api);

    root_route()
        .or(download_route(Arc::clone(&state)))
        .or(ws_route(tx.clone(), Arc::clone(&state)))
        .or(api)
        .with(
            warp::cors()
                .allow_any_origin()
                .allow_headers(["content-type"])
                .allow_methods(["GET", "POST", "DELETE", "OPTIONS"]),
        )
}

fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path::end()
        .and(warp::get())
        .map(|| warp::reply::html(WEB_UI_HTML))
        .or(
            warp::path("index.html")
                .and(warp::path::end())
                .and(warp::get())
                .map(|| warp::reply::html(WEB_UI_HTML)),
        )
}

fn ws_route(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path("ws")
        .and(warp::path::end())
        .and(warp::ws())
        .and(with_tx(tx))
        .and(with_state(state))
        .map(|ws: warp::ws::Ws, tx: mpsc::Sender<Envelope>, state: Arc<HttpState>| {
            ws.on_upgrade(move |socket| websocket_session(socket, tx, state))
        })
}

fn download_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("download" / String)
        .and(warp::path::end())
        .and(warp::get())
        .and(with_state(state))
        .and_then(|filename: String, state: Arc<HttpState>| async move {
            let safe_name = sanitize_filename(&filename);
            if safe_name.is_empty() || safe_name != filename {
                return Ok::<_, Infallible>(warp::reply::with_status(
                    warp::reply::html("无效的文件名".to_string()),
                    StatusCode::BAD_REQUEST,
                )
                .into_response());
            }

            let full = downloads_dir(state.config().as_ref()).join(&safe_name);
            if !full.is_file() {
                return Ok(warp::reply::with_status(
                    warp::reply::html("文件不存在".to_string()),
                    StatusCode::NOT_FOUND,
                )
                .into_response());
            }

            Ok(read_attachment_response(&full))
        })
}

fn status_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "status")
        .and(warp::get())
        .and(with_state(state))
        .and_then(|state: Arc<HttpState>| async move {
            Ok::<_, Infallible>(json_response(StatusCode::OK, &state.player_status()))
        })
}

fn play_route(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "play")
        .and(warp::post())
        .and(with_tx(tx))
        .and_then(|tx| async move {
            send_video_command(tx, Message::PlayerCommand(PlayerCommand::Play), "开始播放").await
        })
}

fn pause_route(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "pause")
        .and(warp::post())
        .and(with_tx(tx))
        .and_then(|tx| async move {
            send_video_command(tx, Message::PlayerCommand(PlayerCommand::Pause), "已暂停").await
        })
}

fn next_route(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "next")
        .and(warp::post())
        .and(with_tx(tx))
        .and_then(|tx| async move {
            send_video_command(
                tx,
                Message::PlayerCommand(PlayerCommand::Next),
                "切换到下一个视频",
            )
            .await
        })
}

fn previous_route(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "previous")
        .and(warp::post())
        .and(with_tx(tx))
        .and_then(|tx| async move {
            send_video_command(
                tx,
                Message::PlayerCommand(PlayerCommand::Previous),
                "切换到上一个视频",
            )
            .await
        })
}

fn goto_route(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "goto" / usize)
        .and(warp::post())
        .and(with_tx(tx))
        .and(with_state(state))
        .and_then(|index, tx, state: Arc<HttpState>| async move {
            if index >= state.player_status().playlist_length {
                return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "无效的视频索引"));
            }

            send_video_command(
                tx,
                Message::PlayerCommand(PlayerCommand::Goto(index)),
                format!("跳转到视频 {index}"),
            )
            .await
        })
}

fn playlist_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "playlist")
        .and(warp::get())
        .and(with_state(state))
        .and_then(|state: Arc<HttpState>| async move {
            let config = state.config();
            let player_status = state.player_status();
            let snapshot = PlaylistSnapshot {
                playlist: config.playlist.clone(),
                current_index: player_status.current_index,
            };
            Ok::<_, Infallible>(json_response(StatusCode::OK, &snapshot))
        })
}

fn scene_route(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "scene" / String)
        .and(warp::post())
        .and(with_tx(tx))
        .and_then(|name: String, tx| async move {
            send_video_command(
                tx,
                Message::PlayerCommand(PlayerCommand::ChangeScene(name.clone())),
                format!("切换到场景: {name}"),
            )
            .await
        })
}

fn trigger_route(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    let with_value = warp::path!("api" / "trigger" / String / String)
        .and(warp::post())
        .and(with_tx(tx.clone()))
        .and_then(|name: String, value: String, tx| async move {
            send_video_command(
                tx,
                Message::Trigger {
                    name: name.clone(),
                    value: value.clone(),
                },
                format!("触发器 '{name}' 已发送，值: {value}"),
            )
            .await
        });

    let without_value = warp::path!("api" / "trigger" / String)
        .and(warp::post())
        .and(with_tx(tx))
        .and_then(|name: String, tx| async move {
            send_video_command(
                tx,
                Message::Trigger {
                    name: name.clone(),
                    value: String::new(),
                },
                format!("触发器 '{name}' 已发送"),
            )
            .await
        });

    with_value.or(without_value)
}

fn config_get_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "config")
        .and(warp::get())
        .and(with_state(state))
        .and_then(|state: Arc<HttpState>| async move {
            let config = state.config();
            Ok::<_, Infallible>(json_response(StatusCode::OK, config.as_ref()))
        })
}

fn config_display_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "config" / "display")
        .and(warp::get())
        .and(with_state(state))
        .and_then(|state: Arc<HttpState>| async move {
            let config = state.config();
            Ok::<_, Infallible>(json_response(StatusCode::OK, &config.display))
        })
}

fn config_update_route(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "config")
        .and(warp::post())
        .and(warp::body::content_length_limit(1024 * 64))
        .and(warp::body::bytes())
        .and(with_tx(tx))
        .and(with_state(state))
        .and_then(handle_config_update)
}

fn config_available_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "config" / "available")
        .and(warp::get())
        .and(with_state(state))
        .and_then(|state: Arc<HttpState>| async move {
            let config = state.config();
            let configs_dir = &config.source_dir;
            let mut files: Vec<String> = Vec::new();
            if let Ok(entries) = std::fs::read_dir(configs_dir) {
                for entry in entries.flatten() {
                    let name = entry.file_name().to_string_lossy().into_owned();
                    if name.ends_with(".json") {
                        files.push(name);
                    }
                }
            }
            files.sort();
            // 标记当前活跃的配置
            let active = config.source_path
                .file_name()
                .map(|n| n.to_string_lossy().into_owned())
                .unwrap_or_default();
            let result = serde_json::json!({
                "configs": files,
                "active": active,
            });
            Ok::<_, Infallible>(json_response(StatusCode::OK, &result))
        })
}

#[derive(Deserialize)]
struct ConfigSwitchRequest {
    filename: String,
}

fn config_switch_route(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "config" / "switch")
        .and(warp::post())
        .and(warp::body::json::<ConfigSwitchRequest>())
        .and(with_tx(tx))
        .and(with_state(state))
        .and_then(|req: ConfigSwitchRequest, tx: mpsc::Sender<Envelope>, state: Arc<HttpState>| async move {
            // 验证文件名安全
            if req.filename.contains("..") || req.filename.contains('/') || req.filename.contains('\\') {
                return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "文件名不合法"));
            }
            if !req.filename.ends_with(".json") {
                return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "只支持 .json 配置文件"));
            }

            let config = state.config();
            let target_path = config.source_dir.join(&req.filename);
            if !target_path.exists() {
                return Ok(error_json(StatusCode::NOT_FOUND, "配置文件不存在"));
            }

            // 读取目标配置文件内容
            let raw = match std::fs::read_to_string(&target_path) {
                Ok(raw) => raw,
                Err(e) => return Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("读取失败: {e}"))),
            };

            // 验证配置合法
            if let Err(e) = config::parse_str(&raw, target_path.clone()) {
                return Ok(error_json(StatusCode::BAD_REQUEST, &format!("配置验证失败: {e}")));
            }

            // 写入当前活跃配置文件路径
            if let Err(e) = std::fs::write(&config.source_path, &raw) {
                return Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("写入失败: {e}")));
            }

            // 触发热重载
            if let Err(e) = tx.send(Envelope {
                from: "http".to_string(),
                to: Destination::Manager,
                message: Message::ConfigReloadRequest,
            }) {
                return Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("发送重载请求失败: {e}")));
            }

            Ok(success_json(format!("已切换到配置: {}", req.filename)))
        })
}

fn video_list_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "videos")
        .and(warp::get())
        .and(with_state(state))
        .and_then(|state: Arc<HttpState>| async move {
            let dir = video_dir(state.config().as_ref());
            Ok::<_, Infallible>(json_response(StatusCode::OK, &list_video_files(&dir)))
        })
}

fn app_info_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "app" / "info")
        .and(warp::get())
        .and(with_state(state))
        .and_then(|state: Arc<HttpState>| async move {
            let apk_path = downloads_dir(state.config().as_ref()).join("showen-app.apk");
            let apk_size = std::fs::metadata(&apk_path)
                .ok()
                .filter(|meta| meta.is_file())
                .map(|meta| meta.len());

            Ok::<_, Infallible>(json_response(
                StatusCode::OK,
                &AppInfoResponse {
                    version: env!("CARGO_PKG_VERSION").to_string(),
                    apk_available: apk_size.is_some(),
                    apk_size,
                    download_url: "/download/showen-app.apk",
                },
            ))
        })
}

fn video_upload_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "videos" / "upload")
        .and(warp::post())
        .and(warp::multipart::form().max_length(500 * 1024 * 1024))
        .and(with_state(state))
        .and_then(handle_video_upload)
}

fn video_delete_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "videos" / String)
        .and(warp::delete())
        .and(with_state(state))
        .and_then(handle_video_delete)
}

fn wifi_status_route(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "wifi" / "status")
        .and(warp::get())
        .and(with_tx(tx))
        .and(with_state(state))
        .and_then(handle_wifi_status)
}

fn wifi_scan_route(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    let tx_for_post = tx.clone();
    let state_for_post = Arc::clone(&state);

    warp::path!("api" / "wifi" / "scan")
        .and(warp::get())
        .and(with_tx(tx))
        .and(with_state(Arc::clone(&state)))
        .and_then(handle_wifi_scan)
        .or(
            warp::path!("api" / "wifi" / "scan")
                .and(warp::post())
                .and(with_tx(tx_for_post))
                .and(with_state(state_for_post))
                .and_then(handle_wifi_scan),
        )
}

fn wifi_connect_route(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "wifi" / "connect")
        .and(warp::post())
        .and(warp::body::json())
        .and(with_tx(tx))
        .and(with_state(state))
        .and_then(|req: WifiConnectRequest, tx, state| async move {
            wifi_action_reply(
                tx,
                state,
                WifiCommand::Connect {
                    ssid: req.ssid,
                    password: req.password,
                },
                |payload| {
                    let ssid = payload
                        .get("ssid")
                        .and_then(Value::as_str)
                        .unwrap_or("未知网络");
                    format!("WiFi 连接成功: {ssid}")
                },
            )
            .await
        })
}

fn wifi_ap_start_route(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    let tx_for_hotspot = tx.clone();
    let state_for_hotspot = Arc::clone(&state);

    warp::path!("api" / "wifi" / "ap" / "start")
        .and(warp::post())
        .and(warp::body::bytes())
        .and(with_tx(tx))
        .and(with_state(Arc::clone(&state)))
        .and_then(|body: bytes::Bytes, tx, state| async move {
            let req: WifiApStartRequest = match parse_optional_json(&body) {
                Ok(req) => req,
                Err(reply) => return Ok::<_, Infallible>(*reply),
            };
            let ssid = req.ssid.unwrap_or_else(|| "showen".to_string());
            let password = req.password.unwrap_or_else(|| "12345678".to_string());
            let success_ssid = ssid.clone();
            wifi_action_reply(
                tx,
                state,
                WifiCommand::ApStart { ssid, password },
                move |_| format!("AP 热点已启动: SSID={success_ssid}"),
            )
            .await
        })
        .or(
            warp::path!("api" / "wifi" / "hotspot" / "start")
                .and(warp::post())
                .and(warp::body::bytes())
                .and(with_tx(tx_for_hotspot))
                .and(with_state(state_for_hotspot))
                .and_then(|body: bytes::Bytes, tx, state| async move {
                    let req: WifiApStartRequest = match parse_optional_json(&body) {
                        Ok(req) => req,
                        Err(reply) => return Ok::<_, Infallible>(*reply),
                    };
                    let ssid = req.ssid.unwrap_or_else(|| "showen".to_string());
                    let password = req.password.unwrap_or_else(|| "12345678".to_string());
                    let success_ssid = ssid.clone();
                    wifi_action_reply(
                        tx,
                        state,
                        WifiCommand::ApStart { ssid, password },
                        move |_| format!("AP 热点已启动: SSID={success_ssid}"),
                    )
                    .await
                }),
        )
}

fn wifi_ap_stop_route(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    let tx_for_hotspot = tx.clone();
    let state_for_hotspot = Arc::clone(&state);

    warp::path!("api" / "wifi" / "ap" / "stop")
        .and(warp::post())
        .and(with_tx(tx))
        .and(with_state(Arc::clone(&state)))
        .and_then(|tx, state| async move {
            wifi_action_reply(tx, state, WifiCommand::ApStop, |_| {
                "AP 热点已关闭".to_string()
            })
            .await
        })
        .or(
            warp::path!("api" / "wifi" / "hotspot" / "stop")
                .and(warp::post())
                .and(with_tx(tx_for_hotspot))
                .and(with_state(state_for_hotspot))
                .and_then(|tx, state| async move {
                    wifi_action_reply(tx, state, WifiCommand::ApStop, |_| {
                        "AP 热点已关闭".to_string()
                    })
                    .await
                }),
        )
}

fn ble_start_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "ble" / "start")
        .and(warp::post())
        .and(warp::body::bytes())
        .and(with_state(state))
        .and_then(|body: bytes::Bytes, state: Arc<HttpState>| async move {
            let req: BleStartRequest = match parse_optional_json(&body) {
                Ok(req) => req,
                Err(reply) => return Ok::<_, Infallible>(*reply),
            };
            let config = state.config();
            let device_name = req
                .device_name
                .unwrap_or_else(|| config.ble.device_name.clone());
            Ok::<_, Infallible>(success_json(format!(
                "BLE 配网服务已内嵌运行中，设备名: {device_name}"
            )))
        })
}

fn ble_stop_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "ble" / "stop")
        .and(warp::post())
        .map(|| success_json("BLE 配网服务随主进程运行，无需手动停止"))
}

fn ble_status_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "ble" / "status")
        .and(warp::get())
        .and(with_state(state))
        .and_then(|state: Arc<HttpState>| async move {
            let config = state.config();
            Ok::<_, Infallible>(json_response(
                StatusCode::OK,
                &BleStatusResponse {
                    running: state.ble_ready(),
                    embedded: true,
                    device_name: config.ble.device_name.clone(),
                },
            ))
        })
}

async fn handle_config_update(
    body: bytes::Bytes,
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> Result<warp::reply::Response, Infallible> {
    let raw = match std::str::from_utf8(&body) {
        Ok(raw) => raw,
        Err(_) => {
            return Ok(error_json(
                StatusCode::BAD_REQUEST,
                "请求体不是有效的 UTF-8",
            ))
        }
    };

    let current = state.config();
    if let Err(error) = config::parse_str(raw, current.source_path.clone()) {
        return Ok(error_json(
            StatusCode::BAD_REQUEST,
            &format!("配置验证失败: {error}"),
        ));
    }

    if let Err(error) = std::fs::write(&current.source_path, raw) {
        return Ok(error_json(
            StatusCode::INTERNAL_SERVER_ERROR,
            &format!("写入配置文件失败: {error}"),
        ));
    }

    if let Err(error) = tx.send(Envelope {
        from: "http".to_string(),
        to: Destination::Manager,
        message: Message::ConfigReloadRequest,
    }) {
        return Ok(error_json(
            StatusCode::INTERNAL_SERVER_ERROR,
            &format!("发送配置重载请求失败: {error}"),
        ));
    }

    Ok(success_json("配置已保存，热重载将自动生效"))
}

async fn handle_video_upload(
    mut form: FormData,
    state: Arc<HttpState>,
) -> Result<warp::reply::Response, Infallible> {
    let dir = video_dir(state.config().as_ref());
    let mut uploaded = Vec::new();

    while let Ok(Some(part)) = form.try_next().await {
        let Some(filename) = part.filename() else {
            continue;
        };
        let safe_name = sanitize_filename(filename);
        if safe_name.is_empty() {
            continue;
        }

        if let Err(error) = stream_upload_part(part, &dir.join(&safe_name)).await {
            let status = if error.contains("文件大小超过限制") {
                StatusCode::PAYLOAD_TOO_LARGE
            } else {
                StatusCode::INTERNAL_SERVER_ERROR
            };
            return Ok(error_json(status, &error));
        }

        uploaded.push(safe_name);
    }

    if uploaded.is_empty() {
        Ok(error_json(StatusCode::BAD_REQUEST, "未找到上传文件"))
    } else {
        Ok(success_json(format!(
            "已上传 {} 个文件: {}",
            uploaded.len(),
            uploaded.join(", ")
        )))
    }
}

async fn handle_video_delete(
    filename: String,
    state: Arc<HttpState>,
) -> Result<impl Reply, Infallible> {
    if filename.contains("..") {
        return Ok(error_json(StatusCode::BAD_REQUEST, "无效的文件名"));
    }

    let target = video_dir(state.config().as_ref()).join(&filename);
    if !target.exists() {
        return Ok(error_json(StatusCode::NOT_FOUND, "文件不存在"));
    }

    match std::fs::remove_file(&target) {
        Ok(()) => Ok(success_json(format!("已删除: {filename}"))),
        Err(error) => Ok(error_json(
            StatusCode::INTERNAL_SERVER_ERROR,
            &format!("删除失败: {error}"),
        )),
    }
}

async fn handle_wifi_status(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> Result<warp::reply::Response, Infallible> {
    let payload = match wifi_request(tx, state, WifiCommand::Status).await {
        Ok(payload) => payload,
        Err(reply) => return Ok(reply),
    };

    let device = payload
        .get("devices")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .find(|item| {
            item.get("device_type").and_then(Value::as_str) == Some("wifi")
                && item.get("state").and_then(Value::as_str) == Some("connected")
        });

    let ssid = device
        .and_then(|item| item.get("connection"))
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();
    let ip = device
        .and_then(|item| item.get("ip4_addresses"))
        .and_then(Value::as_array)
        .and_then(|ips| ips.first())
        .and_then(Value::as_str)
        .map(strip_cidr)
        .unwrap_or_default();

    Ok(json_response(
        StatusCode::OK,
        &WifiStatusResponse {
            connected: !ssid.is_empty(),
            ssid,
            ip,
        },
    ))
}

async fn handle_wifi_scan(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) -> Result<warp::reply::Response, Infallible> {
    let payload = match wifi_request(tx, state, WifiCommand::Scan).await {
        Ok(payload) => payload,
        Err(reply) => return Ok(reply),
    };

    let networks = payload
        .get("networks")
        .cloned()
        .unwrap_or_else(|| Value::Array(Vec::new()));
    Ok(json_response(StatusCode::OK, &networks))
}

async fn send_video_command(
    tx: mpsc::Sender<Envelope>,
    message: Message,
    success_message: impl Into<String>,
) -> Result<warp::reply::Response, Infallible> {
    match tx.send(Envelope {
        from: "http".to_string(),
        to: Destination::Plugin("video".to_string()),
        message,
    }) {
        Ok(()) => Ok(success_json(success_message.into())),
        Err(error) => Ok(error_json(
            StatusCode::INTERNAL_SERVER_ERROR,
            &format!("发送命令失败: {error}"),
        )),
    }
}

async fn wifi_action_reply<F>(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
    command: WifiCommand,
    build_message: F,
) -> Result<warp::reply::Response, Infallible>
where
    F: FnOnce(&Value) -> String,
{
    let payload = match wifi_request(tx, state, command).await {
        Ok(payload) => payload,
        Err(reply) => return Ok(reply),
    };

    Ok(success_json(build_message(&payload)))
}

async fn wifi_request(
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
    command: WifiCommand,
) -> Result<Value, warp::reply::Response> {
    let _request_guard = state.wifi_request_lock.lock().await;
    let version = match state.wifi_response.lock() {
        Ok(guard) => guard.version,
        Err(_) => {
            return Err(error_json(
                StatusCode::INTERNAL_SERVER_ERROR,
                "WiFi 响应状态锁已损坏",
            ));
        }
    };

    if let Err(error) = tx.send(Envelope {
        from: "http".to_string(),
        to: Destination::Plugin("wifi".to_string()),
        message: Message::WifiCommand(command),
    }) {
        return Err(error_json(
            StatusCode::INTERNAL_SERVER_ERROR,
            &format!("发送 WiFi 命令失败: {error}"),
        ));
    }

    let deadline = Instant::now() + Duration::from_secs(10);
    let mut guard = match state.wifi_response.lock() {
        Ok(guard) => guard,
        Err(_) => {
            return Err(error_json(
                StatusCode::INTERNAL_SERVER_ERROR,
                "WiFi 响应状态锁已损坏",
            ));
        }
    };

    while guard.version == version {
        let now = Instant::now();
        if now >= deadline {
            return Err(error_json(
                StatusCode::GATEWAY_TIMEOUT,
                "等待 WiFi 响应超时",
            ));
        }

        let result = state
            .wifi_response_cv
            .wait_timeout(guard, deadline.saturating_duration_since(now));
        let (next_guard, wait_result) = match result {
            Ok(result) => result,
            Err(_) => {
                return Err(error_json(
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "等待 WiFi 响应失败",
                ));
            }
        };
        guard = next_guard;

        if wait_result.timed_out() && guard.version == version {
            return Err(error_json(
                StatusCode::GATEWAY_TIMEOUT,
                "等待 WiFi 响应超时",
            ));
        }
    }

    let raw = guard.payload.clone().unwrap_or_default();
    let payload: Value = match serde_json::from_str(&raw) {
        Ok(payload) => payload,
        Err(error) => {
            return Err(error_json(
                StatusCode::BAD_GATEWAY,
                &format!("WiFi 返回了无效 JSON: {error}"),
            ));
        }
    };

    if payload.get("ok").and_then(Value::as_bool) == Some(false) {
        let message = payload
            .get("error")
            .and_then(Value::as_str)
            .unwrap_or("WiFi 操作失败");
        return Err(error_json(StatusCode::INTERNAL_SERVER_ERROR, message));
    }

    Ok(payload)
}

async fn stream_upload_part(
    part: warp::multipart::Part,
    destination: &Path,
) -> Result<(), String> {
    let parent = destination
        .parent()
        .ok_or_else(|| "上传目标目录无效".to_string())?;
    let temp_path = parent.join(format!(
        ".upload-{}-{}.part",
        std::process::id(),
        UPLOAD_TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
    ));

    let mut file = match tokio::fs::File::create(&temp_path).await {
        Ok(file) => file,
        Err(error) => return Err(format!("创建临时文件失败: {error}")),
    };

    let mut total_size = 0u64;
    let mut stream = part.stream();

    while let Some(chunk) = match stream.try_next().await {
        Ok(chunk) => chunk,
        Err(error) => {
            let _ = tokio::fs::remove_file(&temp_path).await;
            return Err(format!("读取文件失败: {error}"));
        }
    } {
        let chunk_size = chunk.remaining() as u64;
        total_size = total_size.saturating_add(chunk_size);
        if total_size > MAX_UPLOAD_FILE_SIZE {
            let _ = file.flush().await;
            drop(file);
            let _ = tokio::fs::remove_file(&temp_path).await;
            return Err(format!(
                "文件大小超过限制: 单文件最大 {} MB",
                MAX_UPLOAD_FILE_SIZE / 1024 / 1024
            ));
        }

        if let Err(error) = file.write_all(chunk.chunk()).await {
            drop(file);
            let _ = tokio::fs::remove_file(&temp_path).await;
            return Err(format!("写入临时文件失败: {error}"));
        }
    }

    if let Err(error) = file.flush().await {
        drop(file);
        let _ = tokio::fs::remove_file(&temp_path).await;
        return Err(format!("刷新临时文件失败: {error}"));
    }

    drop(file);

    if let Err(error) = tokio::fs::rename(&temp_path, destination).await {
        let _ = tokio::fs::remove_file(&temp_path).await;
        return Err(format!("保存文件失败: {error}"));
    }

    Ok(())
}

async fn websocket_session(
    ws: warp::ws::WebSocket,
    tx: mpsc::Sender<Envelope>,
    state: Arc<HttpState>,
) {
    let (mut sender, mut receiver) = ws.split();
    let mut events = state.ws_subscribe();

    for payload in state.ws_snapshots() {
        if sender.send(warp::ws::Message::text(payload)).await.is_err() {
            return;
        }
    }

    loop {
        tokio::select! {
            event = events.recv() => {
                match event {
                    Ok(payload) => {
                        if sender.send(warp::ws::Message::text(payload)).await.is_err() {
                            break;
                        }
                    }
                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
                }
            }
            incoming = receiver.next() => {
                match incoming {
                    Some(Ok(message)) => {
                        if message.is_ping() {
                            if sender.send(warp::ws::Message::pong(message.as_bytes())).await.is_err() {
                                break;
                            }
                        } else if message.is_close() {
                            break;
                        } else if message.is_text() {
                            let reply = handle_ws_command(message.to_str().unwrap_or(""), &tx);
                            if sender.send(warp::ws::Message::text(reply)).await.is_err() {
                                break;
                            }
                        }
                    }
                    Some(Err(_)) | None => break,
                }
            }
        }
    }
}

/// 解析 WebSocket 收到的 JSON 命令，返回 JSON 响应字符串。
///
/// 输入格式: `{"cmd":"play"}` 或 `{"cmd":"goto","index":3}` 或
///           `{"cmd":"connect","ssid":"x","password":"y"}` 等
fn handle_ws_command(text: &str, tx: &mpsc::Sender<Envelope>) -> String {
    let json: serde_json::Value = match serde_json::from_str(text) {
        Ok(v) => v,
        Err(_) => return r#"{"ok":false,"error":"invalid JSON"}"#.to_string(),
    };

    let cmd = match json.get("cmd").and_then(|v| v.as_str()) {
        Some(c) => c,
        None => return r#"{"ok":false,"error":"missing cmd field"}"#.to_string(),
    };

    // 将 JSON 字段组合为文本命令字符串
    let command_str = build_command_string(cmd, &json);

    // 从 JSON 中提取 ssid/password 作为 hint（用于无参数的 connect/ap_start）
    let ssid_hint = json
        .get("ssid")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let password_hint = json
        .get("password")
        .and_then(|v| v.as_str())
        .unwrap_or("");

    match dispatch::parse_command(&command_str, "ws", ssid_hint, password_hint) {
        Ok(result) => {
            if tx.send(result.envelope).is_ok() {
                format!(r#"{{"ok":true,"cmd":"{}"}}"#, cmd)
            } else {
                r#"{"ok":false,"error":"channel closed"}"#.to_string()
            }
        }
        Err(error) => {
            format!(
                r#"{{"ok":false,"cmd":"{}","error":"{}"}}"#,
                cmd,
                error.replace('"', "\\\"")
            )
        }
    }
}

/// 从 JSON 对象组装文本命令字符串。
/// 例: `{"cmd":"goto","index":3}` -> `"goto:3"`
///     `{"cmd":"scene","name":"idle"}` -> `"scene:idle"`
///     `{"cmd":"trigger","name":"voice","value":"hi"}` -> `"trigger:voice:hi"`
///     `{"cmd":"connect","ssid":"x","password":"y"}` -> `"connect:x:y"`
fn build_command_string(cmd: &str, json: &serde_json::Value) -> String {
    match cmd {
        "goto" => {
            if let Some(index) = json.get("index").and_then(|v| v.as_u64()) {
                format!("goto:{index}")
            } else {
                "goto".to_string()
            }
        }
        "scene" => {
            if let Some(name) = json.get("name").and_then(|v| v.as_str()) {
                format!("scene:{name}")
            } else {
                "scene".to_string()
            }
        }
        "trigger" => {
            let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("");
            let value = json.get("value").and_then(|v| v.as_str()).unwrap_or("");
            if name.is_empty() {
                "trigger".to_string()
            } else {
                format!("trigger:{name}:{value}")
            }
        }
        "connect" => {
            let ssid = json.get("ssid").and_then(|v| v.as_str()).unwrap_or("");
            let password = json.get("password").and_then(|v| v.as_str()).unwrap_or("");
            if ssid.is_empty() {
                "connect".to_string()
            } else {
                format!("connect:{ssid}:{password}")
            }
        }
        "ap_start" => {
            let ssid = json.get("ssid").and_then(|v| v.as_str()).unwrap_or("");
            let password = json.get("password").and_then(|v| v.as_str()).unwrap_or("");
            if ssid.is_empty() {
                "ap_start".to_string()
            } else {
                format!("ap_start:{ssid}:{password}")
            }
        }
        _ => cmd.to_string(),
    }
}

fn parse_optional_json<T>(body: &bytes::Bytes) -> Result<T, Box<warp::reply::Response>>
where
    T: DeserializeOwned + Default,
{
    if body.is_empty() {
        return Ok(T::default());
    }

    serde_json::from_slice(body).map_err(|error| {
        Box::new(error_json(
            StatusCode::BAD_REQUEST,
            &format!("JSON 格式错误: {error}"),
        ))
    })
}

// ── 文件管理 API ──

/// 根据 dir key 解析到实际文件系统路径。仅允许 videos/configs/plugins 三个目录。
fn resolve_managed_dir(dir_key: &str, config: &AppConfig) -> Option<PathBuf> {
    match dir_key {
        "videos" => Some(video_dir(config)),
        "configs" => {
            // source_dir 就是 configs/ 目录
            Some(config.source_dir.clone())
        }
        "plugins" => {
            // plugin_store/ 在项目根目录下（source_dir 的父目录）
            let project_root = config.source_dir.parent()?;
            Some(project_root.join("plugin_store"))
        }
        _ => None,
    }
}

/// 验证相对路径不逃逸出基目录。返回规范化后的完整路径。
fn validate_managed_path(base: &Path, relative: &str) -> Option<PathBuf> {
    if relative.contains("..") || relative.starts_with('/') || relative.starts_with('\\') {
        return None;
    }
    let full = base.join(relative);
    if let Ok(canonical_base) = base.canonicalize() {
        if full.exists() {
            let canonical = full.canonicalize().ok()?;
            if canonical.starts_with(&canonical_base) {
                return Some(canonical);
            }
        } else {
            // 新文件：确保父目录存在且在 base 内
            let parent = full.parent()?;
            if parent.exists() {
                let canonical_parent = parent.canonicalize().ok()?;
                if canonical_parent.starts_with(&canonical_base) {
                    return Some(full);
                }
            }
        }
    }
    None
}

fn list_dir_entries(dir: &Path) -> Vec<FileEntry> {
    let mut entries = Vec::new();
    if let Ok(rd) = std::fs::read_dir(dir) {
        for entry in rd.flatten() {
            if let Ok(meta) = entry.metadata() {
                entries.push(FileEntry {
                    name: entry.file_name().to_string_lossy().into_owned(),
                    is_dir: meta.is_dir(),
                    size: if meta.is_file() { meta.len() } else { 0 },
                });
            }
        }
    }
    entries.sort_by(|a, b| {
        // 目录排前面
        b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name))
    });
    entries
}

fn file_list_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "files" / String)
        .and(warp::get())
        .and(warp::query::raw().or(warp::any().map(String::new)).unify())
        .and(with_state(state))
        .and_then(|dir_key: String, query: String, state: Arc<HttpState>| async move {
            let config = state.config();
            let base = match resolve_managed_dir(&dir_key, config.as_ref()) {
                Some(d) => d,
                None => return Ok::<_, Infallible>(error_json(
                    StatusCode::BAD_REQUEST,
                    &format!("不支持的目录: {dir_key}（仅支持 videos/configs/plugins）"),
                )),
            };

            // 支持 ?path=subdir 子目录浏览
            let sub = query
                .split('&')
                .find_map(|kv| {
                    let mut parts = kv.splitn(2, '=');
                    if parts.next()? == "path" {
                        Some(
                            percent_decode(parts.next().unwrap_or(""))
                        )
                    } else {
                        None
                    }
                })
                .unwrap_or_default();

            let target = if sub.is_empty() {
                base.clone()
            } else {
                match validate_managed_path(&base, &sub) {
                    Some(p) => p,
                    None => return Ok(error_json(StatusCode::FORBIDDEN, "路径不合法")),
                }
            };

            if !target.is_dir() {
                return Ok(error_json(StatusCode::NOT_FOUND, "目录不存在"));
            }

            Ok(json_response(StatusCode::OK, &list_dir_entries(&target)))
        })
}

fn file_upload_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "files" / String / "upload")
        .and(warp::post())
        .and(warp::multipart::form().max_length(500 * 1024 * 1024))
        .and(warp::query::raw().or(warp::any().map(String::new)).unify())
        .and(with_state(state))
        .and_then(|dir_key: String, mut form: FormData, query: String, state: Arc<HttpState>| async move {
            let config = state.config();
            let base = match resolve_managed_dir(&dir_key, config.as_ref()) {
                Some(d) => d,
                None => return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "不支持的目录")),
            };

            let sub = query
                .split('&')
                .find_map(|kv| {
                    let mut parts = kv.splitn(2, '=');
                    if parts.next()? == "path" {
                        Some(percent_decode(parts.next().unwrap_or("")))
                    } else {
                        None
                    }
                })
                .unwrap_or_default();

            let target_dir = if sub.is_empty() {
                base.clone()
            } else {
                match validate_managed_path(&base, &sub) {
                    Some(p) if p.is_dir() => p,
                    _ => return Ok(error_json(StatusCode::FORBIDDEN, "目标目录不合法")),
                }
            };

            let mut uploaded = Vec::new();
            while let Ok(Some(part)) = form.try_next().await {
                let Some(filename) = part.filename() else { continue };
                let safe_name = sanitize_filename(filename);
                if safe_name.is_empty() { continue }

                let dest = target_dir.join(&safe_name);
                if validate_managed_path(&base, &dest.strip_prefix(&base).unwrap_or(Path::new("")).to_string_lossy()).is_none() {
                    continue;
                }

                if let Err(error) = stream_upload_part(part, &dest).await {
                    let status = if error.contains("文件大小超过限制") {
                        StatusCode::PAYLOAD_TOO_LARGE
                    } else {
                        StatusCode::INTERNAL_SERVER_ERROR
                    };
                    return Ok(error_json(status, &error));
                }
                uploaded.push(safe_name);
            }

            if uploaded.is_empty() {
                Ok(error_json(StatusCode::BAD_REQUEST, "未找到上传文件"))
            } else {
                Ok(success_json(format!("已上传 {} 个文件: {}", uploaded.len(), uploaded.join(", "))))
            }
        })
}

fn file_download_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "files" / String / "download")
        .and(warp::get())
        .and(warp::query::raw().or(warp::any().map(String::new)).unify())
        .and(with_state(state))
        .and_then(|dir_key: String, query: String, state: Arc<HttpState>| async move {
            let config = state.config();
            let base = match resolve_managed_dir(&dir_key, config.as_ref()) {
                Some(d) => d,
                None => return Ok::<_, Infallible>(
                    warp::reply::with_status(
                        warp::reply::html("不支持的目录".to_string()),
                        StatusCode::BAD_REQUEST,
                    ).into_response()
                ),
            };

            let file_path = query
                .split('&')
                .find_map(|kv| {
                    let mut parts = kv.splitn(2, '=');
                    if parts.next()? == "path" {
                        Some(percent_decode(parts.next().unwrap_or("")))
                    } else {
                        None
                    }
                })
                .unwrap_or_default();

            if file_path.is_empty() {
                return Ok(warp::reply::with_status(
                    warp::reply::html("缺少 path 参数".to_string()),
                    StatusCode::BAD_REQUEST,
                ).into_response());
            }

            let full = match validate_managed_path(&base, &file_path) {
                Some(p) => p,
                None => return Ok(warp::reply::with_status(
                    warp::reply::html("路径不合法".to_string()),
                    StatusCode::FORBIDDEN,
                ).into_response()),
            };

            if !full.is_file() {
                return Ok(warp::reply::with_status(
                    warp::reply::html("文件不存在".to_string()),
                    StatusCode::NOT_FOUND,
                ).into_response());
            }

            Ok(read_attachment_response(&full))
        })
}

fn file_delete_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "files" / String / "delete")
        .and(warp::post())
        .and(warp::body::json::<serde_json::Value>())
        .and(with_state(state))
        .and_then(|dir_key: String, body: serde_json::Value, state: Arc<HttpState>| async move {
            let config = state.config();
            let base = match resolve_managed_dir(&dir_key, config.as_ref()) {
                Some(d) => d,
                None => return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "不支持的目录")),
            };

            let path = body.get("path").and_then(|v| v.as_str()).unwrap_or("");
            if path.is_empty() {
                return Ok(error_json(StatusCode::BAD_REQUEST, "缺少 path"));
            }

            let full = match validate_managed_path(&base, path) {
                Some(p) => p,
                None => return Ok(error_json(StatusCode::FORBIDDEN, "路径不合法")),
            };

            if !full.exists() {
                return Ok(error_json(StatusCode::NOT_FOUND, "文件不存在"));
            }

            let result = if full.is_dir() {
                std::fs::remove_dir_all(&full)
            } else {
                std::fs::remove_file(&full)
            };

            match result {
                Ok(()) => Ok(success_json(format!("已删除: {path}"))),
                Err(e) => Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("删除失败: {e}"))),
            }
        })
}

fn file_move_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "files" / "move")
        .and(warp::post())
        .and(warp::body::json::<FileMoveRequest>())
        .and(with_state(state))
        .and_then(|req: FileMoveRequest, state: Arc<HttpState>| async move {
            let config = state.config();
            let src_base = match resolve_managed_dir(&req.from_dir, config.as_ref()) {
                Some(d) => d,
                None => return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "源目录不支持")),
            };
            let dst_base = match resolve_managed_dir(&req.to_dir, config.as_ref()) {
                Some(d) => d,
                None => return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "目标目录不支持")),
            };

            let src = match validate_managed_path(&src_base, &req.from_path) {
                Some(p) => p,
                None => return Ok(error_json(StatusCode::FORBIDDEN, "源路径不合法")),
            };
            let dst = match validate_managed_path(&dst_base, &req.to_path) {
                Some(p) => p,
                None => return Ok(error_json(StatusCode::FORBIDDEN, "目标路径不合法")),
            };

            if !src.exists() {
                return Ok(error_json(StatusCode::NOT_FOUND, "源文件不存在"));
            }

            if dst.exists() {
                return Ok(error_json(StatusCode::CONFLICT, "目标路径已存在"));
            }

            // 先尝试 rename（同文件系统），失败则 copy+delete
            match std::fs::rename(&src, &dst) {
                Ok(()) => Ok(success_json(format!("已移动: {} → {}", req.from_path, req.to_path))),
                Err(_) => {
                    // 跨文件系统：copy then delete
                    match std::fs::copy(&src, &dst) {
                        Ok(_) => {
                            let _ = std::fs::remove_file(&src);
                            Ok(success_json(format!("已移动: {} → {}", req.from_path, req.to_path)))
                        }
                        Err(e) => Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("移动失败: {e}"))),
                    }
                }
            }
        })
}

fn file_mkdir_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "files" / String / "mkdir")
        .and(warp::post())
        .and(warp::body::json::<serde_json::Value>())
        .and(with_state(state))
        .and_then(|dir_key: String, body: serde_json::Value, state: Arc<HttpState>| async move {
            let config = state.config();
            let base = match resolve_managed_dir(&dir_key, config.as_ref()) {
                Some(d) => d,
                None => return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "不支持的目录")),
            };

            let path = body.get("path").and_then(|v| v.as_str()).unwrap_or("");
            if path.is_empty() {
                return Ok(error_json(StatusCode::BAD_REQUEST, "缺少 path"));
            }

            let full = match validate_managed_path(&base, path) {
                Some(p) => p,
                None => return Ok(error_json(StatusCode::FORBIDDEN, "路径不合法")),
            };

            match std::fs::create_dir_all(&full) {
                Ok(()) => Ok(success_json(format!("已创建目录: {path}"))),
                Err(e) => Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("创建失败: {e}"))),
            }
        })
}

fn percent_decode(s: &str) -> String {
    let mut bytes = Vec::with_capacity(s.len());
    let mut chars = s.bytes();
    while let Some(b) = chars.next() {
        if b == b'%' {
            let h = chars.next().unwrap_or(0);
            let l = chars.next().unwrap_or(0);
            let hex = [h, l];
            if let Ok(hex_str) = std::str::from_utf8(&hex) {
                if let Ok(val) = u8::from_str_radix(hex_str, 16) {
                    bytes.push(val);
                    continue;
                }
            }
            bytes.push(b'%');
            bytes.push(h);
            bytes.push(l);
        } else if b == b'+' {
            bytes.push(b' ');
        } else {
            bytes.push(b);
        }
    }
    String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
}

fn list_video_files(dir: &Path) -> Vec<VideoFileInfo> {
    let mut files = Vec::new();

    if let Ok(entries) = std::fs::read_dir(dir) {
        for entry in entries.flatten() {
            if let Ok(meta) = entry.metadata() {
                if meta.is_file() {
                    files.push(VideoFileInfo {
                        name: entry.file_name().to_string_lossy().into_owned(),
                        size: meta.len(),
                    });
                }

                if meta.is_dir() {
                    let prefix = entry.file_name().to_string_lossy().into_owned();
                    if let Ok(sub_entries) = std::fs::read_dir(entry.path()) {
                        for sub_entry in sub_entries.flatten() {
                            if let Ok(sub_meta) = sub_entry.metadata() {
                                if sub_meta.is_file() {
                                    files.push(VideoFileInfo {
                                        name: format!(
                                            "{prefix}/{}",
                                            sub_entry.file_name().to_string_lossy()
                                        ),
                                        size: sub_meta.len(),
                                    });
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    files.sort_by(|left, right| left.name.cmp(&right.name));
    files
}

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

fn video_dir(config: &AppConfig) -> PathBuf {
    if let Some(first) = config.playlist.first() {
        let resolved = config.resolve_media_path(first);
        return resolved
            .parent()
            .map(Path::to_path_buf)
            .unwrap_or_else(|| config.source_dir.clone());
    }

    config.source_dir.clone()
}

fn downloads_dir(config: &AppConfig) -> PathBuf {
    config.source_dir.join("downloads")
}

fn read_attachment_response(path: &Path) -> warp::reply::Response {
    match std::fs::read(path) {
        Ok(data) => {
            let filename = path.file_name().unwrap_or_default().to_string_lossy();
            // Sanitize filename for Content-Disposition header to prevent header injection
            let safe_filename = filename
                .replace('\\', "_")
                .replace('"', "_")
                .replace('\r', "")
                .replace('\n', "");
            match warp::http::Response::builder()
                .header("Content-Type", "application/octet-stream")
                .header(
                    "Content-Disposition",
                    format!("attachment; filename=\"{safe_filename}\""),
                )
                .header("Content-Length", data.len().to_string())
                .body(data)
            {
                Ok(response) => response.into_response(),
                Err(_) => warp::reply::with_status(
                    warp::reply::html("响应构建失败".to_string()),
                    StatusCode::INTERNAL_SERVER_ERROR,
                )
                .into_response(),
            }
        }
        Err(e) => warp::reply::with_status(
            warp::reply::html(format!("读取失败: {e}")),
            StatusCode::INTERNAL_SERVER_ERROR,
        )
        .into_response(),
    }
}

fn strip_cidr(value: &str) -> String {
    value.split('/').next().unwrap_or_default().to_string()
}

fn with_tx(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = (mpsc::Sender<Envelope>,), Error = Infallible> + Clone {
    warp::any().map(move || tx.clone())
}

fn with_state(
    state: Arc<HttpState>,
) -> impl Filter<Extract = (Arc<HttpState>,), Error = Infallible> + Clone {
    warp::any().map(move || Arc::clone(&state))
}

fn success_json(message: impl Into<String>) -> warp::reply::Response {
    json_response(
        StatusCode::OK,
        &ApiMessage {
            status: "ok",
            message: message.into(),
        },
    )
}

fn error_json(status: StatusCode, message: &str) -> warp::reply::Response {
    json_response(
        status,
        &ApiMessage {
            status: "error",
            message: message.to_string(),
        },
    )
}

// ── 插件管理 API ──

fn plugins_list_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "plugins")
        .and(warp::get())
        .and(with_state(state))
        .and_then(|state: Arc<HttpState>| async move {
            Ok::<_, Infallible>(json_response(StatusCode::OK, &state.plugin_states()))
        })
}

fn plugin_detail_route(
    state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "plugins" / String)
        .and(warp::get())
        .and(with_state(state))
        .and_then(|id: String, state: Arc<HttpState>| async move {
            let plugins = state.plugin_states();
            match plugins.iter().find(|p| p.id == id) {
                Some(info) => Ok::<_, Infallible>(json_response(StatusCode::OK, info)),
                None => Ok(error_json(StatusCode::NOT_FOUND, &format!("plugin '{}' not found", id))),
            }
        })
}

#[derive(Deserialize)]
struct PluginSwitchRequest {
    version: String,
}

#[derive(Deserialize)]
struct PluginInstallRequest {
    id: String,
    #[serde(default)]
    version: Option<String>,
}

fn plugin_enable_route(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "plugins" / String / "enable")
        .and(warp::post())
        .and(with_tx(tx))
        .and_then(|id: String, tx: mpsc::Sender<Envelope>| async move {
            send_plugin_command(tx, "plugin_enable", &id).await
        })
}

fn plugin_disable_route(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "plugins" / String / "disable")
        .and(warp::post())
        .and(with_tx(tx))
        .and_then(|id: String, tx: mpsc::Sender<Envelope>| async move {
            send_plugin_command(tx, "plugin_disable", &id).await
        })
}

fn plugin_rollback_route(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "plugins" / String / "rollback")
        .and(warp::post())
        .and(with_tx(tx))
        .and_then(|id: String, tx: mpsc::Sender<Envelope>| async move {
            send_plugin_command(tx, "plugin_rollback", &id).await
        })
}

fn plugin_switch_route(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "plugins" / String / "switch")
        .and(warp::post())
        .and(warp::body::json::<PluginSwitchRequest>())
        .and(with_tx(tx))
        .and_then(
            |id: String, body: PluginSwitchRequest, tx: mpsc::Sender<Envelope>| async move {
                let payload = serde_json::json!({
                    "id": id,
                    "version": body.version,
                })
                .to_string();

                match tx.send(Envelope {
                    from: "http".to_string(),
                    to: Destination::Manager,
                    message: Message::Custom {
                        kind: "plugin_switch".to_string(),
                        payload,
                    },
                }) {
                    Ok(()) => Ok::<_, Infallible>(success_json(
                        format!("版本切换请求已发送: {} -> v{}", id, body.version),
                    )),
                    Err(e) => Ok(error_json(
                        StatusCode::INTERNAL_SERVER_ERROR,
                        &format!("发送失败: {e}"),
                    )),
                }
            },
        )
}

fn plugin_install_route(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "plugins" / "install")
        .and(warp::post())
        .and(warp::body::json::<PluginInstallRequest>())
        .and(with_tx(tx))
        .and_then(
            |body: PluginInstallRequest, tx: mpsc::Sender<Envelope>| async move {
                let payload = serde_json::json!({
                    "id": body.id,
                    "version": body.version,
                })
                .to_string();

                match tx.send(Envelope {
                    from: "http".to_string(),
                    to: Destination::Manager,
                    message: Message::Custom {
                        kind: "plugin_install".to_string(),
                        payload,
                    },
                }) {
                    Ok(()) => Ok::<_, Infallible>(success_json(
                        format!("安装请求已发送: {}", body.id),
                    )),
                    Err(e) => Ok(error_json(
                        StatusCode::INTERNAL_SERVER_ERROR,
                        &format!("发送失败: {e}"),
                    )),
                }
            },
        )
}

fn plugin_check_updates_route(
    tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
    warp::path!("api" / "plugins" / "check-updates")
        .and(warp::post())
        .and(with_tx(tx))
        .and_then(|tx: mpsc::Sender<Envelope>| async move {
            send_plugin_command(tx, "plugin_check_updates", "").await
        })
}

async fn send_plugin_command(
    tx: mpsc::Sender<Envelope>,
    kind: &str,
    plugin_id: &str,
) -> Result<warp::reply::Response, Infallible> {
    match tx.send(Envelope {
        from: "http".to_string(),
        to: Destination::Manager,
        message: Message::Custom {
            kind: kind.to_string(),
            payload: plugin_id.to_string(),
        },
    }) {
        Ok(()) => Ok(success_json(format!("{kind} 命令已发送"))),
        Err(e) => Ok(error_json(
            StatusCode::INTERNAL_SERVER_ERROR,
            &format!("发送失败: {e}"),
        )),
    }
}

fn json_response<T: Serialize>(status: StatusCode, payload: &T) -> warp::reply::Response {
    warp::reply::with_status(warp::reply::json(payload), status).into_response()
}

const WEB_UI_HTML: &str = r##"<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Showen 控制台</title>
  <style>
    :root{--bg:#0f1117;--surface:#1a1d27;--surface2:#242836;--border:#2e3345;--text:#e4e6ef;--muted:#8b8fa3;--accent:#6366f1;--accent-glow:rgba(99,102,241,.15);--green:#22c55e;--amber:#f59e0b;--red:#ef4444;--radius:12px}
    *{box-sizing:border-box;margin:0;padding:0}
    body{background:var(--bg);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;min-height:100vh}
    ::selection{background:var(--accent);color:#fff}
    ::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}

    .app{max-width:1200px;margin:0 auto;padding:16px 20px}
    header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px;flex-wrap:wrap}
    header h1{font-size:20px;font-weight:600;background:linear-gradient(135deg,#6366f1,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
    header .badge{font-size:11px;padding:3px 10px;border-radius:99px;background:var(--accent-glow);color:var(--accent);border:1px solid rgba(99,102,241,.3)}
    .header-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
    .header-link{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:8px 16px;border-radius:8px;color:#fff;font:inherit;cursor:pointer;transition:all .15s;text-decoration:none}

    .modal-backdrop{position:fixed;inset:0;background:rgba(10,12,18,.72);backdrop-filter:blur(10px);display:none;align-items:center;justify-content:center;padding:20px;z-index:998}
    .modal-backdrop.open{display:flex}
    .download-modal{width:min(100%,420px);background:linear-gradient(180deg,rgba(36,40,54,.98),rgba(26,29,39,.98));border:1px solid rgba(99,102,241,.28);border-radius:20px;padding:22px;box-shadow:0 24px 60px rgba(0,0,0,.4)}
    .download-modal h2{font-size:22px;margin-bottom:8px}
    .download-modal p{color:var(--muted);margin-bottom:18px}
    .download-qr{display:flex;justify-content:center;align-items:center;padding:14px;background:rgba(99,102,241,.08);border:1px solid rgba(99,102,241,.2);border-radius:16px;margin-bottom:16px}
    .download-qr img{display:block;width:180px;height:180px;border-radius:12px;background:#fff}
    .download-actions{display:flex;gap:10px;flex-wrap:wrap}
    .download-actions>*{flex:1}
    .download-meta{font-size:13px;color:var(--text);margin-bottom:14px;padding:10px 12px;border-radius:12px;background:rgba(255,255,255,.03);border:1px solid var(--border)}
    .download-meta.warn{color:var(--amber);border-color:rgba(245,158,11,.3);background:rgba(245,158,11,.08)}
    .download-note{font-size:12px;color:var(--muted);margin-top:12px}

    .tabs{display:flex;gap:4px;background:var(--surface);border-radius:var(--radius);padding:4px;margin-bottom:16px;overflow-x:auto}
    .tab{padding:8px 16px;border-radius:8px;border:0;background:0;color:var(--muted);cursor:pointer;font:inherit;white-space:nowrap;transition:all .2s}
    .tab:hover{color:var(--text);background:var(--surface2)}
    .tab.active{color:#fff;background:var(--accent);box-shadow:0 2px 8px rgba(99,102,241,.3)}
    .panel{display:none}.panel.active{display:block}

    .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:14px}
    .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px}
    .card h2{font-size:14px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px}
    .stats{display:grid;grid-template-columns:1fr 1fr;gap:8px}
    .stat{background:var(--surface2);border-radius:8px;padding:10px 12px}
    .stat .label{font-size:11px;color:var(--muted);display:block}.stat .val{font-size:16px;font-weight:600;color:var(--accent)}
    .stat .val.ok{color:var(--green)}.stat .val.warn{color:var(--amber)}.stat .val.err{color:var(--red)}

    .btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
    button{border:0;border-radius:8px;padding:8px 16px;font:inherit;cursor:pointer;transition:all .15s}
    .btn{background:var(--accent);color:#fff}.btn:hover{opacity:.85;transform:translateY(-1px)}
    .btn-s{background:var(--surface2);color:var(--text);border:1px solid var(--border)}.btn-s:hover{border-color:var(--accent);color:var(--accent)}
    .btn-d{background:rgba(239,68,68,.1);color:var(--red);border:1px solid rgba(239,68,68,.2)}.btn-d:hover{background:rgba(239,68,68,.2)}
    .btn-g{background:rgba(34,197,94,.1);color:var(--green);border:1px solid rgba(34,197,94,.2)}.btn-g:hover{background:rgba(34,197,94,.2)}

    input,select,textarea{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface2);color:var(--text);font:inherit;outline:0;transition:border-color .2s}
    input:focus,select:focus,textarea:focus{border-color:var(--accent)}
    textarea{min-height:200px;font-family:"Fira Code",monospace;font-size:13px;resize:vertical}
    label{display:block;font-size:12px;color:var(--muted);margin:8px 0 4px}
    .row{display:flex;gap:8px;align-items:end}.row>*{flex:1}

    .list{max-height:300px;overflow:auto;border:1px solid var(--border);border-radius:8px;background:var(--surface2)}
    .item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid var(--border);gap:8px;font-size:13px}
    .item:last-child{border-bottom:0}
    .item .name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
    .item .meta{color:var(--muted);font-size:12px;white-space:nowrap}
    .item .dir-icon{color:var(--amber);margin-right:4px}

    .toast{position:fixed;top:16px;right:16px;padding:10px 18px;border-radius:8px;color:#fff;font-size:13px;display:none;z-index:999;backdrop-filter:blur(8px);max-width:360px}
    .toast.ok{background:rgba(34,197,94,.9)}.toast.err{background:rgba(239,68,68,.9)}

    .file-breadcrumb{display:flex;gap:4px;align-items:center;margin-bottom:10px;font-size:13px;flex-wrap:wrap}
    .file-breadcrumb span{color:var(--muted);cursor:pointer;padding:2px 6px;border-radius:4px}.file-breadcrumb span:hover{background:var(--surface2);color:var(--text)}
    .file-breadcrumb .sep{color:var(--border);cursor:default;padding:0}.file-breadcrumb .sep:hover{background:none;color:var(--border)}
    .file-toolbar{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;align-items:center}
    .file-toolbar select{width:auto;min-width:120px}
    .checkbox-label{display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer}
    .checkbox-label input[type=checkbox]{width:auto;accent-color:var(--accent)}

    @media (max-width:720px){.grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr}header h1{font-size:16px}}
  </style>
</head>
<body>
<div id="toast" class="toast"></div>
<div id="download-modal" class="modal-backdrop" onclick="closeDownloadModal(event)">
  <div class="download-modal">
    <h2>下载 Showen App</h2>
    <p id="download-modal-desc">扫描二维码或点击下载，支持蓝牙配网和远程控制</p>
    <div id="download-meta" class="download-meta">正在获取 App 信息...</div>
    <div class="download-qr"><img id="download-qr-image" alt="Showen App 下载二维码"></div>
    <div class="download-actions">
      <a id="download-apk-link" class="btn header-link" href="/download/showen-app.apk" download>下载 APK</a>
      <button class="btn-s" type="button" onclick="closeDownloadModal()">关闭</button>
    </div>
    <div class="download-note">若二维码加载失败，可直接点击下载按钮获取 APK。</div>
  </div>
</div>
<div class="app">
  <header>
    <h1>Showen 控制台</h1>
    <div class="header-actions">
      <button class="btn" type="button" onclick="openDownloadModal()">下载 App</button>
      <span class="badge" id="ws-badge">WS 连接中...</span>
    </div>
  </header>

  <div class="tabs">
    <button class="tab active" data-tab="control">播放控制</button>
    <button class="tab" data-tab="videos">视频管理</button>
    <button class="tab" data-tab="files">文件管理</button>
    <button class="tab" data-tab="wifi">网络设置</button>
    <button class="tab" data-tab="settings">系统配置</button>
  </div>

  <!-- 播放控制 -->
  <section class="panel active" id="panel-control">
    <div class="grid">
      <div class="card">
        <h2>播放状态</h2>
        <div class="stats">
          <div class="stat"><span class="label">状态</span><span id="st-state" class="val">--</span></div>
          <div class="stat"><span class="label">当前视频</span><span id="st-video" class="val">--</span></div>
          <div class="stat"><span class="label">索引</span><span id="st-index" class="val">--</span></div>
          <div class="stat"><span class="label">列表长度</span><span id="st-len" class="val">--</span></div>
        </div>
      </div>
      <div class="card">
        <h2>播放操作</h2>
        <div class="btns">
          <button class="btn-s" onclick="wsReady?wsCmd({cmd:'prev'}):api('POST','/api/previous')">上一个</button>
          <button class="btn" onclick="wsReady?wsCmd({cmd:'play'}):api('POST','/api/play')">播放</button>
          <button class="btn-s" onclick="wsReady?wsCmd({cmd:'pause'}):api('POST','/api/pause')">暂停</button>
          <button class="btn" onclick="wsReady?wsCmd({cmd:'next'}):api('POST','/api/next')">下一个</button>
        </div>
        <div class="row" style="margin-top:12px">
          <div><label>跳转到索引</label><input id="goto-idx" type="number" min="0" placeholder="0"></div>
          <div style="flex:0"><label>&nbsp;</label><button class="btn" onclick="gotoVideo()">跳转</button></div>
        </div>
      </div>
      <div class="card" style="grid-column:1/-1">
        <h2>触发器</h2>
        <div class="btns">
          <button class="btn-s" onclick="triggerPreset('voice','name')">语音唤醒</button>
          <button class="btn-s" onclick="triggerPreset('button','button1')">按钮 1</button>
          <button class="btn-s" onclick="triggerPreset('button','button2')">按钮 2</button>
          <button class="btn-s" onclick="triggerPreset('sensor','touch')">触摸感应</button>
        </div>
        <div class="row" style="margin-top:10px">
          <div><label>触发器名称</label><input id="tr-name" type="text" placeholder="voice"></div>
          <div><label>触发器值</label><input id="tr-value" type="text" placeholder="name"></div>
          <div style="flex:0"><label>&nbsp;</label><button class="btn" onclick="triggerCustom()">发送</button></div>
        </div>
      </div>
    </div>
  </section>

  <!-- 视频管理 -->
  <section class="panel" id="panel-videos">
    <div class="grid">
      <div class="card">
        <h2>上传视频</h2>
        <input id="upload-file" type="file" accept="video/*" multiple>
        <div class="btns"><button class="btn" onclick="uploadVideos()">上传</button></div>
      </div>
      <div class="card">
        <h2>设备视频文件</h2>
        <div id="video-list" class="list"><div class="item"><span class="name">加载中...</span></div></div>
        <div class="btns"><button class="btn-s" onclick="loadVideoList()">刷新</button></div>
      </div>
    </div>
  </section>

  <!-- 文件管理 -->
  <section class="panel" id="panel-files">
    <div class="card">
      <h2>文件管理器</h2>
      <div class="file-toolbar">
        <select id="fm-dir" onchange="fmNavigate('')">
          <option value="videos">视频目录</option>
          <option value="configs">配置目录</option>
          <option value="plugins">插件目录</option>
        </select>
        <button class="btn-s" onclick="fmRefresh()">刷新</button>
        <button class="btn-s" onclick="fmMkdir()">新建文件夹</button>
        <button class="btn" onclick="document.getElementById('fm-upload-input').click()">上传文件</button>
        <input id="fm-upload-input" type="file" multiple style="display:none" onchange="fmUpload(this)">
      </div>
      <div id="fm-breadcrumb" class="file-breadcrumb"></div>
      <div id="fm-list" class="list" style="max-height:400px"><div class="item"><span class="name">选择目录后加载...</span></div></div>
      <div class="btns" style="margin-top:8px">
        <button class="btn-d" id="fm-del-btn" onclick="fmDeleteSelected()" style="display:none">删除选中</button>
      </div>
    </div>
  </section>

  <!-- 网络设置 -->
  <section class="panel" id="panel-wifi">
    <div class="grid">
      <div class="card">
        <h2>网络状态</h2>
        <div class="stats">
          <div class="stat"><span class="label">WiFi</span><span id="wifi-connected" class="val">--</span></div>
          <div class="stat"><span class="label">SSID</span><span id="wifi-ssid" class="val">--</span></div>
          <div class="stat"><span class="label">IP</span><span id="wifi-ip" class="val">--</span></div>
          <div class="stat"><span class="label">BLE</span><span id="ble-status" class="val">--</span></div>
        </div>
        <div class="btns"><button class="btn-s" onclick="loadWifiStatus();loadBleStatus()">刷新状态</button></div>
      </div>
      <div class="card">
        <h2>WiFi 扫描</h2>
        <div id="wifi-list" class="list"><div class="item"><span class="name">点击扫描搜索附近网络</span></div></div>
        <div class="btns"><button class="btn" onclick="scanWifi()">扫描网络</button></div>
      </div>
      <div class="card">
        <h2>连接 WiFi</h2>
        <label>SSID</label><input id="wifi-ssid-input" type="text" placeholder="网络名称">
        <label>密码</label><input id="wifi-pass-input" type="password" placeholder="WiFi 密码">
        <div class="btns"><button class="btn" onclick="connectWifi()">连接</button></div>
      </div>
      <div class="card">
        <h2>热点 / BLE</h2>
        <label>热点名称</label><input id="ap-ssid" type="text" value="Showen">
        <label>热点密码</label><input id="ap-pass" type="text" value="12345678">
        <div class="btns">
          <button class="btn-g" onclick="startAP()">开启热点</button>
          <button class="btn-d" onclick="stopAP()">关闭热点</button>
        </div>
        <label>BLE 设备名</label><input id="ble-name" type="text" value="Showen">
        <div class="btns">
          <button class="btn-s" onclick="startBLE()">启动 BLE</button>
          <button class="btn-d" onclick="stopBLE()">停止 BLE</button>
        </div>
      </div>
    </div>
  </section>

  <!-- 系统配置 -->
  <section class="panel" id="panel-settings">
    <div class="grid">
      <div class="card" style="grid-column:1/-1">
        <h2>配置文件切换</h2>
        <div class="row">
          <div><label>当前配置</label><select id="cfg-select"><option>加载中...</option></select></div>
          <div style="flex:0"><label>&nbsp;</label><button class="btn" onclick="switchConfig()">切换</button></div>
        </div>
      </div>
      <div class="card">
        <h2>显示设置</h2>
        <div id="display-form"></div>
        <div class="btns"><button class="btn" onclick="saveDisplay()">保存显示设置</button></div>
      </div>
      <div class="card">
        <h2>配置编辑器</h2>
        <textarea id="cfg-editor" placeholder="加载中..."></textarea>
        <div class="btns">
          <button class="btn-s" onclick="loadConfig()">重新加载</button>
          <button class="btn-s" onclick="formatConfig()">格式化</button>
          <button class="btn" onclick="saveConfig()">保存配置</button>
        </div>
      </div>
    </div>
  </section>
</div>

<script>
var cachedConfig=null,ws=null,wsReady=false;
var fmCurrentDir='videos',fmCurrentPath='';
function $(id){return document.getElementById(id)}
function toast(msg,err){var el=$('toast');el.textContent=msg;el.className='toast '+(err?'err':'ok');el.style.display='block';clearTimeout(el._t);el._t=setTimeout(function(){el.style.display='none'},3000)}
function formatBytes(size){if(size===undefined||size===null||isNaN(size))return '--';if(size<1024)return size+' B';if(size<1048576)return(size/1024).toFixed(1)+' KB';if(size<1073741824)return(size/1048576).toFixed(1)+' MB';return(size/1073741824).toFixed(1)+' GB'}
function updateDownloadModal(info){
  var meta=$('download-meta'),link=$('download-apk-link'),qr=$('download-qr-image'),desc=$('download-modal-desc');
  if(!info||!info.apk_available){
    meta.textContent='APK 尚未发布，请稍后再试';
    meta.className='download-meta warn';
    link.style.display='none';
    qr.style.display='none';
    qr.removeAttribute('src');
    desc.textContent='当前暂未提供可下载的 APK 安装包';
    return;
  }
  var url=location.origin+(info.download_url||'/download/showen-app.apk');
  meta.textContent='版本 '+(info.version||'0.1.0')+' · '+formatBytes(info.apk_size);
  meta.className='download-meta';
  link.style.display='inline-flex';
  link.href=info.download_url||'/download/showen-app.apk';
  qr.style.display='block';
  qr.src='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(url);
  desc.textContent='扫描二维码或点击下载，支持蓝牙配网和远程控制';
}
function openDownloadModal(){
  var modal=$('download-modal');
  $('download-meta').textContent='正在获取 App 信息...';
  $('download-meta').className='download-meta';
  $('download-apk-link').style.display='none';
  $('download-qr-image').style.display='none';
  $('download-qr-image').removeAttribute('src');
  $('download-modal-desc').textContent='扫描二维码或点击下载，支持蓝牙配网和远程控制';
  modal.classList.add('open');
  fetch('/api/app/info').then(function(r){if(!r.ok)throw new Error('加载 App 信息失败');return r.json()}).then(updateDownloadModal).catch(function(){
    $('download-meta').textContent='APK 信息加载失败，请稍后再试';
    $('download-meta').className='download-meta warn';
    $('download-apk-link').style.display='none';
    $('download-qr-image').style.display='none';
  })
}
function closeDownloadModal(ev){if(ev&&ev.target!==$('download-modal'))return;$('download-modal').classList.remove('open')}
document.addEventListener('keydown',function(ev){if(ev.key==='Escape')closeDownloadModal()})

// Tabs
document.querySelectorAll('.tab').forEach(function(t){t.onclick=function(){document.querySelectorAll('.tab').forEach(function(e){e.classList.remove('active')});document.querySelectorAll('.panel').forEach(function(e){e.classList.remove('active')});t.classList.add('active');$('panel-'+t.dataset.tab).classList.add('active');if(t.dataset.tab==='videos')loadVideoList();if(t.dataset.tab==='files')fmRefresh();if(t.dataset.tab==='wifi'){loadWifiStatus();loadBleStatus()}if(t.dataset.tab==='settings'&&!cachedConfig){loadConfig();loadAvailableConfigs()}}});

// WebSocket
function connectWS(){var proto=location.protocol==='https:'?'wss:':'ws:';ws=new WebSocket(proto+'//'+location.host+'/ws');ws.onopen=function(){wsReady=true;$('ws-badge').textContent='WS 已连接';$('ws-badge').style.borderColor='rgba(34,197,94,.4)';$('ws-badge').style.color='#22c55e';$('ws-badge').style.background='rgba(34,197,94,.1)'};ws.onclose=function(){wsReady=false;$('ws-badge').textContent='WS 断开';$('ws-badge').style.borderColor='rgba(239,68,68,.3)';$('ws-badge').style.color='#ef4444';$('ws-badge').style.background='rgba(239,68,68,.1)';setTimeout(connectWS,2000)};ws.onerror=function(){wsReady=false};ws.onmessage=function(ev){try{var msg=JSON.parse(ev.data);if(msg.type==='status_update')applyStatus(msg.data);else if(msg.type==='state_update')toast('场景: '+msg.data.old_state+' -> '+msg.data.new_state);else if(msg.type==='wifi_update')applyWifi(msg.data);else if(msg.type==='ble_update')applyBle(msg.data);else if(msg.type==='config_update')cachedConfig=msg.data}catch(e){}}}
function wsCmd(obj){if(wsReady)ws.send(JSON.stringify(obj));else toast('WebSocket 未连接',true)}

// API
function api(method,path,body){var opts={method:method,headers:{}};if(body!==undefined){if(typeof body==='string'){opts.headers['Content-Type']='application/json';opts.body=body}else if(body instanceof FormData){opts.body=body}else{opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}}return fetch(path,opts).then(function(r){return r.json().then(function(d){if(!r.ok)throw d;return d})}).then(function(d){if(d.message)toast(d.message,d.status==='error');return d}).catch(function(e){toast((e&&e.message)||'请求失败',true);throw e})}

// Status
function applyStatus(d){var el=$('st-state');if(!d.running){el.textContent='已停止';el.className='val warn'}else if(d.paused){el.textContent='已暂停';el.className='val warn'}else{el.textContent='播放中';el.className='val ok'}$('st-video').textContent=d.current_video||'无';$('st-index').textContent=d.current_index;$('st-len').textContent=d.playlist_length}
function refreshStatus(){fetch('/api/status').then(function(r){return r.json()}).then(applyStatus).catch(function(){})}
function applyWifi(d){if(d.connected!==undefined){$('wifi-connected').textContent=d.connected?'已连接':'未连接';$('wifi-connected').className=d.connected?'val ok':'val err';$('wifi-ssid').textContent=d.ssid||'--';$('wifi-ip').textContent=d.ip||'--'}}
function applyBle(d){if(d.ready!==undefined){var el=$('ble-status');el.textContent=d.ready?'运行中':'未就绪';el.className=d.ready?'val ok':'val warn'}}

// Controls
function gotoVideo(){var idx=$('goto-idx').value;if(idx===''){toast('请输入索引',true);return}if(wsReady)wsCmd({cmd:'goto',index:parseInt(idx,10)});else api('POST','/api/goto/'+idx)}
function triggerPreset(n,v){if(wsReady)wsCmd({cmd:'trigger',name:n,value:v||''});else api('POST','/api/trigger/'+encodeURIComponent(n)+'/'+encodeURIComponent(v||''))}
function triggerCustom(){var n=$('tr-name').value,v=$('tr-value').value;if(!n){toast('请输入触发器名',true);return}triggerPreset(n,v)}

// Videos
function loadVideoList(){fetch('/api/videos').then(function(r){return r.json()}).then(function(files){var el=$('video-list');if(!files.length){el.innerHTML='<div class="item"><span class="name">目录中没有视频文件</span></div>';return}el.innerHTML=files.map(function(f){var sz=f.size<1048576?(f.size/1024).toFixed(1)+' KB':(f.size/1048576).toFixed(1)+' MB';return '<div class="item"><span class="name">'+esc(f.name)+'</span><span class="meta">'+sz+'</span><button class="btn-d" onclick="deleteVideo(\''+js(f.name)+'\')">删除</button></div>'}).join('')}).catch(function(){toast('加载视频列表失败',true)})}
function uploadVideos(){var input=$('upload-file');if(!input.files.length){toast('请先选择文件',true);return}var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);fetch('/api/videos/upload',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';loadVideoList()}).catch(function(){toast('上传失败',true)})}
function deleteVideo(name){if(!confirm('确定删除 '+name+' ?'))return;fetch('/api/videos/'+encodeURIComponent(name),{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');loadVideoList()}).catch(function(){toast('删除失败',true)})}

// File Manager
function fmRefresh(){fmNavigate(fmCurrentPath)}
function fmNavigate(subpath){
  fmCurrentDir=$('fm-dir').value;fmCurrentPath=subpath||'';
  var url='/api/files/'+fmCurrentDir;if(fmCurrentPath)url+='?path='+encodeURIComponent(fmCurrentPath);
  // breadcrumb
  var bc=$('fm-breadcrumb');var parts=fmCurrentPath?fmCurrentPath.split('/'):[];
  var html='<span onclick="fmNavigate(\'\')">'+({'videos':'视频目录','configs':'配置目录','plugins':'插件目录'}[fmCurrentDir]||fmCurrentDir)+'</span>';
  var acc='';
  for(var i=0;i<parts.length;i++){if(parts[i]){acc+=(acc?'/':'')+parts[i];html+='<span class="sep">/</span><span onclick="fmNavigate(\''+js(acc)+'\')">'+esc(parts[i])+'</span>'}}
  bc.innerHTML=html;
  fetch(url).then(function(r){return r.json()}).then(function(entries){
    var el=$('fm-list');
    if(entries.status==='error'){el.innerHTML='<div class="item"><span class="name">'+esc(entries.message)+'</span></div>';return}
    if(!entries.length){el.innerHTML='<div class="item"><span class="name">空目录</span></div>';$('fm-del-btn').style.display='none';return}
    var html='';
    for(var i=0;i<entries.length;i++){
      var e=entries[i];var sz=e.is_dir?'':e.size<1048576?(e.size/1024).toFixed(1)+' KB':(e.size/1048576).toFixed(1)+' MB';
      var fp=fmCurrentPath?(fmCurrentPath+'/'+e.name):e.name;
      html+='<div class="item">';
      html+='<label class="checkbox-label"><input type="checkbox" class="fm-check" data-path="'+esc(fp)+'" data-isdir="'+(e.is_dir?'1':'0')+'"></label>';
      if(e.is_dir){html+='<span class="dir-icon">&#128193;</span><span class="name" style="cursor:pointer;color:var(--amber)" onclick="fmNavigate(\''+js(fp)+'\')">'+esc(e.name)+'</span>'}
      else{html+='<span class="name">'+esc(e.name)+'</span>'}
      html+='<span class="meta">'+sz+'</span>';
      if(!e.is_dir){html+='<button class="btn-s" onclick="fmDownload(\''+js(fp)+'\')">下载</button>'}
      html+='</div>'
    }
    el.innerHTML=html;
    // 监听 checkbox 变化
    el.querySelectorAll('.fm-check').forEach(function(cb){cb.onchange=fmUpdateDelBtn});
    fmUpdateDelBtn()
  }).catch(function(){toast('加载文件列表失败',true)})
}
function fmUpdateDelBtn(){var cbs=document.querySelectorAll('.fm-check:checked');$('fm-del-btn').style.display=cbs.length?'':'none';$('fm-del-btn').textContent='删除选中 ('+cbs.length+')'}
function fmDeleteSelected(){
  var cbs=document.querySelectorAll('.fm-check:checked');if(!cbs.length)return;
  if(!confirm('确定删除 '+cbs.length+' 个项目?'))return;
  var promises=[];
  cbs.forEach(function(cb){
    var p=cb.dataset.path;
    promises.push(fetch('/api/files/'+fmCurrentDir+'/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p})}).then(function(r){return r.json()}))
  });
  Promise.all(promises).then(function(results){
    var ok=0,fail=0;results.forEach(function(r){if(r.status==='ok')ok++;else fail++});
    toast('删除完成: '+ok+' 成功'+(fail?' / '+fail+' 失败':''),fail>0);fmRefresh()
  })
}
function fmDownload(path){window.open('/api/files/'+fmCurrentDir+'/download?path='+encodeURIComponent(path),'_blank')}
function fmUpload(input){
  if(!input.files.length)return;
  var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);
  var url='/api/files/'+fmCurrentDir+'/upload';if(fmCurrentPath)url+='?path='+encodeURIComponent(fmCurrentPath);
  fetch(url,{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';fmRefresh()}).catch(function(){toast('上传失败',true)})
}
function fmMkdir(){
  var name=prompt('输入新文件夹名称:');if(!name)return;
  var path=fmCurrentPath?(fmCurrentPath+'/'+name):name;
  fetch('/api/files/'+fmCurrentDir+'/mkdir',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:path})}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');fmRefresh()}).catch(function(){toast('创建失败',true)})
}

// WiFi
function loadWifiStatus(){fetch('/api/wifi/status').then(function(r){return r.json()}).then(applyWifi).catch(function(){})}
function scanWifi(){$('wifi-list').innerHTML='<div class="item"><span class="name">扫描中...</span></div>';fetch('/api/wifi/scan').then(function(r){return r.json()}).then(function(list){if(!list.length){$('wifi-list').innerHTML='<div class="item"><span class="name">未发现 WiFi</span></div>';return}$('wifi-list').innerHTML=list.map(function(n){return '<div class="item"><span class="name">'+esc(n.ssid||'隐藏网络')+'</span><span class="meta">'+esc(String(n.signal||0))+' / '+esc(n.security||'OPEN')+'</span><button class="btn-s" onclick="selectWifi(\''+js(n.ssid||'')+'\')">选择</button></div>'}).join('')}).catch(function(){toast('扫描失败',true)})}
function selectWifi(ssid){$('wifi-ssid-input').value=ssid}
function connectWifi(){var s=$('wifi-ssid-input').value,p=$('wifi-pass-input').value;if(!s){toast('请输入 WiFi 名称',true);return}if(wsReady)wsCmd({cmd:'connect',ssid:s,password:p});else api('POST','/api/wifi/connect',{ssid:s,password:p}).then(function(){setTimeout(loadWifiStatus,1500)})}
function startAP(){var s=$('ap-ssid').value||'showen',p=$('ap-pass').value||'12345678';if(p.length<8){toast('热点密码至少 8 位',true);return}if(wsReady)wsCmd({cmd:'ap_start',ssid:s,password:p});else api('POST','/api/wifi/ap/start',{ssid:s,password:p})}
function stopAP(){if(wsReady)wsCmd({cmd:'ap_stop'});else api('POST','/api/wifi/ap/stop')}
function loadBleStatus(){fetch('/api/ble/status').then(function(r){return r.json()}).then(function(d){var el=$('ble-status');if(d.running){el.textContent='运行中 / '+(d.device_name||'showen');el.className='val ok'}else{el.textContent='未就绪';el.className='val warn'}}).catch(function(){})}
function startBLE(){api('POST','/api/ble/start',{device_name:$('ble-name').value||'showen'}).then(loadBleStatus)}
function stopBLE(){api('POST','/api/ble/stop').then(loadBleStatus)}

// Config switch
function loadAvailableConfigs(){fetch('/api/config/available').then(function(r){return r.json()}).then(function(d){var sel=$('cfg-select');sel.innerHTML='';d.configs.forEach(function(f){var opt=document.createElement('option');opt.value=f;opt.textContent=f;if(f===d.active)opt.selected=true;sel.appendChild(opt)})}).catch(function(){})}
function switchConfig(){var f=$('cfg-select').value;if(!f){toast('请选择配置',true);return}if(!confirm('确定切换到 '+f+' ?'))return;api('POST','/api/config/switch',{filename:f}).then(function(){loadConfig();loadAvailableConfigs()})}

// Config
function loadConfig(){fetch('/api/config').then(function(r){return r.json()}).then(function(cfg){cachedConfig=cfg;$('cfg-editor').value=JSON.stringify(cfg,null,2);renderDisplay(cfg.display)}).catch(function(){toast('加载配置失败',true)})}
function formatConfig(){try{$('cfg-editor').value=JSON.stringify(JSON.parse($('cfg-editor').value),null,2)}catch(_){toast('JSON 格式错误',true)}}
function saveConfig(){var raw=$('cfg-editor').value;try{JSON.parse(raw)}catch(_){toast('JSON 格式错误',true);return}api('POST','/api/config',raw).then(loadConfig)}
function renderDisplay(d){if(!d)return;var h='';h+='<label class="checkbox-label"><input id="d-fullscreen" type="checkbox" '+(d.fullscreen?'checked':'')+'> 全屏模式</label>';h+='<label>窗口标题</label><input id="d-title" type="text" value="'+ea(d.window_title||'')+'">';h+='<label>旋转角度</label><input id="d-rotation" type="number" value="'+ea(String(d.rotation||0))+'">';h+='<label>渲染宽度</label><input id="d-render-width" type="number" value="'+ea(String(d.render_width||1024))+'">';h+='<label>渲染高度</label><input id="d-render-height" type="number" value="'+ea(String(d.render_height||1024))+'">';h+='<label>色键下限 (H,S,V)</label><input id="d-ck-min" type="text" value="'+ea((d.chroma_key&&d.chroma_key.hsv_min?d.chroma_key.hsv_min.join(','):'0,0,200'))+'">';h+='<label>色键上限 (H,S,V)</label><input id="d-ck-max" type="text" value="'+ea((d.chroma_key&&d.chroma_key.hsv_max?d.chroma_key.hsv_max.join(','):'180,30,255'))+'">';h+='<label>透视校正点 (JSON)</label><input id="d-points" type="text" value="'+ea(JSON.stringify((d.perspective_correction&&d.perspective_correction.points)||[]))+'">';$('display-form').innerHTML=h}
function saveDisplay(){if(!cachedConfig){loadConfig();return}var n=JSON.parse(JSON.stringify(cachedConfig));n.display.fullscreen=$('d-fullscreen').checked;n.display.window_title=$('d-title').value;n.display.rotation=parseInt($('d-rotation').value||'0',10);n.display.render_width=parseInt($('d-render-width').value||'1024',10);n.display.render_height=parseInt($('d-render-height').value||'1024',10);n.display.chroma_key=n.display.chroma_key||{};n.display.chroma_key.hsv_min=$('d-ck-min').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});n.display.chroma_key.hsv_max=$('d-ck-max').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});n.display.perspective_correction=n.display.perspective_correction||{};try{n.display.perspective_correction.points=JSON.parse($('d-points').value)}catch(_){toast('透视点 JSON 无效',true);return}cachedConfig=n;$('cfg-editor').value=JSON.stringify(n,null,2);saveConfig()}

// Utils
function esc(v){return String(v).replace(/[&<>"]/g,function(c){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]})}
function ea(v){return esc(v).replace(/'/g,'&#39;')}
function js(v){return String(v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")}
connectWS();refreshStatus();
</script>
</body>
</html>"##;
