from __future__ import annotations

import asyncio
import copy
import json
import os
import re
import shutil
import subprocess
import tempfile
from typing import Any, Dict, List, Optional

from datetime import datetime
from pathlib import Path

from fastapi import APIRouter, Depends, FastAPI, File, HTTPException, Query, Request, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse as FastAPIFileResponse, StreamingResponse
from starlette.background import BackgroundTask

# 文件管理根目录（文件查看 tab 可操作的范围）
FILE_ROOT_DIRS: Dict[str, Dict[str, str]] = {
    "savemap":    {"path": "~/savemap",    "label": "地图"},
    "saveimg":    {"path": "~/saveimg",    "label": "图片"},
    "savemission":{"path": "~/savemission","label": "任务"},
    "savebag":    {"path": "~/savebag",    "label": "数据"},
    "savelog":    {"path": "~/savelog",    "label": "日志"},
    "savepcd":    {"path": "~/savepcd",    "label": "PCD"},
    "savedetect": {"path": "~/savedetect", "label": "检测"},
}


def _resolve_root_dirs() -> List[Path]:
    """解析所有文件根目录的绝对路径。"""
    return [Path(d["path"]).expanduser().resolve() for d in FILE_ROOT_DIRS.values()]


def _validate_file_path(path_str: str) -> Path:
    """校验路径是否在允许的文件根目录范围内，返回解析后的 Path。"""
    if ".." in path_str:
        raise HTTPException(status_code=400, detail="Invalid path")
    file_path = Path(path_str).expanduser().resolve()
    roots = _resolve_root_dirs()
    if not any(str(file_path).startswith(str(r)) for r in roots):
        raise HTTPException(status_code=403, detail="Access denied: path outside allowed directories")
    return file_path

from models import (
    ConflictPolicy,
    ProgressInfo,
    StartTaskRequest,
    TaskDefinition,
    TaskInstance,
    TaskInstanceOut,
)
from task_manager import TaskManager
from self_updater import SelfUpdater
from system_diagnostics import LocalDiagnosticTests, TEST_ITEMS

router = APIRouter(prefix="/api")


def get_task_manager(request: Request) -> TaskManager:
    return request.app.state.task_manager


@router.get("/test")
async def api_test():
    """轻量探测端点，供局域网内设备扫描发现无人机服务。"""
    return {
        "service": "drone-task-manager",
        "version": "1.0.0",
        "timestamp": datetime.utcnow().isoformat(),
    }


def _localize_definition(definition: TaskDefinition, lang: Optional[str]) -> TaskDefinition:
    """根据语言参数切换 name 和 description。

    回退策略：
    1. 未指定语言 或 中文系 → 返回原始对象（基础语言）
    2. 目标语言翻译存在且非空 → 使用翻译
    3. 目标语言翻译不存在或为空 → 回退到基础 name/description
    """
    if not lang or lang.startswith("zh") or lang == "cn":
        return definition
    try:
        d = definition.model_copy()
    except Exception:
        d = copy.copy(definition)

    # name 回退：存在且非空才使用翻译
    translated_name = definition.name_i18n.get(lang)
    if translated_name:
        d.name = translated_name

    # description 回退：存在且非空才使用翻译
    translated_desc = definition.description_i18n.get(lang)
    if translated_desc:
        d.description = translated_desc

    return d


@router.get("/tasks/definitions", response_model=Dict[str, TaskDefinition])
async def list_definitions(
    lang: Optional[str] = Query(default=None, description="语言: zh/en/ru。若 App 未提供该语言翻译，自动回退到基础 name/description"),
    tm: TaskManager = Depends(get_task_manager),
):
    """列出所有任务定义（内置 YAML + 已安装 App），支持多语言切换。"""
    defs = tm.list_definitions()
    return {k: _localize_definition(v, lang) for k, v in defs.items()}


@router.get("/tasks/definitions/{definition_id}", response_model=TaskDefinition)
async def get_definition(
    definition_id: str,
    lang: Optional[str] = Query(default=None, description="语言: zh/en/ru。若 App 未提供该语言翻译，自动回退到基础 name/description"),
    tm: TaskManager = Depends(get_task_manager),
):
    """获取单个任务定义详情，支持多语言切换。"""
    definition = tm.get_definition(definition_id)
    if not definition:
        raise HTTPException(status_code=404, detail="Definition not found")
    return _localize_definition(definition, lang)


@router.post("/tasks/preview")
async def preview_command(req: StartTaskRequest, tm: TaskManager = Depends(get_task_manager)):
    """预览任务启动后的实际 shell 命令（含参数填充、setup_script 注入）。"""
    from task_manager import _render_command
    definition = tm.get_definition(req.definition_id)
    if not definition:
        raise HTTPException(status_code=404, detail="Definition not found")
    try:
        params = tm.fill_params(definition, req.params)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    # App 包预览需要注入 app_dir 占位符
    if "app_dir" not in params:
        params["app_dir"] = f"apps/{req.definition_id}"
    command = _render_command(definition.command_template, params)
    if definition.setup_script:
        command = f"source {definition.setup_script} && {command}"
    return {"command": command, "params": params}


@router.post("/tasks/instances", response_model=TaskInstanceOut)
async def start_task(req: StartTaskRequest, tm: TaskManager = Depends(get_task_manager)):
    """启动任务实例。若存在 /tmp/arm.lock 锁文件，返回 423 需前端二次确认。"""
    try:
        instance = await tm.start_task(req)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except RuntimeError as e:
        detail = str(e)
        if "Lock file exists" in detail:
            raise HTTPException(status_code=423, detail=detail)
        raise HTTPException(status_code=409, detail=detail)
    return TaskInstanceOut.from_instance(instance)


@router.get("/tasks/instances")
async def list_instances(
    page: int = Query(default=1, ge=1, description="页码，从1开始"),
    page_size: int = Query(default=10, ge=1, le=100, description="每页条数"),
    tm: TaskManager = Depends(get_task_manager),
):
    """分页查询任务实例（含运行中和已停止）。"""
    offset = (page - 1) * page_size
    result = tm.list_instances(limit=page_size, offset=offset)
    return {
        "entries": [TaskInstanceOut.from_instance(i) for i in result["entries"]],
        "total": result["total"],
        "page": page,
        "page_size": page_size,
    }


@router.get("/tasks/instances/{instance_id}", response_model=TaskInstanceOut)
async def get_instance(instance_id: str, tm: TaskManager = Depends(get_task_manager)):
    """获取单个任务实例详情。"""
    instance = tm.get_instance(instance_id)
    if not instance:
        raise HTTPException(status_code=404, detail="Instance not found")
    return TaskInstanceOut.from_instance(instance)


@router.post("/tasks/instances/{instance_id}/stop", response_model=TaskInstanceOut)
async def stop_instance(
    instance_id: str,
    force: bool = False,
    tm: TaskManager = Depends(get_task_manager),
):
    """停止任务实例。force=true 时发送 SIGKILL。"""
    try:
        instance = await tm.stop_task(instance_id, force=force)
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))
    return TaskInstanceOut.from_instance(instance)


