from __future__ import annotations

import asyncio
import json
import os
import signal
import subprocess
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Union

import jinja2

from app_manager import AppManager, AppManagerError, AppPackage
from config import load_task_definitions
from system_monitor import SystemMonitor
from models import (
    ConflictPolicy,
    ProgressInfo,
    StartTaskRequest,
    TaskDefinition,
    TaskInstance,
    TaskStatus,
)


def _render_command(template_str: str, params: Dict[str, Any]) -> str:
    """使用 Jinja2 渲染命令模板。None 值保留为 "None" 字符串，供 C++ 节点识别为未设置。"""
    env = jinja2.Environment(undefined=jinja2.StrictUndefined)
    env.filters["basename"] = lambda x: Path(str(x)).name
    template = env.from_string(template_str)
    # 将 None / 空字符串替换为 "None" 字符串，避免 Jinja2 渲染为空
    render_params = {k: ("None" if v is None or v == "" else v) for k, v in params.items()}
    rendered = template.render(**render_params)
    lines = [line.strip() for line in rendered.splitlines() if line.strip()]
    return " ".join(lines)


EventCallback = Callable[[str, Dict[str, Any]], None]


class StateStore:
    """断电安全的 JSON 状态持久化存储。

    保存策略：先写 .tmp → 备份当前文件为 .bak → 原子替换主文件 → 强制刷盘
    加载策略：主文件 → .bak → .tmp，逐级回退
    """

    def __init__(self, path: Path):
        self._path = Path(path)
        self._tmp_path = self._path.with_suffix(".tmp")
        self._bak_path = self._path.with_suffix(".bak")
        self._lock = threading.Lock()

    def save(self, instances: Dict[str, TaskInstance]) -> None:
        data = [
            inst.model_dump(mode="json", exclude={"log_buffer"})
            for inst in instances.values()
        ]
        text = json.dumps(data, ensure_ascii=False, separators=(",", ":"), default=str)
        with self._lock:
            self._tmp_path.write_text(text, encoding="utf-8")
            # 备份当前文件（如果存在）
            if self._path.exists():
                try:
                    self._path.replace(self._bak_path)
                except Exception:
                    pass
            # 原子替换，保证读取方不会拿到半写文件
            self._tmp_path.replace(self._path)
            # 强制刷盘：先刷 Python 缓冲区到 OS，再刷 OS 缓冲区到物理介质
            os.sync()
            subprocess.run(["sync"], check=False, capture_output=True)

    def _try_load_file(self, path: Path) -> Optional[list]:
        """尝试从指定路径加载 JSON 数据，失败返回 None。"""
        if not path.exists():
            return None
        try:
            text = path.read_text(encoding="utf-8")
            if not text.strip():
                return None
            return json.loads(text)
        except Exception:
            return None

    def load(self) -> Dict[str, TaskInstance]:
        # 加载优先级：主文件 → .bak → .tmp
        data = self._try_load_file(self._path)
        if data is None:
            data = self._try_load_file(self._bak_path)
            if data is not None:
                print(f"[WARN] 主状态文件加载失败，已从备份 {self._bak_path.name} 恢复")
        if data is None:
            data = self._try_load_file(self._tmp_path)
            if data is not None:
                print(f"[WARN] 主状态文件和备份均失败，已从临时文件 {self._tmp_path.name} 恢复")
        if data is None:
            return {}

        instances: Dict[str, TaskInstance] = {}
        for item in data:
            try:
                inst = TaskInstance.model_validate(item)
                # 断电恢复：非终态任务进程已不存在，标记为 failed
                # 但添加 _was_interrupted 标记，提示用户是否重新运行
                if inst.status in (
                    TaskStatus.PENDING,
                    TaskStatus.RUNNING,
                    TaskStatus.STOPPING,
                ):
                    inst.status = TaskStatus.FAILED
                    inst.returncode = -1
                    inst.finished_at = datetime.utcnow()
                    meta = dict(inst.progress.metadata) if inst.progress.metadata else {}
                    meta["_was_interrupted"] = True
                    inst.progress = ProgressInfo(
                        percent=inst.progress.percent,
                        message=inst.progress.message,
                        metadata=meta,
                    )
                instances[inst.id] = inst
            except Exception:
                continue
        return instances


