#!/usr/bin/env python3
from __future__ import annotations

from contextlib import contextmanager
import json
import os
from pathlib import Path
import shlex
import signal
import subprocess
import sys
import time
from typing import Any, Dict, List, Optional, Tuple

try:
    import fcntl  # type: ignore
except Exception:  # pragma: no cover
    fcntl = None  # type: ignore


REPO_ROOT = Path(__file__).resolve().parents[1]
SERVICE_PATH = REPO_ROOT / "cheapclaw_service.py"
FLEET_WEB_CONSOLE_PATH = REPO_ROOT / "scripts" / "fleet_web_console.py"
DEFAULT_HOME_ROOT = (Path.home() / "cheapclaw").resolve()
DEFAULT_RUNTIME_ROOT = (DEFAULT_HOME_ROOT / "runtime").resolve()
PLACEHOLDER_SNIPPETS = (
    "/ABS/PATH/TO/",
    "/ABS/PATH/TO",
    "telegram_token_here",
    "cli_xxx",
)
PROXY_ENV_KEYS = (
    "http_proxy",
    "https_proxy",
    "all_proxy",
    "no_proxy",
    "HTTP_PROXY",
    "HTTPS_PROXY",
    "ALL_PROXY",
    "NO_PROXY",
)


def _is_placeholder_text(text: str) -> bool:
    value = str(text or "").strip()
    if not value:
        return False
    if any(snippet in value for snippet in PLACEHOLDER_SNIPPETS):
        return True
    return value in {"xxx", "your_token_here", "your_app_id", "your_app_secret"}


def _load_json(path: Path) -> Dict[str, Any]:
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except Exception as exc:
        raise SystemExit(f"[ERROR] Failed to parse JSON: {path} ({exc})")
    if not isinstance(payload, dict):
        raise SystemExit(f"[ERROR] JSON root must be an object: {path}")
    return payload