@router.get("/tasks/instances/{instance_id}/logs")
async def get_logs(
    instance_id: str,
    tail: int = Query(default=100, ge=1, le=5000, description="返回最近 N 行日志"),
    tm: TaskManager = Depends(get_task_manager),
):
    """获取任务实例的 stdout/stderr 日志。"""
    logs = tm.get_logs(instance_id, tail=tail)
    return {"instance_id": instance_id, "logs": logs}


@router.delete("/tasks/instances/{instance_id}")
async def delete_instance(instance_id: str, tm: TaskManager = Depends(get_task_manager)):
    """删除已停止的任务实例记录。"""
    try:
        tm.delete_instance(instance_id)
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except RuntimeError as e:
        raise HTTPException(status_code=400, detail=str(e))
    return {"ok": True}


# ---------- Updater（固件/压缩包更新） ----------

UPDATER_DIR = Path("~/updater").expanduser().resolve()
UPDATER_DIR.mkdir(parents=True, exist_ok=True)


@router.post("/updater/upload")
async def upload_update_package(
    request: Request,
    file: UploadFile = File(..., description="压缩包文件 (.zip/.tar.gz/.tar 等)"),
):
    """上传更新压缩包到 ~/updater。"""
    filename = file.filename or "unnamed"
    if ".." in filename or "/" in filename or "\\" in filename:
        raise HTTPException(status_code=400, detail="Invalid filename")

    # 校验扩展名
    ext = Path(filename).suffix.lower()
    allowed_exts = {".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar"}
    if ext not in allowed_exts and not any(filename.lower().endswith(e) for e in [".tar.gz", ".tar.bz2", ".tar.xz"]):
        raise HTTPException(status_code=400, detail="Only compressed archives are allowed")

    dest = UPDATER_DIR / filename
    try:
        content = await file.read()
        # 限制 500MB
        if len(content) > 500 * 1024 * 1024:
            raise HTTPException(status_code=413, detail="File too large (max 500MB)")
        dest.write_bytes(content)
        os.sync()
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Upload failed: {e}")

    return {"ok": True, "filename": filename, "size": len(content), "path": str(dest)}


@router.get("/updater")
async def list_update_packages():
    """列出 ~/updater 下所有压缩包。"""
    if not UPDATER_DIR.exists():
        return {"packages": []}
    packages = []
    for f in sorted(UPDATER_DIR.iterdir()):
        if f.is_file():
            stat = f.stat()
            packages.append({
                "filename": f.name,
                "size": stat.st_size,
                "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(),
                "path": str(f),
            })
    return {"packages": packages}


@router.delete("/updater/{filename}")
async def delete_update_package(filename: str):
    """删除 ~/updater 下的指定压缩包。"""
    if ".." in filename or "/" in filename or "\\" in filename:
        raise HTTPException(status_code=400, detail="Invalid filename")
    target = UPDATER_DIR / filename
    if not target.exists() or not target.is_file():
        raise HTTPException(status_code=404, detail="File not found")
    try:
        target.unlink()
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Delete failed: {e}")
    return {"ok": True}


# 允许下载的文件路径白名单（resolve 后前缀匹配）
ALLOWED_DOWNLOAD_PATHS = _resolve_root_dirs() + [
    Path("/data").resolve(),
    Path("/tmp").resolve(),
]


@router.get("/files/download")
async def download_file(path: str = Query(..., description="本地文件绝对路径")):
    if ".." in path:
        raise HTTPException(status_code=400, detail="Invalid path")
    file_path = Path(path).expanduser().resolve()
    if not any(str(file_path).startswith(str(p)) for p in ALLOWED_DOWNLOAD_PATHS):
        raise HTTPException(status_code=403, detail="Access denied")
    if not file_path.exists():
        raise HTTPException(status_code=404, detail="File not found")
    if not file_path.is_file():
        raise HTTPException(status_code=400, detail="Not a file")
    return FastAPIFileResponse(file_path, filename=file_path.name)


def _cleanup_tmp_dir(tmp_dir: str) -> None:
    """删除临时目录（供 BackgroundTask 使用）。"""
    try:
        shutil.rmtree(tmp_dir, ignore_errors=True)
    except Exception:
        pass


@router.get("/files/download-zip")
async def download_zip(dir: str = Query(..., description="要打包的目录路径")):
    target = _validate_file_path(dir)
    if not target.exists():
        raise HTTPException(status_code=404, detail="Directory not found")
    if not target.is_dir():
        raise HTTPException(status_code=400, detail="Not a directory")

    tmp_dir = tempfile.mkdtemp(prefix="dtm_zip_")
    base_name = os.path.join(tmp_dir, target.name)

    def _make_zip():
        zip_path = shutil.make_archive(base_name, "zip", root_dir=target)
        return zip_path

    zip_path = await asyncio.get_event_loop().run_in_executor(None, _make_zip)
    return FastAPIFileResponse(
        zip_path,
        filename=f"{target.name}.zip",
        background=BackgroundTask(_cleanup_tmp_dir, tmp_dir),
    )


@router.get("/files/download-tar")
async def download_tar(dir: str = Query(..., description="要打包的目录路径")):
    target = _validate_file_path(dir)
    if not target.exists():
        raise HTTPException(status_code=404, detail="Directory not found")
    if not target.is_dir():
        raise HTTPException(status_code=400, detail="Not a directory")

    proc = await asyncio.create_subprocess_exec(
        "tar", "cf", "-", "-C", str(target.parent), target.name,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )

    async def _stream():
        try:
            while True:
                chunk = await proc.stdout.read(65536)
                if not chunk:
                    break
                yield chunk
        finally:
            if proc.returncode is None:
                proc.kill()
            await proc.wait()
            if proc.returncode != 0:
                stderr = await proc.stderr.read()
                raise RuntimeError(f"tar failed: {stderr.decode()[:200]}")

    return StreamingResponse(
        _stream(),
        media_type="application/x-tar",
        headers={"Content-Disposition": f'attachment; filename="{target.name}.tar"'},
    )


def _calc_dir_size(path: Path) -> int:
    """递归计算目录下所有文件的总大小（字节）。"""
    total = 0
    try:
        for f in path.rglob("*"):
            if f.is_file():
                total += f.stat().st_size
    except (PermissionError, OSError):
        pass
    return total