class HistoryStore:
    """任务历史记录存储（只追加），用于 delete 后仍可查看过往任务。

    保存策略：先写 .tmp → 备份当前文件为 .bak → 原子替换主文件 → 强制刷盘
    加载策略：主文件 → .bak → .tmp，逐级回退
    """

    def __init__(self, path: Path, max_entries: int = 2000):
        self._path = Path(path)
        self._path.parent.mkdir(parents=True, exist_ok=True)
        self._bak_path = self._path.with_suffix(".bak")
        self._tmp_path = self._path.with_suffix(".tmp")
        self._max_entries = max_entries
        self._lock = threading.Lock()
        self._seen_ids: set = set()
        self._load_seen()

    def _try_load(self, path: Path) -> Optional[List[Dict[str, Any]]]:
        """尝试从指定路径加载 JSON 数组，失败返回 None。"""
        if not path.exists():
            return None
        try:
            with open(path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            return None

    def _load_seen(self) -> None:
        data = self._try_load(self._path)
        if data is None:
            data = self._try_load(self._bak_path)
            if data is not None:
                print(f"[WARN] 历史记录主文件加载失败，已从备份 {self._bak_path.name} 恢复")
        if data is None:
            data = self._try_load(self._tmp_path)
            if data is not None:
                print(f"[WARN] 历史记录主文件和备份均失败，已从临时文件 {self._tmp_path.name} 恢复")
        if data is None:
            return
        for item in data:
            self._seen_ids.add(item.get("id"))

    def append(self, instance: TaskInstance) -> None:
        if instance.id in self._seen_ids:
            return
        self._seen_ids.add(instance.id)
        entry = instance.model_dump(mode="json", exclude={"log_buffer"})
        with self._lock:
            entries = self._try_load(self._path) or []
            entries.append(entry)
            if len(entries) > self._max_entries:
                entries = entries[-self._max_entries:]
                self._seen_ids = {e.get("id") for e in entries}
            # 备份当前文件（如果存在）
            if self._path.exists():
                try:
                    self._path.replace(self._bak_path)
                except Exception:
                    pass
            with open(self._tmp_path, "w", encoding="utf-8") as f:
                json.dump(entries, f, ensure_ascii=False, separators=(",", ":"), default=str)
            self._tmp_path.replace(self._path)
            os.sync()

    def list_entries(self, limit: int = 100, offset: int = 0, definition_id: Optional[str] = None) -> Dict[str, Any]:
        entries = self._try_load(self._path)
        if entries is None:
            entries = self._try_load(self._bak_path) or []
        if entries is None:
            entries = self._try_load(self._tmp_path) or []
        if not entries:
            return {"entries": [], "total": 0}
        entries.reverse()
        if definition_id:
            entries = [e for e in entries if e.get("definition_id") == definition_id]
        total = len(entries)
        return {"entries": entries[offset:offset + limit], "total": total}

    def get_entry(self, instance_id: str) -> Optional[Dict[str, Any]]:
        entries = self._try_load(self._path)
        if entries is None:
            entries = self._try_load(self._bak_path) or []
        if entries is None:
            entries = self._try_load(self._tmp_path) or []
        for item in reversed(entries):
            if item.get("id") == instance_id:
                return item
        return None

    def cleanup(self, days: int) -> int:
        """清理 N 天前的历史记录，始终至少保留 100 条。"""
        if not self._path.exists():
            return 0
        cutoff = datetime.utcnow().timestamp() - days * 86400
        try:
            with open(self._path, "r", encoding="utf-8") as f:
                entries = json.load(f)
            original_len = len(entries)
            new_entries = [e for e in entries if self._entry_time(e) > cutoff]
            if len(new_entries) < 100:
                new_entries = entries[-100:]
            with open(self._path, "w", encoding="utf-8") as f:
                json.dump(new_entries, f, ensure_ascii=False, separators=(",", ":"), default=str)
                os.fsync(f.fileno())
            os.sync()
            self._seen_ids = {e.get("id") for e in new_entries}
            return original_len - len(new_entries)
        except Exception:
            return 0

    @staticmethod
    def _entry_time(entry: Dict[str, Any]) -> float:
        finished = entry.get("finished_at")
        if finished:
            try:
                return datetime.fromisoformat(finished).timestamp()
            except Exception:
                pass
        created = entry.get("created_at")
        if created:
            try:
                return datetime.fromisoformat(created).timestamp()
            except Exception:
                pass
        return 0.0


class TaskManager:
    def __init__(
        self,
        tasks_dir: Union[str, Path],
        internal_api_base: str,
        state_file: Optional[Union[str, Path]] = None,
        apps_dir: Optional[Union[str, Path]] = None,
    ):
        # 1. 加载内置任务定义
        self._definitions: Dict[str, TaskDefinition] = load_task_definitions(tasks_dir)
        # 2. 初始化 AppManager 并加载已安装 apps
        self._app_manager: Optional[AppManager] = None
        if apps_dir:
            self._app_manager = AppManager(apps_dir)
            builtin_ids = set(self._definitions.keys())
            app_packages = self._app_manager.load_installed(builtin_definition_ids=builtin_ids)
            for app in app_packages.values():
                self._definitions[app.full_id] = app.definition
        state_path = Path(state_file) if state_file else Path(__file__).parent / "data" / "state.json"
        self._state_store = StateStore(state_path)
        self._history_store = HistoryStore(
            state_path.with_name("history.json"),
            max_entries=2000,
        )
        self._system_monitor = SystemMonitor()
        self._max_instances = 10000
        self._active_map: Dict[str, str] = {}
        self._processes: Dict[str, asyncio.subprocess.Process] = {}
        self._monitors: Dict[str, asyncio.Task] = {}
        # 启动时从文件恢复状态
        self._instances: Dict[str, TaskInstance] = self._state_store.load()
        # 启动时清理：超出上限 + 30 天前的终态实例
        self._cleanup_old_instances()
        self._cleanup_instances_by_age(days=30)
        self._internal_api_base = internal_api_base.rstrip("/")
        self._subscribers: List[EventCallback] = []
        self._lock = asyncio.Lock()
        # 重建 _active_map（恢复后应该没有 active，但以防万一）
        for inst in self._instances.values():
            if inst.status in (TaskStatus.PENDING, TaskStatus.RUNNING, TaskStatus.STOPPING):
                self._active_map[inst.definition_id] = inst.id
        # 进度更新 debounce 控制
        self._save_needed = False
        self._save_in_progress = False
        self._watchdog_task: Optional[asyncio.Task] = None
        self._system_info_task: Optional[asyncio.Task] = None

    async def start_watchdog(self, interval: float = 5.0) -> None:
        """启动看门狗定时检查运行中的子进程。"""
        if self._watchdog_task is not None and not self._watchdog_task.done():
            return
        self._watchdog_task = asyncio.create_task(self._watchdog_loop(interval))

    def stop_watchdog(self) -> None:
        if self._watchdog_task is not None and not self._watchdog_task.done():
            self._watchdog_task.cancel()
            self._watchdog_task = None

    async def _watchdog_loop(self, interval: float) -> None:
        while True:
            try:
                await asyncio.sleep(interval)
            except asyncio.CancelledError:
                break
            await self._watchdog_check()

    async def _watchdog_check(self) -> None:
        async with self._lock:
            dirty = False
            for instance_id in list(self._active_map.values()):
                instance = self._instances.get(instance_id)
                if not instance or instance.status != TaskStatus.RUNNING:
                    continue
                pid = instance.pid
                if pid is None:
                    continue
                try:
                    os.kill(pid, 0)
                except ProcessLookupError:
                    # 进程已不存在但状态仍为 running，主动修复
                    instance.status = TaskStatus.FAILED
                    instance.returncode = -1
                    instance.finished_at = datetime.utcnow()
                    self._history_store.append(instance)
                    self._active_map.pop(instance.definition_id, None)
                    self._processes.pop(instance_id, None)
                    monitor = self._monitors.pop(instance_id, None)
                    if monitor and not monitor.done():
                        monitor.cancel()
                    self._emit(
                        "status_changed",
                        {
                            "instance_id": instance_id,
                            "status": TaskStatus.FAILED.value,
                            "returncode": -1,
                        },
                    )
                    dirty = True
            if dirty:
                await self._persist_now()
        self._save_timer: Optional[asyncio.Task] = None

    def subscribe(self, callback: EventCallback) -> None:
        self._subscribers.append(callback)

    def unsubscribe(self, callback: EventCallback) -> None:
        if callback in self._subscribers:
            self._subscribers.remove(callback)

    def _emit(self, event_type: str, payload: Dict[str, Any]) -> None:
        for cb in self._subscribers:
            try:
                cb(event_type, payload)
            except Exception:
                pass

    # ---------- 持久化 ----------

    def _cleanup_old_instances(self) -> int:
        """当实例总数超过上限时，删除最旧的终态实例（先归档到历史）。返回删除数量。"""
        terminal_statuses = {TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.STOPPED}
        terminal = [
            (k, v) for k, v in self._instances.items()
            if v.status in terminal_statuses
        ]
        total = len(self._instances)
        if total <= self._max_instances:
            return 0
        # 按 finished_at 排序，最旧的在前
        terminal.sort(key=lambda x: x[1].finished_at or datetime.min)
        to_remove = total - self._max_instances
        removed = 0
        for inst_id, inst in terminal[:to_remove]:
            # 先写入历史，再删除
            self._history_store.append(inst)
            self._cleanup_instance(inst_id)
            removed += 1
        if removed:
            print(f"[INFO] 实例数量超限，已自动清理 {removed} 条旧实例")
        return removed

    def _cleanup_instances_by_age(self, days: int = 30) -> int:
        """删除 N 天前的终态实例（先归档到历史）。返回删除数量。"""
        terminal_statuses = {TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.STOPPED}
        cutoff = datetime.utcnow().timestamp() - days * 86400
        removed = 0
        for inst_id, inst in list(self._instances.items()):
            if inst.status not in terminal_statuses:
                continue
            finished_ts = inst.finished_at.timestamp() if inst.finished_at else 0
            if finished_ts < cutoff:
                self._history_store.append(inst)
                self._cleanup_instance(inst_id)
                removed += 1
        if removed:
            print(f"[INFO] 自动清理 {removed} 条 {days} 天前的旧实例")
        return removed

    async def _persist_now(self) -> None:
        """立即持久化状态（用于生命周期变更）。保存前自动清理超出的终态实例。"""
        self._cleanup_old_instances()
        loop = asyncio.get_event_loop()
        await loop.run_in_executor(None, self._state_store.save, self._instances)

    async def _persist_debounced(self) -> None:
        """防抖持久化（用于高频进度更新），最多丢失 200ms 内的进度。"""
        self._save_needed = True
        if self._save_in_progress:
            return
        self._save_in_progress = True
        try:
            while self._save_needed:
                self._save_needed = False
                await asyncio.sleep(0.2)
            await self._persist_now()
        finally:
            self._save_in_progress = False

    def _cancel_pending_save(self) -> None:
        if self._save_timer and not self._save_timer.done():
            self._save_timer.cancel()
        self._save_needed = False
        self._save_in_progress = False

    # ---------- 公共接口 ----------

    def list_definitions(self) -> Dict[str, TaskDefinition]:
        return self._definitions

    def get_definition(self, definition_id: str) -> Optional[TaskDefinition]:
        return self._definitions.get(definition_id)

    # ---------- App 包管理（委托给 AppManager） ----------

    async def install_app(self, lldtp_path: Path) -> AppPackage:
        if not self._app_manager:
            raise RuntimeError("App manager not initialized (no --apps-dir)")
        builtin_ids = {
            k for k, v in self._definitions.items()
            if "@" not in k  # 内置任务不含 @
        }
        # 更新 builtin_ids：把当前所有非 app 的 definition_id 都当作内置
        # 但更准确的是：检查是否与已有 definition 冲突（包括其他 app）
        builtin_ids = set(self._definitions.keys())
        app_package = await self._app_manager.install(lldtp_path, builtin_ids)
        # 注册到任务定义列表
        self._definitions[app_package.full_id] = app_package.definition
        return app_package

    async def uninstall_app(self, app_id: str, app_version: str) -> None:
        if not self._app_manager:
            raise RuntimeError("App manager not initialized (no --apps-dir)")
        full_id = f"{app_id}@{app_version}"
        await self._app_manager.uninstall(app_id, app_version)
        self._definitions.pop(full_id, None)

    def list_apps(self) -> List[AppPackage]:
        if not self._app_manager:
            return []
        return self._app_manager.list_apps()

    async def start_task(self, req: StartTaskRequest) -> TaskInstance:
        # 全局锁文件检查
        lock_file = Path("/tmp/arm.lock")
        if lock_file.exists() and not req.force_ignore_lock:
            raise RuntimeError(f"Lock file exists: {lock_file}")

        async with self._lock:
            definition = self._definitions.get(req.definition_id)
            if not definition:
                raise ValueError(f"Unknown task definition: {req.definition_id}")

            # 单实例约束检查
            existing_id = self._active_map.get(req.definition_id)
            if existing_id:
                existing = self._instances.get(existing_id)
                if existing and existing.status in (
                    TaskStatus.PENDING,
                    TaskStatus.RUNNING,
                    TaskStatus.STOPPING,
                ):
                    if req.conflict_policy == ConflictPolicy.REJECT:
                        raise RuntimeError(
                            f"Task '{req.definition_id}' already has an active instance: {existing_id}"
                        )
                    elif req.conflict_policy == ConflictPolicy.RESTART:
                        await self._stop_instance(existing_id, force=False)
                        for _ in range(50):
                            await asyncio.sleep(0.1)
                            inst = self._instances.get(existing_id)
                            if not inst or inst.status in (
                                TaskStatus.STOPPED,
                                TaskStatus.FAILED,
                                TaskStatus.COMPLETED,
                            ):
                                break
                        else:
                            raise RuntimeError(
                                f"Failed to stop existing instance {existing_id} for restart"
                            )
                        self._cleanup_instance(existing_id)

            filled_params = self.fill_params(definition, req.params)

            # 如果是 app 任务，注入 app_dir 变量
            app_dir: Optional[str] = None
            if self._app_manager:
                app = self._app_manager.get_app_by_full_id(definition.id)
                if app:
                    app_dir = app.app_dir
                    filled_params["app_dir"] = app_dir

            command = _render_command(definition.command_template, filled_params)
            if definition.setup_script:
                command = f"source {definition.setup_script} && {command}"

            instance = TaskInstance(
                definition_id=req.definition_id,
                params=filled_params,
                command=command,
                status=TaskStatus.PENDING,
            )
            self._instances[instance.id] = instance
            self._active_map[req.definition_id] = instance.id

            await self._launch_instance(instance, definition, app_dir=app_dir)
            await self._persist_now()
            return instance

    def fill_params(
        self, definition: TaskDefinition, user_params: Dict[str, Any]
    ) -> Dict[str, Any]:
        result: Dict[str, Any] = {}
        for param in definition.parameters:
            value = user_params.get(param.name, param.default)
            if param.required and value is None:
                raise ValueError(f"Missing required parameter: {param.name}")
            if value is None:
                value = param.default
            result[param.name] = value
        for k, v in user_params.items():
            if k not in result:
                result[k] = v
        # 第二轮：渲染字符串默认值中的嵌套模板变量
        env = jinja2.Environment(undefined=jinja2.StrictUndefined)
        env.filters["basename"] = lambda x: Path(str(x)).name
        for k, v in list(result.items()):
            if isinstance(v, str) and "{{" in v:
                try:
                    template = env.from_string(v)
                    result[k] = template.render(**result)
                except Exception:
                    pass
        # scan2fullpcd: output_pcd 未指定时默认使用 {input_dir}/full_map.pcd
        if definition.id.startswith("scan2fullpcd") and not result.get("output_pcd"):
            input_dir = result.get("input_dir")
            if input_dir and str(input_dir) not in ("None", ""):
                result["output_pcd"] = str(Path(str(input_dir)) / "full_map.pcd")
        return result

    async def _launch_instance(
        self,
        instance: TaskInstance,
        definition: TaskDefinition,
        app_dir: Optional[str] = None,
    ) -> None:
        env = os.environ.copy()
        env.update(definition.env or {})

        # App 任务：注入 LD_LIBRARY_PATH（优先使用 app 自带的 lib/）
        if app_dir:
            app_lib = Path(app_dir) / "lib"
            if app_lib.exists():
                existing_ld = env.get("LD_LIBRARY_PATH", "")
                if existing_ld:
                    env["LD_LIBRARY_PATH"] = f"{app_lib}:{existing_ld}"
                else:
                    env["LD_LIBRARY_PATH"] = str(app_lib)
        progress_url = f"{self._internal_api_base}/api/internal/progress/{instance.id}"
        env["DRONE_TASK_PROGRESS_URL"] = progress_url
        env["DRONE_TASK_INSTANCE_ID"] = instance.id
        env["DRONE_TASK_DEFINITION_ID"] = definition.id

        instance.status = TaskStatus.RUNNING
        instance.started_at = datetime.utcnow()

        self._emit(
            "status_changed",
            {"instance_id": instance.id, "status": instance.status.value},
        )

        cwd = definition.working_dir or os.getcwd()

        # 显式使用 /bin/bash，确保 setup_script 中的 source 命令可用
        process = await asyncio.create_subprocess_shell(
            instance.command,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            env=env,
            cwd=cwd,
            executable="/bin/bash",
        )
        instance.pid = process.pid
        self._processes[instance.id] = process

        monitor = asyncio.create_task(
            self._monitor_instance(instance.id, process, definition)
        )
        self._monitors[instance.id] = monitor

    async def _monitor_instance(
        self,
        instance_id: str,
        process: asyncio.subprocess.Process,
        definition: TaskDefinition,
    ) -> None:
        instance = self._instances.get(instance_id)
        if not instance:
            return

        stdout_task = asyncio.create_task(
            self._read_stream(instance_id, process.stdout, "stdout")
        )
        stderr_task = asyncio.create_task(
            self._read_stream(instance_id, process.stderr, "stderr")
        )

        try:
            returncode = await process.wait()
        finally:
            stdout_task.cancel()
            stderr_task.cancel()
            try:
                await stdout_task
            except asyncio.CancelledError:
                pass
            try:
                await stderr_task
            except asyncio.CancelledError:
                pass

        instance = self._instances.get(instance_id)
        if not instance:
            return

        instance.returncode = returncode
        instance.finished_at = datetime.utcnow()

        if instance.status == TaskStatus.STOPPING:
            instance.status = TaskStatus.STOPPED
        elif returncode == 0:
            # Check if result metadata explicitly indicates failure
            meta_result = instance.progress.metadata.get("result") if instance.progress and instance.progress.metadata else None
            if isinstance(meta_result, dict) and meta_result.get("success") is False:
                instance.status = TaskStatus.FAILED
            else:
                instance.status = TaskStatus.COMPLETED
        else:
            instance.status = TaskStatus.FAILED

        self._emit(
            "status_changed",
            {
                "instance_id": instance_id,
                "status": instance.status.value,
                "returncode": returncode,
            },
        )

        if self._active_map.get(instance.definition_id) == instance_id:
            self._active_map.pop(instance.definition_id, None)

        self._processes.pop(instance_id, None)
        self._monitors.pop(instance_id, None)

        await self._persist_now()
        self._history_store.append(instance)

        # 自动重启
        if definition.auto_restart and instance.status == TaskStatus.FAILED:
            await asyncio.sleep(2)
            if self._active_map.get(instance.definition_id) is None:
                instance_new = TaskInstance(
                    definition_id=definition.id,
                    params=instance.params,
                    command=instance.command,
                    status=TaskStatus.PENDING,
                )
                self._instances[instance_new.id] = instance_new
                self._active_map[definition.id] = instance_new.id
                await self._launch_instance(instance_new, definition)
                await self._persist_now()

    async def _read_stream(
        self, instance_id: str, stream: Optional[Any], tag: str
    ) -> None:
        if stream is None:
            return
        instance = self._instances.get(instance_id)
        if not instance:
            return
        while True:
            try:
                line = await stream.readline()
            except Exception:
                break
            if not line:
                break
            text = line.decode("utf-8", errors="replace").rstrip("\n")
            log_line = f"[{tag}] {text}"
            instance.log_buffer.append(log_line)
            if len(instance.log_buffer) > 5000:
                instance.log_buffer.pop(0)
            self._emit(
                "log_added",
                {"instance_id": instance_id, "line": log_line},
            )
            self._try_parse_progress(instance_id, text)

    def _try_parse_progress(self, instance_id: str, text: str) -> None:
        stripped = text.strip()
        if not stripped.startswith("{"):
            return
        try:
            data = json.loads(stripped)
        except json.JSONDecodeError:
            return
        progress_data = data.get("_progress")
        if not progress_data:
            return
        instance = self._instances.get(instance_id)
        if not instance:
            return
        percent = int(progress_data.get("percent", 0))
        message = str(progress_data.get("message", ""))
        metadata = progress_data.get("metadata", {})
        instance.progress = ProgressInfo(
            percent=max(0, min(100, percent)),
            message=message,
            metadata=metadata,
        )
        self._emit(
            "progress_updated",
            {
                "instance_id": instance_id,
                "progress": instance.progress.model_dump(mode="json"),
            },
        )
        # 进度更新使用防抖持久化，避免频繁刷盘
        asyncio.create_task(self._persist_debounced())

    async def stop_task(self, instance_id: str, force: bool = False) -> TaskInstance:
        async with self._lock:
            return await self._stop_instance(instance_id, force)

    async def _stop_instance(self, instance_id: str, force: bool) -> TaskInstance:
        instance = self._instances.get(instance_id)
        if not instance:
            raise ValueError(f"Instance not found: {instance_id}")
        if instance.status not in (TaskStatus.PENDING, TaskStatus.RUNNING):
            return instance

        instance.status = TaskStatus.STOPPING
        self._emit(
            "status_changed",
            {"instance_id": instance_id, "status": instance.status.value},
        )

        process = self._processes.get(instance_id)
        if process and process.returncode is None:
            if force:
                process.kill()
            else:
                process.send_signal(signal.SIGTERM)
                try:
                    await asyncio.wait_for(process.wait(), timeout=5.0)
                except asyncio.TimeoutError:
                    process.kill()
        await self._persist_now()
        return instance

    def get_instance(self, instance_id: str) -> Optional[TaskInstance]:
        return self._instances.get(instance_id)

    def list_instances(self, limit: Optional[int] = None, offset: int = 0) -> Dict[str, Any]:
        """返回实例列表，支持分页。"""
        all_instances = sorted(
            self._instances.values(),
            key=lambda x: x.created_at,
            reverse=True,
        )
        total = len(all_instances)
        if limit is None:
            return {"entries": all_instances, "total": total}
        return {"entries": all_instances[offset:offset + limit], "total": total}

    def get_logs(self, instance_id: str, tail: int = 100) -> List[str]:
        instance = self._instances.get(instance_id)
        if not instance:
            return []
        return instance.log_buffer[-tail:]

    async def update_progress(self, instance_id: str, progress: ProgressInfo) -> None:
        instance = self._instances.get(instance_id)
        if not instance:
            return
        instance.progress = progress
        self._emit(
            "progress_updated",
            {
                "instance_id": instance_id,
                "progress": progress.model_dump(mode="json"),
            },
        )
        asyncio.create_task(self._persist_debounced())

    def delete_instance(self, instance_id: str) -> None:
        instance = self._instances.get(instance_id)
        if not instance:
            raise ValueError(f"Instance not found: {instance_id}")
        if instance.status in (TaskStatus.RUNNING, TaskStatus.PENDING, TaskStatus.STOPPING):
            raise RuntimeError("Cannot delete a running instance. Stop it first.")
        # 删除前先确保已记录到历史
        self._history_store.append(instance)
        self._cleanup_instance(instance_id)
        # 删除操作也同步落盘
        asyncio.create_task(self._persist_debounced())

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

    def list_history(self, limit: int = 100, offset: int = 0, definition_id: Optional[str] = None) -> Dict[str, Any]:
        return self._history_store.list_entries(limit=limit, offset=offset, definition_id=definition_id)

    def get_history(self, instance_id: str) -> Optional[Dict[str, Any]]:
        return self._history_store.get_entry(instance_id)

    def cleanup_history(self, days: int) -> int:
        return self._history_store.cleanup(days)

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

    def get_system_info(self) -> Dict[str, Any]:
        return self._system_monitor.get_system_info()

    async def start_system_info_loop(self, interval: float = 2.0) -> None:
        if self._system_info_task is not None and not self._system_info_task.done():
            return
        self._system_info_task = asyncio.create_task(self._system_info_loop(interval))

    def stop_system_info_loop(self) -> None:
        if self._system_info_task is not None and not self._system_info_task.done():
            self._system_info_task.cancel()
            self._system_info_task = None

    async def _system_info_loop(self, interval: float) -> None:
        while True:
            try:
                await asyncio.sleep(interval)
            except asyncio.CancelledError:
                break
            info = self._system_monitor.get_system_info()
            self._emit("system_info", info)

    def _cleanup_instance(self, instance_id: str) -> None:
        instance = self._instances.pop(instance_id, None)
        if instance:
            self._active_map.pop(instance.definition_id, None)
        self._processes.pop(instance_id, None)
        monitor = self._monitors.pop(instance_id, None)
        if monitor and not monitor.done():
            monitor.cancel()