def _dump_json(path: Path, payload: Dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def _required_text(value: Any, field_name: str, bot_id: str) -> str:
    text = str(value or "").strip()
    if not text:
        raise SystemExit(f"[ERROR] Missing '{field_name}' for bot '{bot_id}'")
    return text


def _child_process_env(manifest: Dict[str, Any]) -> Dict[str, str]:
    env = os.environ.copy()
    for key in PROXY_ENV_KEYS:
        env.pop(key, None)

    proxy_env = manifest.get("proxy_env")
    if not isinstance(proxy_env, dict):
        return env

    for key in ("http_proxy", "https_proxy", "all_proxy", "no_proxy"):
        value = str(proxy_env.get(key) or "").strip()
        if not value:
            continue
        env[key] = value
        env[key.upper()] = value
    return env


def _safe_load_json(path: Path, default: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    fallback = default if isinstance(default, dict) else {}
    if not path.exists():
        return dict(fallback)
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return dict(fallback)
    if not isinstance(payload, dict):
        return dict(fallback)
    return payload


def safe_load_json(path: Path, default: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    return _safe_load_json(path, default)


@contextmanager
def file_lock(lock_path: Path):
    lock_path.parent.mkdir(parents=True, exist_ok=True)
    lock_file = open(lock_path, "a+", encoding="utf-8")
    try:
        if fcntl is not None:
            fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
        yield
    finally:
        try:
            if fcntl is not None:
                fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
        finally:
            lock_file.close()


def bot_process_lock_path(user_data_root: Path) -> Path:
    return user_data_root / "cheapclaw" / "runtime" / "process_ctl.lock"


def fleet_web_runtime_paths(manifest_path: Path) -> Tuple[Path, Path, Path]:
    runtime_root = DEFAULT_RUNTIME_ROOT
    if manifest_path.exists():
        try:
            manifest = _load_json(manifest_path)
            runtime_raw = str(manifest.get("runtime_root") or "").strip()
            if runtime_raw and not _is_placeholder_text(runtime_raw):
                runtime_root = Path(runtime_raw).expanduser().resolve()
        except Exception:
            pass
    state_dir = (runtime_root / ".fleet_web").resolve()
    state_dir.mkdir(parents=True, exist_ok=True)
    return (
        state_dir / "fleet_web.pid",
        state_dir / "fleet_web.log",
        state_dir / "fleet_web.state.json",
    )


def fleet_web_lock_path(manifest_path: Path) -> Path:
    state_dir = fleet_web_runtime_paths(manifest_path)[0].parent
    return state_dir / "process_ctl.lock"


def is_pid_alive(pid: int) -> bool:
    if pid <= 0:
        return False
    try:
        os.kill(pid, 0)
    except OSError:
        return False
    return True


def iter_process_table() -> List[Tuple[int, str]]:
    try:
        proc = subprocess.run(
            ["ps", "-eo", "pid=,command="],
            capture_output=True,
            text=True,
            timeout=3,
            check=False,
        )
    except Exception:
        return []
    if proc.returncode != 0:
        return []
    rows: List[Tuple[int, str]] = []
    for raw in (proc.stdout or "").splitlines():
        line = str(raw or "").strip()
        if not line:
            continue
        parts = line.split(None, 1)
        if not parts:
            continue
        try:
            pid = int(parts[0])
        except Exception:
            continue
        command = parts[1] if len(parts) > 1 else ""
        rows.append((pid, command))
    return rows


def read_pid_command(pid: int) -> str:
    if pid <= 0:
        return ""
    try:
        proc = subprocess.run(
            ["ps", "-p", str(pid), "-o", "command="],
            capture_output=True,
            text=True,
            timeout=2,
            check=False,
        )
    except Exception:
        return ""
    if proc.returncode != 0:
        return ""
    return str(proc.stdout or "").strip()


def read_pid_arg_value(pid: int, arg_name: str) -> str:
    command = read_pid_command(pid)
    if not command:
        return ""
    try:
        tokens = shlex.split(command)
    except Exception:
        return ""
    for idx, token in enumerate(tokens):
        if token == arg_name and idx + 1 < len(tokens):
            return str(tokens[idx + 1]).strip()
        if token.startswith(f"{arg_name}="):
            return str(token.split("=", 1)[1]).strip()
    return ""


def all_needles_in_command(command: str, needles: List[str]) -> bool:
    if not command:
        return False
    return all(str(needle) in command for needle in needles)


def discover_pids_by_needles(needles: List[str]) -> List[int]:
    pids: List[int] = []
    for pid, command in iter_process_table():
        if all_needles_in_command(command, needles):
            pids.append(int(pid))
    return sorted(set(pids))


def bot_service_needles(bot_id: str, user_data_root: Path) -> List[str]:
    return [
        "cheapclaw_service.py",
        "--run-loop",
        f"--bot-id {str(bot_id)}",
        f"--user-data-root {str(user_data_root)}",
    ]


def is_pid_for_bot_service(pid: int, *, bot_id: str, user_data_root: Path) -> bool:
    if not is_pid_alive(pid):
        return False
    command = read_pid_command(pid)
    return all_needles_in_command(command, bot_service_needles(bot_id, user_data_root))


def discover_bot_service_pids(bot_id: str, user_data_root: Path) -> List[int]:
    return discover_pids_by_needles(bot_service_needles(bot_id, user_data_root))


def write_runtime_state_pid(user_data_root: Path, *, pid: int) -> None:
    state_path = user_data_root / "cheapclaw" / "runtime" / "state.json"
    state = _safe_load_json(state_path, {})
    bot_state = state.get("bot") if isinstance(state.get("bot"), dict) else {}
    bot_state["pid"] = int(pid)
    state["bot"] = bot_state
    _dump_json(state_path, state)


def read_bot_pid_from_runtime_state(user_data_root: Path) -> int:
    state_path = user_data_root / "cheapclaw" / "runtime" / "state.json"
    if not state_path.exists():
        return 0
    try:
        state_payload = _load_json(state_path)
    except Exception:
        return 0
    if not isinstance(state_payload, dict):
        return 0
    return int((((state_payload.get("bot") or {}) if isinstance(state_payload, dict) else {}).get("pid")) or 0)


def collect_bot_service_target_pids(bot_id: str, user_data_root: Path) -> List[int]:
    pids = discover_bot_service_pids(bot_id, user_data_root)
    state_pid = read_bot_pid_from_runtime_state(user_data_root)
    if is_pid_for_bot_service(state_pid, bot_id=bot_id, user_data_root=user_data_root):
        pids.append(int(state_pid))
    return sorted(set(pids))


def bot_start_conflicts_with_other_fleet(
    *,
    bot_id: str,
    user_data_root: Path,
    expected_fleet_config_path: Path,
) -> List[Dict[str, Any]]:
    expected = str(expected_fleet_config_path.expanduser().resolve())
    conflicts: List[Dict[str, Any]] = []
    for pid in discover_bot_service_pids(bot_id, user_data_root):
        detected_raw = read_pid_arg_value(pid, "--fleet-config-path")
        if not detected_raw:
            continue
        try:
            detected = str(Path(detected_raw).expanduser().resolve())
        except Exception:
            detected = str(detected_raw).strip()
        if detected and detected != expected:
            conflicts.append(
                {
                    "pid": int(pid),
                    "detected_fleet_config_path": detected,
                    "expected_fleet_config_path": expected,
                }
            )
    return conflicts


def fleet_web_needles(manifest_path: Path) -> List[str]:
    return [
        "fleet_web_console.py",
        f"--manifest {str(manifest_path)}",
    ]


def is_pid_for_fleet_web(pid: int, *, manifest_path: Path) -> bool:
    if not is_pid_alive(pid):
        return False
    command = read_pid_command(pid)
    if "fleet_web_console.py" not in command:
        return False
    detected_raw = read_pid_arg_value(pid, "--manifest")
    if not detected_raw:
        return True
    try:
        detected = str(Path(detected_raw).expanduser().resolve())
    except Exception:
        detected = str(detected_raw).strip()
    expected = str(manifest_path.expanduser().resolve())
    return bool(detected) and detected == expected


def discover_repo_bot_loop_pids() -> List[int]:
    return discover_pids_by_needles([str(SERVICE_PATH), "cheapclaw_service.py", "--run-loop"])


def discover_repo_fleet_web_pids() -> List[int]:
    return discover_pids_by_needles([str(FLEET_WEB_CONSOLE_PATH), "fleet_web_console.py"])


def collect_fleet_web_target_pids(manifest_path: Path) -> List[int]:
    pids = [int(pid) for pid in discover_repo_fleet_web_pids() if is_pid_for_fleet_web(int(pid), manifest_path=manifest_path)]
    pid_path, _log_path, _state_path = fleet_web_runtime_paths(manifest_path)
    pid = 0
    if pid_path.exists():
        try:
            pid = int((pid_path.read_text(encoding="utf-8") or "0").strip())
        except Exception:
            pid = 0
    if is_pid_for_fleet_web(pid, manifest_path=manifest_path):
        pids.append(int(pid))
    return sorted(set(pids))


def fleet_web_status_payload(manifest_path: Path) -> Dict[str, Any]:
    pid_path, log_path, state_path = fleet_web_runtime_paths(manifest_path)
    pid = 0
    if pid_path.exists():
        try:
            pid = int((pid_path.read_text(encoding="utf-8") or "0").strip())
        except Exception:
            pid = 0
    target_pids = collect_fleet_web_target_pids(manifest_path)
    if target_pids:
        pid = int(target_pids[0])
    else:
        pid = 0
    running = bool(target_pids)
    state = _safe_load_json(state_path, {})
    return {
        "status": "success",
        "manifest_path": str(manifest_path),
        "running": bool(running),
        "pid": int(pid),
        "pids": target_pids,
        "host": str(state.get("host") or ""),
        "port": int(state.get("port") or 0) if state.get("port") else 0,
        "url": str(state.get("url") or ""),
        "log_path": str(log_path),
        "state_path": str(state_path),
    }


def start_fleet_web(manifest_path: Path, *, host: str, port: int) -> Dict[str, Any]:
    with file_lock(fleet_web_lock_path(manifest_path)):
        pid_path, log_path, state_path = fleet_web_runtime_paths(manifest_path)
        existing = fleet_web_status_payload(manifest_path)
        if bool(existing.get("running")):
            return {**existing, "status": "already_running", "output": f"fleet web already running at {existing.get('url') or '-'}"}
        if not FLEET_WEB_CONSOLE_PATH.exists():
            return {"status": "error", "manifest_path": str(manifest_path), "error": f"fleet web script not found: {FLEET_WEB_CONSOLE_PATH}"}

        cmd = [
            sys.executable,
            str(FLEET_WEB_CONSOLE_PATH),
            "--manifest",
            str(manifest_path),
            "--host",
            str(host or "127.0.0.1"),
            "--port",
            str(int(port or 8787)),
        ]
        env = os.environ.copy()
        log_path.parent.mkdir(parents=True, exist_ok=True)
        std_log = open(log_path, "a", encoding="utf-8")
        process = subprocess.Popen(cmd, cwd=str(REPO_ROOT), env=env, stdout=std_log, stderr=std_log, start_new_session=True)
        std_log.close()
        pid_path.write_text(str(int(process.pid)), encoding="utf-8")
        payload = {
            "host": str(host or "127.0.0.1"),
            "port": int(port or 8787),
            "url": f"http://{str(host or '127.0.0.1')}:{int(port or 8787)}/dashboard",
            "manifest_path": str(manifest_path),
            "pid": int(process.pid),
            "started_at": int(time.time()),
        }
        _dump_json(state_path, payload)
        time.sleep(0.4)
        if process.poll() is not None:
            try:
                log_tail = "\n".join(log_path.read_text(encoding="utf-8", errors="ignore").splitlines()[-20:])
            except Exception:
                log_tail = ""
            return {
                "status": "error",
                "manifest_path": str(manifest_path),
                "running": False,
                "pid": int(process.pid),
                "host": payload["host"],
                "port": payload["port"],
                "url": payload["url"],
                "log_path": str(log_path),
                "error": "fleet web console exited immediately after start",
                "log_tail": log_tail,
            }
        return {
            "status": "started",
            "manifest_path": str(manifest_path),
            "running": True,
            "pid": int(process.pid),
            "host": payload["host"],
            "port": payload["port"],
            "url": payload["url"],
            "log_path": str(log_path),
        }


def stop_fleet_web(manifest_path: Path, *, timeout_sec: int = 8) -> Dict[str, Any]:
    with file_lock(fleet_web_lock_path(manifest_path)):
        pid_path, log_path, state_path = fleet_web_runtime_paths(manifest_path)
        pids = collect_fleet_web_target_pids(manifest_path)
        if not pids:
            status = fleet_web_status_payload(manifest_path)
            return {**status, "status": "already_stopped", "log_path": str(log_path)}
        for pid in pids:
            try:
                os.kill(pid, signal.SIGTERM)
            except ProcessLookupError:
                continue

        deadline = time.time() + max(1, int(timeout_sec))
        remaining: List[int] = list(pids)
        while time.time() < deadline:
            remaining = [pid for pid in pids if is_pid_for_fleet_web(pid, manifest_path=manifest_path)]
            if not remaining:
                break
            time.sleep(0.2)
        if remaining:
            try:
                for pid in remaining:
                    try:
                        os.kill(pid, signal.SIGKILL)
                    except ProcessLookupError:
                        continue
            except Exception:
                pass
        time.sleep(0.2)
        final_remaining = [pid for pid in pids if is_pid_for_fleet_web(pid, manifest_path=manifest_path)]
        try:
            pid_path.unlink(missing_ok=True)
        except Exception:
            pass
        if state_path.exists():
            state = _safe_load_json(state_path, {})
            state["stopped_at"] = int(time.time())
            _dump_json(state_path, state)
        return {
            "status": "stopped" if not final_remaining else "partial",
            "manifest_path": str(manifest_path),
            "pid": int(pids[0]),
            "pids": pids,
            "remaining_pids": final_remaining,
            "log_path": str(log_path),
        }


def resolve_bot_from_manifest(manifest_path: Path, bot_id: str) -> Tuple[Dict[str, Any], Dict[str, Any], Path, Path]:
    manifest = _load_json(manifest_path)
    llm_config_path = Path(_required_text(manifest.get("llm_config_path"), "llm_config_path", "<manifest>")).expanduser().resolve()
    runtime_root = Path(_required_text(manifest.get("runtime_root"), "runtime_root", "<manifest>")).expanduser().resolve()
    fleet_config_path = Path(str(manifest.get("fleet_config_path") or (runtime_root / "fleet.generated.json"))).expanduser().resolve()
    target: Dict[str, Any] | None = None
    for item in manifest.get("bots", []):
        if isinstance(item, dict) and str(item.get("bot_id") or "").strip() == bot_id:
            target = item
            break
    if target is None:
        raise SystemExit(f"[ERROR] bot_id not found in manifest: {bot_id}")
    user_data_root = Path(runtime_root) / str(target.get("bot_id") or bot_id)
    return manifest, target, user_data_root.expanduser().resolve(), llm_config_path


def list_manifest_bots(manifest_path: Path, *, enabled_only: bool = False) -> List[Dict[str, Any]]:
    manifest = _load_json(manifest_path)
    bots = [item for item in manifest.get("bots", []) if isinstance(item, dict)]
    if enabled_only:
        bots = [item for item in bots if bool(item.get("enabled", True))]
    return bots


def read_bot_runtime_snapshot(user_data_root: Path, *, bot_id: str) -> Dict[str, Any]:
    runtime_root = user_data_root / "cheapclaw" / "runtime"
    state_payload = _safe_load_json(runtime_root / "state.json")
    panel_payload = _safe_load_json(user_data_root / "cheapclaw" / "panel" / "panel.json")
    instructions_payload = _safe_load_json(user_data_root / "cheapclaw" / "monitor_instructions.json", {"version": 1, "instructions": []})
    instructions = instructions_payload.get("instructions")
    if not isinstance(instructions, list):
        instructions = []
    pending_count = sum(1 for item in instructions if isinstance(item, dict) and str(item.get("status") or "") == "pending")
    bot_runtime = state_payload.get("bot") if isinstance(state_payload.get("bot"), dict) else {}
    service_state = panel_payload.get("service_state") if isinstance(panel_payload.get("service_state"), dict) else {}
    state_pid = int(bot_runtime.get("pid") or 0)
    live_pids = discover_bot_service_pids(bot_id, user_data_root)
    pid = int(live_pids[0]) if live_pids else 0
    if pid <= 0 and is_pid_for_bot_service(state_pid, bot_id=bot_id, user_data_root=user_data_root):
        pid = int(state_pid)
    return {
        "pid": pid,
        "alive": bool(pid > 0),
        "pids": live_pids,
        "pending_instruction_count": pending_count,
        "main_agent_running": bool(service_state.get("main_agent_running")),
        "main_agent_last_started_at": str(service_state.get("main_agent_last_started_at") or ""),
        "watchdog_last_run_at": str(service_state.get("watchdog_last_run_at") or ""),
        "service_log_path": str(runtime_root / "cheapclaw_service.log"),
        "loop_log_path": str(runtime_root / "service.loop.log"),
    }


def status_single_bot_from_manifest(manifest_path: Path, bot_id: str) -> Dict[str, Any]:
    _manifest, target, user_data_root, _llm = resolve_bot_from_manifest(manifest_path, bot_id)
    runtime = read_bot_runtime_snapshot(user_data_root, bot_id=bot_id)
    return {
        "bot_id": str(target.get("bot_id") or bot_id),
        "display_name": str(target.get("display_name") or bot_id),
        "channel": str(target.get("channel") or ""),
        "enabled": bool(target.get("enabled", True)),
        "serve_webhooks": bool(target.get("serve_webhooks", False)),
        "user_data_root": str(user_data_root),
        **runtime,
    }


def status_all_bots_from_manifest(manifest_path: Path) -> Dict[str, Any]:
    bots = list_manifest_bots(manifest_path, enabled_only=False)
    statuses = []
    for item in bots:
        bot_id = str(item.get("bot_id") or "").strip()
        if not bot_id:
            continue
        statuses.append(status_single_bot_from_manifest(manifest_path, bot_id))
    return {
        "status": "success",
        "manifest_path": str(manifest_path),
        "bot_count": len(statuses),
        "running_count": sum(1 for item in statuses if item.get("alive")),
        "bots": statuses,
    }


def stop_single_bot_from_manifest(manifest_path: Path, *, bot_id: str, timeout_sec: int = 8) -> Dict[str, Any]:
    _manifest, _target, user_data_root, _llm = resolve_bot_from_manifest(manifest_path, bot_id)
    with file_lock(bot_process_lock_path(user_data_root)):
        pids = collect_bot_service_target_pids(bot_id, user_data_root)
        if not pids:
            stale_pid = read_bot_pid_from_runtime_state(user_data_root)
            if stale_pid:
                try:
                    write_runtime_state_pid(user_data_root, pid=0)
                except Exception:
                    pass
            return {"status": "already_stopped", "bot_id": bot_id, "pid": stale_pid, "pids": [], "user_data_root": str(user_data_root)}

        for pid in pids:
            try:
                os.kill(pid, signal.SIGTERM)
            except ProcessLookupError:
                continue

        deadline = time.time() + max(1, int(timeout_sec))
        remaining: List[int] = list(pids)
        while time.time() < deadline:
            remaining = [pid for pid in pids if is_pid_for_bot_service(pid, bot_id=bot_id, user_data_root=user_data_root)]
            if not remaining:
                try:
                    write_runtime_state_pid(user_data_root, pid=0)
                except Exception:
                    pass
                return {"status": "stopped", "bot_id": bot_id, "pid": int(pids[0]), "pids": pids, "user_data_root": str(user_data_root)}
            time.sleep(0.2)

        for pid in remaining:
            try:
                os.kill(pid, signal.SIGKILL)
            except ProcessLookupError:
                continue
        time.sleep(0.2)
        final_remaining = [pid for pid in pids if is_pid_for_bot_service(pid, bot_id=bot_id, user_data_root=user_data_root)]
        if not final_remaining:
            try:
                write_runtime_state_pid(user_data_root, pid=0)
            except Exception:
                pass
        return {
            "status": "killed" if not final_remaining else "partial",
            "bot_id": bot_id,
            "pid": int(pids[0]),
            "pids": pids,
            "remaining_pids": final_remaining,
            "user_data_root": str(user_data_root),
        }


def start_single_bot_from_manifest(manifest_path: Path, *, bot_id: str, poll_interval: int = 5) -> Dict[str, Any]:
    manifest, target, user_data_root, llm_config_path = resolve_bot_from_manifest(manifest_path, bot_id)
    runtime_root = Path(_required_text(manifest.get("runtime_root"), "runtime_root", "<manifest>")).expanduser().resolve()
    fleet_config_path = Path(str(manifest.get("fleet_config_path") or (runtime_root / "fleet.generated.json"))).expanduser().resolve()
    if not bool(target.get("enabled", True)):
        raise SystemExit(f"[ERROR] bot is disabled in manifest: {bot_id}")

    with file_lock(bot_process_lock_path(user_data_root)):
        conflicts = bot_start_conflicts_with_other_fleet(bot_id=bot_id, user_data_root=user_data_root, expected_fleet_config_path=fleet_config_path)
        if conflicts:
            conflict_pids = sorted(set(int(item.get("pid") or 0) for item in conflicts if int(item.get("pid") or 0) > 0))
            return {
                "status": "conflict",
                "bot_id": bot_id,
                "pid": int(conflict_pids[0]) if conflict_pids else 0,
                "pids": conflict_pids,
                "user_data_root": str(user_data_root),
                "fleet_config_path": str(fleet_config_path),
                "conflicts": conflicts,
                "error": "bot process is already running under a different fleet config; stop the conflicting process first",
            }
        current_pid = read_bot_pid_from_runtime_state(user_data_root)
        if is_pid_for_bot_service(current_pid, bot_id=bot_id, user_data_root=user_data_root):
            return {"status": "already_running", "bot_id": bot_id, "pid": current_pid, "user_data_root": str(user_data_root)}
        live_pids = discover_bot_service_pids(bot_id, user_data_root)
        if live_pids:
            try:
                write_runtime_state_pid(user_data_root, pid=int(live_pids[0]))
            except Exception:
                pass
            return {"status": "already_running", "bot_id": bot_id, "pid": int(live_pids[0]), "pids": live_pids, "user_data_root": str(user_data_root)}

        cmd = [
            sys.executable,
            str(SERVICE_PATH),
            "--user-data-root",
            str(user_data_root),
            "--llm-config-path",
            str(llm_config_path),
            "--bot-id",
            str(bot_id),
            "--bot-display-name",
            str(target.get("display_name") or bot_id),
            "--fleet-config-path",
            str(fleet_config_path),
            "--run-loop",
            "--poll-interval",
            str(max(1, int(poll_interval))),
        ]
        if bool(target.get("serve_webhooks", False)):
            cmd.extend(
                [
                    "--serve-webhooks",
                    "--host",
                    str(target.get("host") or "127.0.0.1"),
                    "--port",
                    str(int(target.get("port") or 8765)),
                ]
            )

        env = _child_process_env(manifest)
        log_dir = user_data_root / "cheapclaw" / "runtime"
        log_dir.mkdir(parents=True, exist_ok=True)
        std_log_path = log_dir / "service.loop.log"
        std_log = open(std_log_path, "a", encoding="utf-8")
        process = subprocess.Popen(cmd, cwd=str(REPO_ROOT), env=env, stdout=std_log, stderr=std_log, start_new_session=True)
        std_log.close()
        return {
            "status": "started",
            "bot_id": bot_id,
            "pid": int(process.pid),
            "user_data_root": str(user_data_root),
            "log_path": str(std_log_path),
            "fleet_config_path": str(fleet_config_path),
        }


def start_all_bots_from_manifest(manifest_path: Path, *, poll_interval: int = 5) -> Dict[str, Any]:
    bots = list_manifest_bots(manifest_path, enabled_only=True)
    results = []
    for item in bots:
        bot_id = str(item.get("bot_id") or "").strip()
        if not bot_id:
            continue
        results.append(start_single_bot_from_manifest(manifest_path, bot_id=bot_id, poll_interval=poll_interval))
    return {
        "status": "success",
        "manifest_path": str(manifest_path),
        "requested_bot_count": len(bots),
        "started_count": sum(1 for item in results if str(item.get("status") or "") == "started"),
        "already_running_count": sum(1 for item in results if str(item.get("status") or "") == "already_running"),
        "conflict_count": sum(1 for item in results if str(item.get("status") or "") == "conflict"),
        "results": results,
    }


def stop_all_bots_from_manifest(manifest_path: Path, *, timeout_sec: int = 8) -> Dict[str, Any]:
    bots = list_manifest_bots(manifest_path, enabled_only=False)
    results = []
    for item in bots:
        bot_id = str(item.get("bot_id") or "").strip()
        if not bot_id:
            continue
        results.append(stop_single_bot_from_manifest(manifest_path, bot_id=bot_id, timeout_sec=timeout_sec))
    return {
        "status": "success",
        "manifest_path": str(manifest_path),
        "requested_bot_count": len(bots),
        "stopped_count": sum(1 for item in results if str(item.get("status") or "") in {"stopped", "killed"}),
        "already_stopped_count": sum(1 for item in results if str(item.get("status") or "") == "already_stopped"),
        "partial_count": sum(1 for item in results if str(item.get("status") or "") == "partial"),
        "results": results,
    }


def terminate_pids(pids: List[int], *, timeout_sec: int = 8) -> Dict[str, Any]:
    targets = sorted(set(int(pid) for pid in pids if is_pid_alive(int(pid))))
    if not targets:
        return {"status": "already_stopped", "pids": [], "remaining_pids": []}
    for pid in targets:
        try:
            os.kill(pid, signal.SIGTERM)
        except ProcessLookupError:
            continue
    deadline = time.time() + max(1, int(timeout_sec))
    remaining = list(targets)
    while time.time() < deadline:
        remaining = [pid for pid in targets if is_pid_alive(pid)]
        if not remaining:
            break
        time.sleep(0.2)
    if remaining:
        for pid in remaining:
            try:
                os.kill(pid, signal.SIGKILL)
            except ProcessLookupError:
                continue
        time.sleep(0.2)
    final_remaining = [pid for pid in targets if is_pid_alive(pid)]
    return {"status": "stopped" if not final_remaining else "partial", "pids": targets, "remaining_pids": final_remaining}


def stop_fleet_web_fallback(*, timeout_sec: int = 8) -> Dict[str, Any]:
    payload = terminate_pids(discover_repo_fleet_web_pids(), timeout_sec=timeout_sec)
    return {
        "status": payload.get("status") or "already_stopped",
        "running": bool(payload.get("remaining_pids")),
        "mode": "manifest_missing_fallback",
        "manifest_path": "",
        "pid": int((payload.get("pids") or [0])[0]) if payload.get("pids") else 0,
        "pids": payload.get("pids") or [],
        "remaining_pids": payload.get("remaining_pids") or [],
        "log_path": "",
        "note": "manifest not found, stopped web by process scan",
    }


def stop_all_fallback_without_manifest(*, timeout_sec: int = 8) -> Dict[str, Any]:
    bot_payload = terminate_pids(discover_repo_bot_loop_pids(), timeout_sec=timeout_sec)
    web_payload = terminate_pids(discover_repo_fleet_web_pids(), timeout_sec=timeout_sec)
    return {
        "status": "success",
        "mode": "manifest_missing_fallback",
        "manifest_path": "",
        "bot_processes": bot_payload,
        "web_processes": web_payload,
        "note": "manifest not found, fallback stop by process scan",
    }