@router.get("/files")
async def list_files(
    dir: str = Query(..., description="目录路径"),
    pattern: str = Query(default="*", description="glob匹配模式"),
):
    if ".." in dir:
        raise HTTPException(status_code=400, detail="Invalid path")
    target = Path(dir).expanduser().resolve()
    if not target.exists():
        raise HTTPException(status_code=404, detail="Directory not found")
    if not target.is_dir():
        raise HTTPException(status_code=400, detail="Not a directory")

    files = []
    try:
        entries = []
        for f in target.glob(pattern):
            if f.is_file():
                stat = f.stat()
                entries.append((f, stat, True))
            elif f.is_dir() and pattern == "*":
                stat = f.stat()
                entries.append((f, stat, False))
        # 按修改时间从近到远排序
        entries.sort(key=lambda x: x[1].st_mtime, reverse=True)
        for f, stat, is_file in entries:
            if is_file:
                files.append({
                    "name": f.name,
                    "path": str(f),
                    "size": stat.st_size,
                    "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(),
                })
            else:
                files.append({
                    "name": f.name + "/",
                    "path": str(f),
                    "size": 0,
                    "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(),
                })
    except PermissionError:
        raise HTTPException(status_code=403, detail="Permission denied")

    return {"dir": str(target), "pattern": pattern, "files": files}


@router.get("/files/browse")
async def browse_files(
    dir: str = Query(..., description="目录路径"),
    page: int = Query(default=1, ge=1, description="页码，从1开始"),
    page_size: int = Query(default=20, ge=1, le=200, description="每页条数"),
    prefix: Optional[str] = Query(default=None, description="文件名前缀过滤：EXTRA_, SYNC_, normal(非EXTRA_/SYNC_)"),
    ext: Optional[str] = Query(default=None, description="文件扩展名过滤：bag, pcd"),
):
    """分页浏览目录内容（文件+子目录），返回友好格式。支持 prefix / ext 过滤。"""
    target = _validate_file_path(dir)
    if not target.exists():
        raise HTTPException(status_code=404, detail="Directory not found")
    if not target.is_dir():
        raise HTTPException(status_code=400, detail="Not a directory")

    items = []
    try:
        entries = []
        for f in target.iterdir():
            stat = f.stat()
            entries.append((f, stat))
        # 按修改时间从近到远排序
        entries.sort(key=lambda x: x[1].st_mtime, reverse=True)
        for f, stat in entries:
            items.append({
                "name": f.name,
                "path": str(f),
                "is_dir": f.is_dir(),
                "size": stat.st_size if f.is_file() else 0,
                "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(),
            })
    except PermissionError:
        raise HTTPException(status_code=403, detail="Permission denied")

    # prefix 过滤（对 saveimg 根目录下的一级子项生效，含文件和子文件夹）
    if prefix:
        filtered = []
        for it in items:
            if prefix == "normal":
                if not it["name"].startswith("EXTRA_") and not it["name"].startswith("SYNC_"):
                    filtered.append(it)
            else:
                if it["name"].startswith(prefix):
                    filtered.append(it)
        items = filtered

    # ext 扩展名过滤（仅对文件生效，子目录始终保留）
    if ext:
        filtered = []
        for it in items:
            if it["is_dir"]:
                filtered.append(it)  # 子目录始终保留
                continue
            if ext == "other_log":
                if not it["name"].lower().endswith(".snapshot") and not it["name"].lower().endswith(".traj"):
                    filtered.append(it)
            else:
                suffix = "." + ext.lower()
                if it["name"].lower().endswith(suffix):
                    filtered.append(it)
        items = filtered

    # 计算每个子目录的大小及当前目录总大小
    total_size = 0
    for it in items:
        if it["is_dir"]:
            it["size"] = await asyncio.get_event_loop().run_in_executor(
                None, _calc_dir_size, Path(it["path"])
            )
        total_size += it["size"]

    total = len(items)
    offset = (page - 1) * page_size
    return {
        "dir": str(target),
        "items": items[offset:offset + page_size],
        "total": total,
        "page": page,
        "page_size": page_size,
        "total_size": total_size,
    }


@router.get("/flight-missions")
async def list_flight_missions(
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=200),
):
    """列出 ~/saveimg 下纯时间格式命名的任务文件夹（不含任何 prefix）。

    排除名字中包含 '_' 的目录（如 EXTRA_xxx、SYNC_xxx 等）。
    按修改时间倒序排列。
    返回每个文件夹的 mp4/jpg 数量及 result_done.json / result.json 中的状态码。
    """
    saveimg = _validate_file_path("~/saveimg")
    if not saveimg.exists() or not saveimg.is_dir():
        return {"missions": [], "total": 0}

    missions: List[Dict[str, Any]] = []
    try:
        entries = []
        for f in saveimg.iterdir():
            if not f.is_dir():
                continue
            # 只保留严格时间戳格式 YYYY-MM-DD-HH-MM-SS
            if not re.match(r"^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$", f.name):
                continue
            stat = f.stat()
            entries.append((f, stat))

        # 按修改时间倒序排列
        entries.sort(key=lambda x: x[1].st_mtime, reverse=True)

        total = len(entries)
        start = (page - 1) * page_size
        end = start + page_size
        paged_entries = entries[start:end]

        for f, stat in paged_entries:
            # 递归统计 mp4 和 jpg/jpeg 数量
            img_count = 0
            video_count = 0
            try:
                for child in f.rglob("*"):
                    if not child.is_file():
                        continue
                    name_lower = child.name.lower()
                    if name_lower.endswith(".jpg") or name_lower.endswith(".jpeg"):
                        img_count += 1
                    elif name_lower.endswith(".mp4"):
                        video_count += 1
            except (PermissionError, OSError):
                pass

            # 解析状态码：优先 result_done.json，回退 result.json
            state_code: Optional[int] = None
            for result_file in [f / "result_done.json", f / "result.json"]:
                if result_file.exists():
                    try:
                        data = json.loads(result_file.read_text(encoding="utf-8"))
                        # 支持 req.state 或 state 或 result_state
                        if isinstance(data, dict):
                            req = data.get("req")
                            if isinstance(req, dict):
                                code = req.get("state")
                                if isinstance(code, int):
                                    state_code = code
                                    break
                            # 直接取 state
                            code = data.get("state")
                            if isinstance(code, int):
                                state_code = code
                                break
                    except (json.JSONDecodeError, OSError):
                        pass

            missions.append({
                "name": f.name,
                "path": str(f),
                "img_count": img_count,
                "video_count": video_count,
                "state": state_code,
                "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(),
            })
    except (PermissionError, OSError):
        pass

    return {"missions": missions, "total": total}


@router.post("/files/upload")
async def upload_file(
    request: Request,
    file: UploadFile = File(..., description="要上传的文件"),
    dir: str = Query(..., description="目标目录"),
):
    """上传文件到指定目录（必须在 FILE_ROOT_DIRS 范围内）。"""
    target_dir = _validate_file_path(dir)
    if not target_dir.exists():
        target_dir.mkdir(parents=True, exist_ok=True)
    if not target_dir.is_dir():
        raise HTTPException(status_code=400, detail="Target is not a directory")

    filename = file.filename or "unnamed"
    if ".." in filename or "/" in filename or "\\" in filename:
        raise HTTPException(status_code=400, detail="Invalid filename")

    dest = target_dir / filename
    try:
        content = await file.read()
        dest.write_bytes(content)
        os.sync()
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Upload failed: {e}")

    return {"ok": True, "path": str(dest), "size": len(content)}


@router.delete("/files/delete")
async def delete_file(
    path: str = Query(..., description="要删除的文件或目录路径"),
):
    """删除指定文件或目录（必须在 FILE_ROOT_DIRS 范围内），目录支持非空删除。"""
    target = _validate_file_path(path)
    if not target.exists():
        raise HTTPException(status_code=404, detail="File or directory not found")

    try:
        if target.is_file():
            target.unlink()
        elif target.is_dir():
            shutil.rmtree(str(target))
        else:
            raise HTTPException(status_code=400, detail="Not a file or directory")
    except OSError as e:
        raise HTTPException(status_code=400, detail=f"Delete failed: {e}")

    return {"ok": True}


@router.get("/files/choices")
async def list_file_choices(
    definition_id: str = Query(..., description="任务定义ID"),
    parameter_name: str = Query(..., description="参数名"),
    tm: TaskManager = Depends(get_task_manager),
):
    """根据任务定义中 path 参数的 source_dir 配置，列出候选文件供前端选择。"""
    from models import ParameterType

    definition = tm.get_definition(definition_id)
    if not definition:
        raise HTTPException(status_code=404, detail="Definition not found")

    param = next((p for p in definition.parameters if p.name == parameter_name), None)
    if not param:
        raise HTTPException(status_code=404, detail="Parameter not found")
    if param.type != ParameterType.PATH:
        raise HTTPException(status_code=400, detail="Parameter is not a path type")

    # 确定 source_dir
    source_dir_str = param.source_dir
    if not source_dir_str:
        # 回退到 default 值的目录部分
        default_val = param.default
        if default_val is not None:
            default_path = Path(str(default_val)).expanduser()
            if default_path.is_dir():
                source_dir_str = str(default_path)
            elif default_path.parent.exists():
                source_dir_str = str(default_path.parent)

    if not source_dir_str:
        raise HTTPException(
            status_code=400, detail="No source_dir configured for this path parameter"
        )

    if ".." in source_dir_str:
        raise HTTPException(status_code=400, detail="Invalid path")

    target = Path(source_dir_str).expanduser().resolve()
    if not target.exists() or not target.is_dir():
        raise HTTPException(status_code=404, detail="Source directory not found")

    # 根据 regex 约束推断 glob pattern
    pattern = "*"
    regex = param.constraints.get("regex") if param.constraints else None
    if isinstance(regex, str):
        if ".bag" in regex:
            pattern = "*.bag"
        elif ".json" in regex:
            pattern = "*.json"
        elif ".yaml" in regex or ".yml" in regex:
            pattern = "*.yaml"
        elif ".py" in regex:
            pattern = "*.py"
        elif ".txt" in regex:
            pattern = "*.txt"
        elif ".csv" in regex:
            pattern = "*.csv"
        elif ".pcd" in regex:
            pattern = "*.pcd"
        elif ".ply" in regex:
            pattern = "*.ply"
        elif ".las" in regex or ".laz" in regex:
            pattern = "*.las"

    files = []
    try:
        for f in sorted(target.glob(pattern)):
            if f.is_file():
                stat = f.stat()
                files.append({
                    "name": f.name,
                    "path": str(f),
                    "size": stat.st_size,
                    "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(),
                })
            elif f.is_dir() and not regex:
                # 无 regex 约束的 path 参数（如 input_dir）同时列出子目录
                files.append({
                    "name": f.name + "/",
                    "path": str(f),
                    "size": 0,
                    "mtime": "",
                })
    except PermissionError:
        raise HTTPException(status_code=403, detail="Permission denied")

    return {"dir": str(target), "pattern": pattern, "files": files}


@router.post("/internal/progress/{instance_id}")
async def internal_progress(
    instance_id: str,
    progress: ProgressInfo,
    tm: TaskManager = Depends(get_task_manager),
):
    await tm.update_progress(instance_id, progress)
    return {"ok": True}


# ---------- 历史记录 ----------

@router.get("/history")
async def list_history(
    page: int = Query(default=1, ge=1, description="页码，从1开始"),
    page_size: int = Query(default=5, ge=1, le=100, description="每页条数"),
    definition_id: Optional[str] = Query(default=None, description="按任务定义ID筛选，如 bag_analyzer@1.0.0"),
    tm: TaskManager = Depends(get_task_manager),
):
    """分页查询任务历史记录，支持按 definition_id 过滤。"""
    offset = (page - 1) * page_size
    result = tm.list_history(limit=page_size, offset=offset, definition_id=definition_id)
    return {
        "entries": result["entries"],
        "total": result["total"],
        "page": page,
        "page_size": page_size,
    }


@router.get("/history/{instance_id}")
async def get_history(instance_id: str, tm: TaskManager = Depends(get_task_manager)):
    """获取单条历史记录详情。"""
    entry = tm.get_history(instance_id)
    if not entry:
        raise HTTPException(status_code=404, detail="History entry not found")
    return entry


@router.post("/history/cleanup")
async def cleanup_history(
    days: int = Query(default=30, ge=1, description="保留最近 N 天的历史，删除更早的记录"),
    tm: TaskManager = Depends(get_task_manager),
):
    """清理过期历史记录。"""
    removed = tm.cleanup_history(days)
    return {"removed": removed}


# ---------- 系统监控 ----------

@router.get("/system/info")
async def system_info(tm: TaskManager = Depends(get_task_manager)):
    """获取系统实时信息：CPU、内存、磁盘、网卡、版本号。"""
    return tm.get_system_info()


@router.post("/system/power")
async def system_power(body: Dict[str, Any] = None):
    """通过 sysd-ctrl 发送重启/关机命令。

    body: {"action": "reboot" | "poweroff", "confirm": true}
    """
    if _check_flight_lock():
        raise HTTPException(
            status_code=423,
            detail="Drone is armed or in flight. Power control is prohibited.",
        )

    action = (body or {}).get("action", "")
    confirm = (body or {}).get("confirm", False)
    if action not in ("reboot", "poweroff"):
        raise HTTPException(status_code=400, detail="Invalid action. Use 'reboot' or 'poweroff'.")
    if not confirm:
        raise HTTPException(status_code=400, detail="Confirmation required. Set confirm=true.")

    # 读取 sysd-ctrl 配置
    config_path = "/etc/sysd-ctrl/sysd-ctrl.conf"
    token = ""
    port = 9527
    try:
        if os.path.exists(config_path):
            with open(config_path, "r") as f:
                for line in f:
                    line = line.strip()
                    if line.startswith("TOKEN="):
                        token = line.split("=", 1)[1].strip()
                    elif line.startswith("PORT="):
                        try:
                            port = int(line.split("=", 1)[1].strip())
                        except ValueError:
                            pass
    except Exception:
        pass

    if not token:
        token = "ramy"

    # 发送命令
    try:
        import socket
        message = "{}:{}\n".format(token, action)
        with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
            sock.sendall(message.encode())
            data = b""
            try:
                while True:
                    chunk = sock.recv(256)
                    if not chunk:
                        break
                    data += chunk
            except socket.timeout:
                pass
        response = data.decode(errors="replace").strip()
        if response.startswith("OK"):
            return {"success": True, "action": action, "response": response}
        else:
            raise HTTPException(status_code=500, detail="sysd-ctrl rejected: {}".format(response))
    except (OSError, ConnectionRefusedError) as exc:
        raise HTTPException(status_code=503, detail="sysd-ctrl unreachable: {}".format(str(exc)))


# WebSocket
async def websocket_endpoint(websocket: WebSocket):
    tm: TaskManager = websocket.app.state.task_manager
    await websocket.accept()

    queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue()

    def on_event(event_type: str, payload: Dict[str, Any]):
        try:
            queue.put_nowait({"type": event_type, "payload": payload})
        except asyncio.QueueFull:
            pass

    tm.subscribe(on_event)

    # 发送全量快照
    all_instances = tm.list_instances(limit=None, offset=0)
    instances = [TaskInstanceOut.from_instance(i).model_dump(mode="json") for i in all_instances["entries"]]
    await websocket.send_json({"type": "snapshot", "payload": {"instances": instances}})

    try:
        while True:
            msg = await queue.get()
            await websocket.send_json(msg)
    except WebSocketDisconnect:
        pass
    except Exception:
        pass
    finally:
        tm.unsubscribe(on_event)


# ---------- App 包管理 ----------

@router.post("/apps/upload")
async def upload_app(
    request: Request,
    file: UploadFile = File(..., description=".lldtp app 包文件"),
):
    """上传并安装 .lldtp app 包。"""
    tm: TaskManager = request.app.state.task_manager

    # 校验扩展名
    filename = file.filename or ""
    if not filename.lower().endswith(".lldtp"):
        raise HTTPException(status_code=400, detail="Only .lldtp files are allowed")

    # 保存到临时文件
    tmp_path = Path(f"/tmp/app_upload_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}_{id(file)}.lldtp")
    try:
        content = await file.read()
        # 限制 100MB
        if len(content) > 100 * 1024 * 1024:
            raise HTTPException(status_code=413, detail="File too large (max 100MB)")
        tmp_path.write_bytes(content)
        os.sync()

        app_package = await tm.install_app(tmp_path)
        return {
            "ok": True,
            "app": {
                "app_id": app_package.app_id,
                "app_version": app_package.app_version,
                "name": app_package.name,
                "full_id": app_package.full_id,
                "installed_at": app_package.installed_at.isoformat(),
            },
        }
    except HTTPException:
        raise
    except Exception as e:
        # 区分已知错误码
        from app_manager import AppManagerError
        if isinstance(e, AppManagerError):
            raise HTTPException(status_code=e.status_code, detail=str(e))
        raise HTTPException(status_code=500, detail=f"Installation failed: {e}")
    finally:
        if tmp_path.exists():
            tmp_path.unlink(missing_ok=True)


@router.get("/apps")
async def list_apps(request: Request):
    """列出所有已安装的 app 包（排除隐藏的内置应用）。"""
    tm: TaskManager = request.app.state.task_manager
    apps = [a for a in tm.list_apps() if not a.hidden]
    return {
        "apps": [
            {
                "app_id": a.app_id,
                "app_version": a.app_version,
                "name": a.name,
                "description": a.description,
                "full_id": a.full_id,
                "installed_at": a.installed_at.isoformat(),
            }
            for a in apps
        ]
    }


@router.delete("/apps/{app_id}")
async def delete_app(
    app_id: str,
    version: str = Query(..., description="App 版本号"),
    request: Request = None,
):
    """卸载指定版本的 app。"""
    tm: TaskManager = request.app.state.task_manager
    try:
        await tm.uninstall_app(app_id, version)
    except Exception as e:
        from app_manager import AppManagerError
        if isinstance(e, AppManagerError):
            raise HTTPException(status_code=e.status_code, detail=str(e))
        raise HTTPException(status_code=500, detail=str(e))
    return {"ok": True}


# ---------- Media Streams (integrated) ----------

from media_manager import MediaManager

_MEDIA_CONFIG_PATH = Path(__file__).parent / "data" / "media_config.json"


def _load_media_config() -> dict:
    """加载媒体配置，默认启用起飞自动停止。"""
    try:
        if _MEDIA_CONFIG_PATH.exists():
            return json.loads(_MEDIA_CONFIG_PATH.read_text(encoding="utf-8"))
    except Exception:
        pass
    return {"auto_stop_on_takeoff": True}


def _save_media_config(cfg: dict):
    """保存媒体配置。"""
    try:
        _MEDIA_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
        _MEDIA_CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception:
        pass


# 内部状态：用于避免重复触发
_media_auto_stop_triggered = False


_media_manager_ref: MediaManager | None = None

def _set_media_manager_ref(mm: MediaManager | None):
    global _media_manager_ref
    _media_manager_ref = mm

async def media_auto_stop_monitor():
    """后台协程：如果启用了起飞自动停止，当 /tmp/arm.lock 出现时自动停止所有流。"""
    global _media_auto_stop_triggered
    while True:
        try:
            cfg = _load_media_config()
            if cfg.get("auto_stop_on_takeoff", True):
                arm_lock_exists = Path("/tmp/arm.lock").exists()
                if arm_lock_exists and not _media_auto_stop_triggered:
                    mm = _media_manager_ref
                    if mm:
                        for s in mm.list_streams():
                            if s.get("enabled"):
                                await mm.stop_stream(s["id"])
                    _media_auto_stop_triggered = True
                elif not arm_lock_exists:
                    _media_auto_stop_triggered = False
            else:
                _media_auto_stop_triggered = False
        except Exception:
            pass
        await asyncio.sleep(1)


@router.get("/media/config")
async def get_media_config():
    """获取媒体配置。"""
    return _load_media_config()


@router.post("/media/config")
async def set_media_config(body: dict):
    """设置媒体配置。"""
    cfg = _load_media_config()
    if "auto_stop_on_takeoff" in body:
        cfg["auto_stop_on_takeoff"] = bool(body["auto_stop_on_takeoff"])
    _save_media_config(cfg)
    return cfg


def _get_media_manager(request: Request) -> MediaManager:
    mm = getattr(request.app.state, "media_manager", None)
    if not mm:
        raise HTTPException(status_code=503, detail="Media manager not initialized")
    return mm


@router.get("/media/streams")
async def list_streams(request: Request):
    """列出所有流配置。"""
    return {"streams": _get_media_manager(request).list_streams()}


@router.post("/media/streams")
async def create_stream(request: Request, body: dict):
    """创建流配置。"""
    mm = _get_media_manager(request)
    return mm.create_stream(body)


@router.get("/media/streams/{stream_id}")
async def get_stream(stream_id: str, request: Request):
    """获取单个流配置。"""
    mm = _get_media_manager(request)
    s = mm.get_stream(stream_id)
    if not s:
        raise HTTPException(status_code=404, detail="Stream not found")
    return s


@router.put("/media/streams/{stream_id}")
async def update_stream(stream_id: str, request: Request, body: dict):
    """更新流配置。"""
    mm = _get_media_manager(request)
    s = mm.update_stream(stream_id, body)
    if not s:
        raise HTTPException(status_code=404, detail="Stream not found")
    return s


@router.delete("/media/streams/{stream_id}")
async def delete_stream(stream_id: str, request: Request):
    """删除流配置。"""
    mm = _get_media_manager(request)
    # Stop first if active
    s = mm.get_stream(stream_id)
    if s and s.get("enabled"):
        await mm.stop_stream(stream_id)
    if mm.delete_stream(stream_id):
        return {"ok": True}
    raise HTTPException(status_code=404, detail="Stream not found")


@router.post("/media/streams/{stream_id}/start")
async def start_stream(stream_id: str, request: Request):
    """启动流（启用并重启 go2rtc）。"""
    mm = _get_media_manager(request)
    # Takeoff lock protection
    cfg = _load_media_config()
    if cfg.get("auto_stop_on_takeoff", True) and Path("/tmp/arm.lock").exists():
        raise HTTPException(
            status_code=423,
            detail="Takeoff lock detected (/tmp/arm.lock). Stream start is blocked while auto-stop on takeoff is enabled.",
        )
    try:
        return await mm.start_stream(stream_id)
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except RuntimeError as e:
        raise HTTPException(status_code=500, detail=str(e))


@router.post("/media/streams/{stream_id}/stop")
async def stop_stream(stream_id: str, request: Request):
    """停止流（禁用并重启 go2rtc）。"""
    mm = _get_media_manager(request)
    try:
        return await mm.stop_stream(stream_id)
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))


@router.post("/media/webrtc-offer")
async def webrtc_offer(request: Request, body: dict):
    """WHEP proxy: forward SDP offer to go2rtc, return answer."""
    mm = _get_media_manager(request)
    stream_id = body.get("stream_id", "")
    offer_sdp = body.get("sdp", "")
    if not stream_id or not offer_sdp:
        raise HTTPException(status_code=400, detail="stream_id and sdp are required")
    try:
        answer = await mm.webrtc_offer(stream_id, offer_sdp)
        return {"sdp": answer}
    except RuntimeError as e:
        raise HTTPException(status_code=502, detail=str(e))


# ---------- Managed Services ----------

MANAGED_SERVICES: Dict[str, Dict[str, str]] = {
    "e710-rfid":      {"label": "E710 RFID",       "label_en": "E710 RFID",       "label_ru": "E710 RFID"},
    "livox":          {"label": "Livox",           "label_en": "Livox",           "label_ru": "Livox"},
    "livox_s":        {"label": "Livox S",         "label_en": "Livox S",         "label_ru": "Livox S"},
    "uav-speaker":    {"label": "UAV Speaker",     "label_en": "UAV Speaker",     "label_ru": "UAV Speaker"},
    "zyhk-sio-client":{"label": "ZYHK SIO Client", "label_en": "ZYHK SIO Client", "label_ru": "ZYHK SIO Client"},
}


@router.get("/services")
async def list_services():
    """列出所有管理中的 systemd 服务及其当前状态。"""
    result = []
    for svc_name, info in MANAGED_SERVICES.items():
        item = {
            "name": svc_name,
            "label": info["label"],
            "label_en": info.get("label_en", info["label"]),
            "label_ru": info.get("label_ru", info["label"]),
            "active": False,
            "status": "unknown",
            "sub_state": None,
            "pid": None,
        }
        try:
            proc = subprocess.run(
                ["systemctl", "is-active", f"{svc_name}.service"],
                capture_output=True, text=True, timeout=5
            )
            state_str = proc.stdout.strip()
            item["active"] = state_str == "active"
            item["status"] = state_str
        except Exception:
            pass

        try:
            show_proc = subprocess.run(
                ["systemctl", "show", f"{svc_name}.service", "--property=ActiveState,SubState,MainPID"],
                capture_output=True, text=True, timeout=5
            )
            for line in show_proc.stdout.strip().splitlines():
                if "=" not in line:
                    continue
                k, v = line.split("=", 1)
                if k == "SubState" and v:
                    item["sub_state"] = v
                elif k == "MainPID" and v and v != "0":
                    item["pid"] = v
        except Exception:
            pass

        result.append(item)
    return {"services": result}


@router.post("/services/{service_name}/{action}")
async def control_service(service_name: str, action: str):
    """控制指定 systemd 服务：start / stop / restart。"""
    if service_name not in MANAGED_SERVICES:
        raise HTTPException(status_code=400, detail=f"Unknown service: {service_name}")
    if action not in ("start", "stop", "restart"):
        raise HTTPException(status_code=400, detail="Invalid action. Use start/stop/restart.")

    try:
        proc = subprocess.run(
            ["sudo", "systemctl", action, f"{service_name}.service"],
            capture_output=True, text=True, timeout=30
        )
        if proc.returncode != 0:
            err = proc.stderr.strip() or proc.stdout.strip() or f"systemctl {action} failed"
            print(f"[services] systemctl {action} {service_name}.service failed: {err}")
            raise HTTPException(status_code=500, detail=err)
        return {"ok": True, "service": service_name, "action": action}
    except subprocess.TimeoutExpired:
        raise HTTPException(status_code=504, detail="systemctl command timed out")
    except HTTPException:
        raise
    except Exception as e:
        print(f"[services] unexpected error: {e}")
        raise HTTPException(status_code=500, detail=str(e))


# ---------- Push Stream ----------

@router.get("/push-streams")
async def list_push_streams(request: Request):
    manager = request.app.state.push_stream
    return {"configs": await manager.list_configs(), "global_auto_push": manager.get_global_auto_push()}


@router.post("/push-streams")
async def create_push_stream(request: Request):
    body = await request.json()
    manager = request.app.state.push_stream
    return manager.create_config(body)


@router.get("/push-streams/settings")
async def get_push_stream_settings(request: Request):
    manager = request.app.state.push_stream
    return {"global_auto_push": manager.get_global_auto_push()}


@router.put("/push-streams/settings")
async def set_push_stream_settings(request: Request):
    body = await request.json()
    manager = request.app.state.push_stream
    await manager.set_global_auto_push(body.get("global_auto_push", False))
    return {"ok": True}


@router.get("/push-streams/{config_id}")
async def get_push_stream(config_id: str, request: Request):
    manager = request.app.state.push_stream
    cfg = await manager.get_config(config_id)
    if not cfg:
        raise HTTPException(status_code=404, detail="Config not found")
    return cfg


@router.put("/push-streams/{config_id}")
async def update_push_stream(config_id: str, request: Request):
    body = await request.json()
    manager = request.app.state.push_stream
    return await manager.update_config(config_id, body)


@router.delete("/push-streams/{config_id}")
async def delete_push_stream(config_id: str, request: Request):
    manager = request.app.state.push_stream
    return await manager.delete_config(config_id)


@router.post("/push-streams/{config_id}/start")
async def start_push_stream(config_id: str, request: Request):
    manager = request.app.state.push_stream
    return await manager.start(config_id)


@router.post("/push-streams/{config_id}/stop")
async def stop_push_stream(config_id: str, request: Request):
    manager = request.app.state.push_stream
    return await manager.stop(config_id)


# ---------- Detection ----------

@router.get("/detection/settings")
async def detection_settings(request: Request):
    manager = request.app.state.detection
    return manager.get_settings()


@router.put("/detection/settings")
async def update_detection_settings(request: Request):
    body = await request.json()
    manager = request.app.state.detection
    return manager.update_settings(body)


@router.post("/detection/start")
async def detection_start(request: Request):
    manager = request.app.state.detection
    return manager.start()


@router.post("/detection/stop")
async def detection_stop(request: Request):
    manager = request.app.state.detection
    return manager.stop()


@router.get("/detection/models")
async def list_detection_models(request: Request):
    manager = request.app.state.detection
    return {"models": manager.list_models()}


@router.post("/detection/model/upload")
async def upload_detection_model(request: Request, file: UploadFile = File(...)):
    manager = request.app.state.detection
    content = await file.read()
    return manager.upload_model(file.filename, content)


@router.delete("/detection/model/{filename}")
async def delete_detection_model(filename: str, request: Request):
    manager = request.app.state.detection
    return manager.delete_model(filename)


# ---------- Updater ----------

@router.post("/updater/upload")
async def upload_update_package(
    request: Request,
    file: UploadFile = File(..., description="压缩包文件 (.zip/.tar.gz/.tar 等)"),
):
    filename = file.filename or "unnamed"
    if ".." in filename or "/" in filename or "\\" in filename:
        raise HTTPException(status_code=400, detail="Invalid filename")
    ext = Path(filename).suffix.lower()
    allowed_exts = {".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar"}
    if ext not in allowed_exts and not any(filename.lower().endswith(e) for e in [".tar.gz", ".tar.bz2", ".tar.xz"]):
        raise HTTPException(status_code=400, detail="Only compressed archives are allowed")
    dest = UPDATER_DIR / filename
    try:
        content = await file.read()
        if len(content) > 500 * 1024 * 1024:
            raise HTTPException(status_code=413, detail="File too large (max 500MB)")
        dest.write_bytes(content)
        os.sync()
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Upload failed: {e}")
    return {"ok": True, "filename": filename, "size": len(content), "path": str(dest)}


@router.get("/updater")
async def list_update_packages():
    if not UPDATER_DIR.exists():
        return {"packages": []}
    packages = []
    for f in sorted(UPDATER_DIR.iterdir()):
        if f.is_file():
            stat = f.stat()
            packages.append({
                "filename": f.name,
                "size": stat.st_size,
                "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(),
                "path": str(f),
            })
    return {"packages": packages}


@router.delete("/updater/{filename}")
async def delete_update_package(filename: str):
    if ".." in filename or "/" in filename or "\\" in filename:
        raise HTTPException(status_code=400, detail="Invalid filename")
    target = UPDATER_DIR / filename
    if not target.exists() or not target.is_file():
        raise HTTPException(status_code=404, detail="File not found")
    try:
        target.unlink()
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Delete failed: {e}")
    return {"ok": True}


# ---------- Self Update ----------

@router.get("/self-update")
async def self_update_info(request: Request):
    updater: SelfUpdater = request.app.state.self_updater
    return {
        "current_version": updater.get_version_info(),
        "packages": updater.list_packages(),
        "backups": updater.list_backups(),
        "status": updater.get_status(),
    }


@router.post("/self-update/upload")
async def self_update_upload(
    request: Request,
    file: UploadFile = File(..., description="deploy 生成的 tar.gz 包"),
):
    filename = file.filename or "unnamed"
    if ".." in filename or "/" in filename or "\\" in filename:
        raise HTTPException(status_code=400, detail="Invalid filename")
    allowed = filename.lower().endswith((".tar.gz", ".tgz"))
    if not allowed:
        raise HTTPException(status_code=400, detail="Only .tar.gz or .tgz packages are allowed")
    updater: SelfUpdater = request.app.state.self_updater
    try:
        content = await file.read()
        if len(content) > 500 * 1024 * 1024:
            raise HTTPException(status_code=413, detail="File too large (max 500MB)")
        dest = updater.upload(filename, content)
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Upload failed: {e}")
    return {"ok": True, "filename": filename, "size": len(content), "path": str(dest)}


@router.post("/self-update/apply")
async def self_update_apply(request: Request):
    body = await request.json()
    filename = body.get("filename", "")
    if not filename:
        raise HTTPException(status_code=400, detail="filename is required")
    updater: SelfUpdater = request.app.state.self_updater
    package_path = updater.updater_dir / filename
    result = updater.apply(package_path)
    return result


@router.post("/self-update/rollback")
async def self_update_rollback(request: Request):
    updater: SelfUpdater = request.app.state.self_updater
    result = updater.rollback()
    return result


@router.delete("/self-update/packages/{filename}")
async def self_update_delete_package(filename: str, request: Request):
    updater: SelfUpdater = request.app.state.self_updater
    try:
        updater.delete_package(filename)
        return {"ok": True}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@router.delete("/self-update/backups/{name}")
async def self_update_delete_backup(name: str, request: Request):
    updater: SelfUpdater = request.app.state.self_updater
    try:
        updater.delete_backup(name)
        return {"ok": True}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


# ---------- 系统诊断 ----------

import uuid
import threading
import time
from concurrent.futures import ThreadPoolExecutor

# 诊断会话存储
_diag_sessions: Dict[str, Any] = {}
_diag_lock = threading.Lock()
_diag_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="diag-")


def _check_flight_lock() -> bool:
    """检查是否有 arm/flight 锁文件，返回 True 表示已锁定（飞行中）。"""
    return os.path.exists("/tmp/arm.lock") or os.path.exists("/tmp/flight.lock")


def _cleanup_old_sessions(max_count: int = 10) -> None:
    """保留最近的 N 个会话，删除旧的。"""
    with _diag_lock:
        if len(_diag_sessions) <= max_count:
            return
        # 按 started_at 排序，保留最新的
        sorted_ids = sorted(
            _diag_sessions.keys(),
            key=lambda sid: _diag_sessions[sid].get("started_at", ""),
            reverse=True,
        )
        for old_id in sorted_ids[max_count:]:
            del _diag_sessions[old_id]


def _format_report(session: Dict[str, Any]) -> str:
    """将会话结果格式化为文本报告。"""
    lines = []
    lines.append("=" * 70)
    lines.append("  系统诊断报告")
    lines.append("  会话ID: {}".format(session.get("session_id", "")))
    lines.append("  开始时间: {}".format(session.get("started_at", "")))
    lines.append("  结束时间: {}".format(session.get("completed_at", "未完成")))
    lines.append("=" * 70)
    lines.append("")

    results = session.get("results", [])
    total = len(results)
    passed = sum(1 for r in results if r.get("success"))
    failed = total - passed

    for idx, result in enumerate(results, 1):
        lines.append("-" * 70)
        status = "[OK]" if result.get("success") else "[FAIL]"
        lines.append("[{}/{}] {} {}".format(idx, total, status, result.get("name", "")))
        lines.append("  消息: {}".format(result.get("message", "")))
        if result.get("details"):
            lines.append("  详情:")
            for detail_line in result["details"].split("\n"):
                lines.append("    {}".format(detail_line))
        lines.append("")

    lines.append("=" * 70)
    lines.append("  汇总: 通过 {} / 失败 {} / 总计 {}".format(passed, failed, total))
    lines.append("=" * 70)
    return "\n".join(lines)


@router.get("/system-diagnostics/tests")
async def list_diagnostic_tests():
    """列出所有可用的诊断测试项。"""
    return {
        "tests": [
            {"id": tid, "name": name}
            for tid, name in TEST_ITEMS
        ]
    }


@router.post("/system-diagnostics/run")
async def run_diagnostics(body: Dict[str, Any] = None):
    """启动系统诊断测试。

    body: {"test_ids": ["check_hostname", ...]}  空/null 表示全部
    """
    if _check_flight_lock():
        raise HTTPException(
            status_code=423,
            detail="Drone is armed or in flight. System diagnostics are prohibited.",
        )

    test_ids = (body or {}).get("test_ids") or []
    if test_ids:
        # 校验测试ID
        valid_ids = {tid for tid, _ in TEST_ITEMS}
        invalid = [t for t in test_ids if t not in valid_ids]
        if invalid:
            raise HTTPException(
                status_code=400,
                detail="Invalid test ids: {}".format(", ".join(invalid)),
            )
        items = [(tid, name) for tid, name in TEST_ITEMS if tid in test_ids]
    else:
        items = list(TEST_ITEMS)

    session_id = "diag-" + uuid.uuid4().hex[:12]
    session = {
        "session_id": session_id,
        "status": "running",
        "started_at": datetime.utcnow().isoformat(),
        "completed_at": None,
        "total": len(items),
        "completed": 0,
        "results": [],
    }

    with _diag_lock:
        _diag_sessions[session_id] = session
    _cleanup_old_sessions()

    def _run_tests():
        """在后台线程中顺序执行测试。"""
        diag = LocalDiagnosticTests()
        try:
            for idx, (test_id, test_name) in enumerate(items):
                # 飞行中检查
                if _check_flight_lock():
                    result = {
                        "test_id": test_id,
                        "name": test_name,
                        "success": False,
                        "message": "[X] 测试中断: 无人机已解锁/飞行中",
                        "details": "检测到 /tmp/arm.lock 或 /tmp/flight.lock",
                        "duration_ms": 0,
                    }
                    with _diag_lock:
                        session["results"].append(result)
                        session["completed"] = idx + 1
                    break

                start_ts = time.time()
                try:
                    method = getattr(diag, test_id)
                    raw = method()
                except Exception as e:
                    raw = {
                        "success": False,
                        "message": "[X] 执行异常: {}".format(str(e)),
                        "details": str(e),
                    }
                duration_ms = int((time.time() - start_ts) * 1000)

                result = {
                    "test_id": test_id,
                    "name": test_name,
                    "success": raw.get("success", False),
                    "message": raw.get("message", ""),
                    "details": raw.get("details", ""),
                    "duration_ms": duration_ms,
                }
                with _diag_lock:
                    session["results"].append(result)
                    session["completed"] = idx + 1

            with _diag_lock:
                session["status"] = "completed"
                session["completed_at"] = datetime.utcnow().isoformat()
        except Exception as e:
            with _diag_lock:
                session["status"] = "error"
                session["completed_at"] = datetime.utcnow().isoformat()

    _diag_executor.submit(_run_tests)
    return {"session_id": session_id}


@router.get("/system-diagnostics/{session_id}")
async def get_diagnostics_session(session_id: str):
    """查询诊断会话状态和结果。"""
    with _diag_lock:
        session = _diag_sessions.get(session_id)
    if not session:
        raise HTTPException(status_code=404, detail="Session not found")
    return session


@router.get("/system-diagnostics/{session_id}/download")
async def download_diagnostics_report(session_id: str):
    """下载诊断报告文本文件。"""
    with _diag_lock:
        session = _diag_sessions.get(session_id)
    if not session:
        raise HTTPException(status_code=404, detail="Session not found")

    report_text = _format_report(session)
    tmp_path = Path("/tmp") / "diag_report_{}.txt".format(session_id)
    tmp_path.write_text(report_text, encoding="utf-8")

    return FastAPIFileResponse(
        str(tmp_path),
        media_type="text/plain; charset=utf-8",
        filename="diagnostic_report_{}.txt".format(session_id),
        background=BackgroundTask(lambda: tmp_path.unlink(missing_ok=True)),
    )


# ---------- 端口监控 ----------

@router.get("/port-monitor/status")
async def port_monitor_status(request: Request):
    """触发端口监控检测并返回当前状态（网卡/USB快照 + 变化事件）。飞行中也可访问。"""
    pm = request.app.state.port_monitor
    return pm.check_now()


@router.post("/port-monitor/toggle")
async def port_monitor_toggle(request: Request, body: Dict[str, Any] = None):
    """启用/禁用端口监控。
    body: {"enabled": true/false}
    """
    pm = request.app.state.port_monitor
    enabled = (body or {}).get("enabled")
    if enabled is None:
        raise HTTPException(status_code=400, detail="enabled field is required")
    pm.set_enabled(bool(enabled))
    return {"enabled": pm.enabled}


@router.get("/port-monitor/logs")
async def port_monitor_logs(request: Request):
    """列出端口监控日志文件。飞行中也可访问。"""
    pm = request.app.state.port_monitor
    return {"logs": pm.list_logs()}


@router.get("/port-monitor/logs/download")
async def port_monitor_download_log(request: Request, filename: str = Query(..., description="日志文件名")):
    """下载指定日志文件。飞行中也可访问。"""
    pm = request.app.state.port_monitor
    if ".." in filename or "/" in filename or "\\" in filename:
        raise HTTPException(status_code=400, detail="Invalid filename")
    log_path = pm.log_dir / filename
    if not log_path.exists() or not log_path.is_file():
        raise HTTPException(status_code=404, detail="Log file not found")
    return FastAPIFileResponse(
        str(log_path),
        media_type="application/x-ndjson",
        filename=filename,
    )
