"""Core/gateway/OTEL process lifecycle and Docker operations for CLI.

All subprocess-based Docker invocations are in clearly named functions
so they can be replaced with Docker SDK for Python in a single place.
"""

import json
import os
import re
import secrets
import shlex
import shutil
import signal
import subprocess
import sys
import time
import hashlib
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

from armorer.config import ConfigManager
from armorer import docker_runner
from armorer.runtime_capabilities import device_supports_llm_guard

from armorer.cli.common import armorer_config_dir, console, docker_daemon_unavailable, probe_docker_daemon, ui_confirm

_CORE_PROVIDER_ENV_KEYS = {
    "openai": "OPENAI_API_KEY",
    "anthropic": "ANTHROPIC_API_KEY",
    "gemini": "GEMINI_API_KEY",
    "openrouter": "OPENROUTER_API_KEY",
}

_SELF_DOCKER_IMAGE = "armorer-cli"
_UI_DOCKER_IMAGE = "armorer-ui"
_DOCKER_FINGERPRINT_LABEL = "armorer.build_fingerprint"
_DOCKER_IMAGE_REBUILD_PATHS: dict[str, tuple[str, ...]] = {
    _SELF_DOCKER_IMAGE: ("Dockerfile", "pyproject.toml", "uv.lock"),
    _UI_DOCKER_IMAGE: (
        "web/armorer-ui-selfhost/Dockerfile",
        "web/armorer-ui-selfhost/package.json",
        "web/armorer-ui-selfhost/next.config.ts",
        "web/armorer-ui-selfhost/postcss.config.mjs",
        "web/armorer-ui-selfhost/tailwind.config.ts",
        "web/armorer-ui-selfhost/tsconfig.json",
        "web/armorer-ui-selfhost/src/app/layout.tsx",
        "web/armorer-ui-selfhost/src/app/globals.css",
        "web/armorer-ui-selfhost/src/app/page.tsx",
        "web/armorer-ui-selfhost/src/app/api/bootstrap/route.ts",
        "web/armorer-ui-selfhost/src/lib/proxy.ts",
        "web/armorer-ui-selfhost/src/lib/ui-token.ts",
        "web/armorer-ui-selfhost/src/lib/authed-fetch.ts",
    ),
}
_UI_API_REBUILD_PATHS: tuple[str, ...] = (
    "src/armorer/web",
    "src/armorer/config.py",
    "src/armorer/core/response.py",
    "src/armorer/tools/orchestrator.py",
)
_LOCAL_UI_API_HOSTS = {"127.0.0.1", "localhost", "0.0.0.0", "::1", "::"}
_HOST_MANAGED_COMMANDS = {"install", "uninstall", "rebuild", "ui-api", "ui-token", "gateway"}


@dataclass
class SelfDockerImageStatus:
    exists: bool
    needs_rebuild: bool
    reason: str = ""
    current_fingerprint: str = ""
    image_fingerprint: str = ""


def _tail_detail(detail: str, *, limit: int = 4000) -> str:
    text = str(detail or "").strip()
    if len(text) <= limit:
        return text
    return f"...\n{text[-limit:]}"


def _print_raw_detail(detail: str, *, style: str = "dim", limit: int = 4000) -> None:
    text = _tail_detail(detail, limit=limit)
    if not text:
        return
    console.print(text, style=style, markup=False)


def _print_docker_build_failure(image_name: str, detail: str) -> None:
    console.print(f"[yellow]Failed to build {image_name}.[/yellow]")
    lowered = str(detail or "").lower()
    if "no space left on device" in lowered:
        console.print(
            "[bold yellow]Docker ran out of disk space during the build.[/bold yellow] "
            "Retry with `armorer rebuild --prune` or free Docker space with `docker system df` and prune commands."
        )
    _print_raw_detail(detail)


def should_skip_self_dockerize(argv: Optional[List[str]] = None) -> bool:
    args = [str(arg or "").strip() for arg in (argv or sys.argv[1:]) if str(arg or "").strip()]
    if not args:
        return False
    return args[0] in _HOST_MANAGED_COMMANDS


def ensure_task_signing_key(config_manager: Optional[ConfigManager] = None) -> tuple[str, bool]:
    cm = config_manager or ConfigManager()
    current = str(cm.get_key("ARMORER_TASK_SIGNING_KEY") or "").strip()
    if current:
        os.environ["ARMORER_TASK_SIGNING_KEY"] = current
        return current, False
    generated = secrets.token_hex(32)
    cm.set_key("ARMORER_TASK_SIGNING_KEY", generated)
    os.environ["ARMORER_TASK_SIGNING_KEY"] = generated
    return generated, True


def _container_env_value(container_name: str, env_name: str) -> str:
    try:
        result = subprocess.run(
            [
                "docker",
                "inspect",
                container_name,
                "--format",
                "{{range .Config.Env}}{{println .}}{{end}}",
            ],
            capture_output=True,
            text=True,
            timeout=10,
        )
    except Exception:
        return ""
    if result.returncode != 0:
        return ""
    prefix = f"{env_name}="
    for line in (result.stdout or "").splitlines():
        if line.startswith(prefix):
            return line[len(prefix):].strip()
    return ""


def _project_watch_paths(project_root: Path, rel_paths: tuple[str, ...]) -> list[Path]:
    paths: list[Path] = []
    for rel in rel_paths:
        path = Path(project_root) / rel
        if path.is_dir():
            for subpath in sorted(p for p in path.rglob("*") if p.is_file()):
                paths.append(subpath)
        elif path.exists() and path.is_file():
            paths.append(path)
    return paths


def _docker_rebuild_watch_paths(project_root: Path, image: str) -> list[Path]:
    return _project_watch_paths(project_root, _DOCKER_IMAGE_REBUILD_PATHS.get(image, ()))


def _docker_build_fingerprint(project_root: Path, image: str) -> str:
    digest = hashlib.sha256()
    for path in _docker_rebuild_watch_paths(project_root, image):
        rel = str(path.relative_to(project_root))
        try:
            data = path.read_bytes()
        except Exception:
            data = b""
        digest.update(rel.encode("utf-8"))
        digest.update(b"\0")
        digest.update(data)
        digest.update(b"\0")
    return digest.hexdigest()


def _ui_api_build_fingerprint(project_root: Path) -> str:
    digest = hashlib.sha256()
    for path in _project_watch_paths(project_root, _UI_API_REBUILD_PATHS):
        rel = str(path.relative_to(project_root))
        try:
            data = path.read_bytes()
        except Exception:
            data = b""
        digest.update(rel.encode("utf-8"))
        digest.update(b"\0")
        digest.update(data)
        digest.update(b"\0")
    return digest.hexdigest()


def _docker_image_fingerprint(image: str) -> str:
    try:
        result = subprocess.run(
            ["docker", "image", "inspect", image, "--format", f"{{{{index .Config.Labels \"{_DOCKER_FINGERPRINT_LABEL}\"}}}}"],
            capture_output=True,
            text=True,
        )
    except Exception:
        return ""
    if result.returncode != 0:
        return ""
    value = (result.stdout or "").strip()
    if value in {"", "<no value>"}:
        return ""
    return value


def docker_image_status(project_root: Optional[Path] = None, image: str = _SELF_DOCKER_IMAGE) -> SelfDockerImageStatus:
    root = Path(project_root or Path.cwd())
    try:
        inspect_result = subprocess.run(
            ["docker", "image", "inspect", image],
            capture_output=True,
            text=True,
        )
    except Exception:
        return SelfDockerImageStatus(exists=False, needs_rebuild=True, reason="Docker image inspection failed.")

    if inspect_result.returncode != 0:
        return SelfDockerImageStatus(exists=False, needs_rebuild=True, reason=f"Docker image `{image}` is missing.")

    current_fingerprint = _docker_build_fingerprint(root, image)
    image_fingerprint = _docker_image_fingerprint(image)
    if not image_fingerprint:
        return SelfDockerImageStatus(
            exists=True,
            needs_rebuild=True,
            reason=f"Docker image `{image}` predates rebuild tracking.",
            current_fingerprint=current_fingerprint,
            image_fingerprint=image_fingerprint,
        )
    if image_fingerprint != current_fingerprint:
        return SelfDockerImageStatus(
            exists=True,
            needs_rebuild=True,
            reason=f"Docker-relevant files changed since `{image}` was built.",
            current_fingerprint=current_fingerprint,
            image_fingerprint=image_fingerprint,
        )
    return SelfDockerImageStatus(
        exists=True,
        needs_rebuild=False,
        current_fingerprint=current_fingerprint,
        image_fingerprint=image_fingerprint,
    )


def self_docker_image_status(project_root: Optional[Path] = None, image: str = _SELF_DOCKER_IMAGE) -> SelfDockerImageStatus:
    return docker_image_status(project_root=project_root, image=image)


def _resolve_ui_api_token() -> str:
    existing = str(os.getenv("ARMORER_UI_API_TOKEN", "")).strip()
    if existing:
        return existing

    token_path = armorer_config_dir() / "ui-api-token"
    try:
        cached = token_path.read_text(encoding="utf-8").strip()
    except FileNotFoundError:
        cached = ""
    except Exception:
        cached = ""

    if not cached:
        cached = secrets.token_urlsafe(24)
        token_path.parent.mkdir(parents=True, exist_ok=True)
        token_path.write_text(cached + "\n", encoding="utf-8")
        try:
            os.chmod(token_path, 0o600)
        except Exception:
            pass

    os.environ["ARMORER_UI_API_TOKEN"] = cached
    return cached


def _parse_ui_api_base(api_base: str) -> tuple[bool, str, str, int, str]:
    parsed = urlparse(api_base or "http://127.0.0.1:8788")
    scheme = parsed.scheme or "http"
    bind_host = parsed.hostname or "127.0.0.1"
    port = parsed.port or 8788
    is_local = scheme in {"http", "https"} and bind_host in _LOCAL_UI_API_HOSTS
    connect_host = "127.0.0.1" if bind_host in _LOCAL_UI_API_HOSTS else bind_host
    normalized = f"{scheme}://{connect_host}:{port}"
    return is_local, bind_host, connect_host, port, normalized


def _effective_ui_api_bind_host(bind_host: str) -> str:
    normalized = str(bind_host or "").strip() or "127.0.0.1"
    if normalized in _LOCAL_UI_API_HOSTS and sys.platform.startswith("linux"):
        # The Dockerized self-hosted UI reaches the host ui-api over the bridge on Linux.
        # Binding only to 127.0.0.1 makes that unreachable from the container.
        return "0.0.0.0"
    return normalized


def list_running_host_ui_api_pids(exclude_pid: Optional[int] = None) -> List[int]:
    current_pid = int(exclude_pid or 0)
    try:
        result = subprocess.run(
            ["ps", "-axo", "pid=,args="],
            capture_output=True,
            text=True,
        )
    except Exception:
        return []
    if result.returncode != 0:
        return []
    pids: List[int] = []
    for raw_line in (result.stdout or "").splitlines():
        line = raw_line.strip()
        if not line:
            continue
        pid_text, _, args = line.partition(" ")
        try:
            pid = int(pid_text.strip())
        except Exception:
            continue
        normalized_args = " ".join(args.split())
        if "uvicorn armorer.web.api:app" not in normalized_args:
            continue
        if current_pid and pid == current_pid:
            continue
        pids.append(pid)
    return pids


def _running_ui_api_base_from_pid(pid: int) -> str:
    try:
        result = subprocess.run(
            ["ps", "-p", str(pid), "-o", "args="],
            capture_output=True,
            text=True,
        )
    except Exception:
        return ""
    if result.returncode != 0:
        return ""
    args_text = (result.stdout or "").strip()
    if not args_text:
        return ""
    try:
        parts = shlex.split(args_text)
    except Exception:
        parts = args_text.split()
    host = "127.0.0.1"
    port = "8788"
    for idx, part in enumerate(parts):
        if part == "--host" and idx + 1 < len(parts):
            host = str(parts[idx + 1] or "").strip() or host
        elif part.startswith("--host="):
            host = part.split("=", 1)[1].strip() or host
        elif part == "--port" and idx + 1 < len(parts):
            port = str(parts[idx + 1] or "").strip() or port
        elif part.startswith("--port="):
            port = part.split("=", 1)[1].strip() or port
    is_local, _bind_host, connect_host, parsed_port, normalized = _parse_ui_api_base(f"http://{host}:{port}")
    if not is_local:
        return ""
    return normalized


def resolve_managed_host_ui_api_base(explicit_api_base: str = "") -> str:
    candidate = str(explicit_api_base or "").strip()
    if candidate:
        is_local, _bind_host, _connect_host, _port, normalized = _parse_ui_api_base(candidate)
        if is_local:
            return normalized
    for pid in list_running_host_ui_api_pids():
        discovered = _running_ui_api_base_from_pid(pid)
        if discovered:
            return discovered
    return "http://127.0.0.1:8788"


def _read_text_file(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8").strip()
    except Exception:
        return ""


def _terminate_pid(pid: int, *, wait_s: float = 3.0) -> None:
    if not is_pid_alive(pid):
        return
    try:
        os.kill(pid, signal.SIGTERM)
    except Exception:
        return
    deadline = time.time() + wait_s
    while time.time() < deadline:
        if not is_pid_alive(pid):
            return
        time.sleep(0.1)
    try:
        os.kill(pid, signal.SIGKILL)
    except Exception:
        pass


def stop_host_ui_api_process(api_base: str = "http://127.0.0.1:8788") -> None:
    is_local, _bind_host, _connect_host, _port, _normalized = _parse_ui_api_base(api_base)
    if not is_local:
        return

    config_dir = armorer_config_dir()
    pid_file = config_dir / "ui-api.pid"
    fp_file = config_dir / "ui-api.fingerprint"
    lock_file = config_dir / "ui-api.lock"

    with advisory_lock(lock_file):
        known_pids: list[int] = []
        try:
            pid = int(_read_text_file(pid_file) or "0")
        except Exception:
            pid = 0
        if pid:
            known_pids.append(pid)
        for running_pid in list_running_host_ui_api_pids(exclude_pid=pid):
            if running_pid not in known_pids:
                known_pids.append(running_pid)
        for running_pid in known_pids:
            _terminate_pid(running_pid)
        for path in (pid_file, fp_file):
            try:
                path.unlink()
            except Exception:
                pass


def _ensure_host_ui_api_process(api_base: str, *, force_restart: bool = False, project_root: Optional[Path] = None) -> bool:
    is_local, bind_host, connect_host, port, normalized = _parse_ui_api_base(api_base)
    scheme = urlparse(normalized).scheme or "http"
    if not is_local:
        return False

    status_url = f"{scheme}://{connect_host}:{port}/api/status"
    root = Path(project_root or Path.cwd())
    config_dir = armorer_config_dir()
    config_dir.mkdir(parents=True, exist_ok=True)
    pid_file = config_dir / "ui-api.pid"
    fp_file = config_dir / "ui-api.fingerprint"
    lock_file = config_dir / "ui-api.lock"
    log_path = config_dir / "logs" / "ui-api.log"
    current_fingerprint = _ui_api_build_fingerprint(root)

    def _reachable() -> bool:
        try:
            req = Request(status_url, method="GET")
            with urlopen(req, timeout=1.0) as resp:
                return int(getattr(resp, "status", 200) or 200) < 500
        except Exception:
            return False

    log_dir = config_dir / "logs"
    log_dir.mkdir(parents=True, exist_ok=True)
    env = os.environ.copy()
    env["ARMORER_UI_API_TOKEN"] = _resolve_ui_api_token()
    spawn_bind_host = _effective_ui_api_bind_host(bind_host)

    with advisory_lock(lock_file):
        existing_fingerprint = _read_text_file(fp_file)
        managed_pid = 0
        try:
            managed_pid = int(_read_text_file(pid_file) or "0")
        except Exception:
            managed_pid = 0

        if (
            not force_restart
            and managed_pid
            and is_pid_alive(managed_pid)
            and existing_fingerprint == current_fingerprint
            and _reachable()
        ):
            return True

        running_pids = list_running_host_ui_api_pids(exclude_pid=managed_pid)
        if managed_pid and is_pid_alive(managed_pid):
            _terminate_pid(managed_pid)
        for running_pid in running_pids:
            _terminate_pid(running_pid)
        for path in (pid_file, fp_file):
            try:
                path.unlink()
            except Exception:
                pass

        try:
            with open(log_path, "a", encoding="utf-8") as logf:
                proc = subprocess.Popen(
                    [
                        sys.executable,
                        "-m",
                        "uvicorn",
                        "armorer.web.api:app",
                        "--host",
                        spawn_bind_host,
                        "--port",
                        str(port),
                    ],
                    cwd=str(root),
                    stdout=logf,
                    stderr=logf,
                    stdin=subprocess.DEVNULL,
                    start_new_session=True,
                    env=env,
                )
        except Exception:
            return False

        try:
            pid_file.write_text(str(proc.pid))
            fp_file.write_text(current_fingerprint)
        except Exception:
            pass

    for _ in range(20):
        time.sleep(0.5)
        if _reachable():
            return True
    return False


def inspect_named_container(name: str) -> Dict[str, Any]:
    """Return container state for the given name (exists, running, status)."""
    result = docker_runner.docker_ps(
        args=[
            "-a",
            "--filter",
            f"name=^{name}$",
            "--format",
            "{{.Names}}\t{{.Status}}",
        ]
    )
    if not result.ok:
        return {"exists": False, "running": False, "status": ""}
    for raw_line in (result.stdout or "").splitlines():
        line = raw_line.strip()
        if not line:
            continue
        container_name, _, status = line.partition("\t")
        if container_name.strip() != name:
            continue
        status_text = status.strip()
        return {
            "exists": True,
            "running": status_text.startswith("Up"),
            "status": status_text,
        }
    return {"exists": False, "running": False, "status": ""}


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


def _process_matches_armorer_config_dir(pid: int, config_dir: Optional[Path] = None) -> bool:
    target_dir = Path(config_dir or armorer_config_dir())
    try:
        result = subprocess.run(
            ["ps", "eww", "-p", str(pid)],
            capture_output=True,
            text=True,
        )
    except Exception:
        return True
    if result.returncode != 0:
        return False
    output = " ".join((result.stdout or "").split())
    home_match = re.search(r"\bHOME=([^ ]+)", output)
    if not home_match:
        return True
    process_home = Path(home_match.group(1)).expanduser()
    return (process_home / ".armorer") == target_dir


def list_running_telegram_gateway_pids(exclude_pid: Optional[int] = None, config_dir: Optional[Path] = None) -> List[int]:
    current_pid = int(exclude_pid or 0)
    target_dir = Path(config_dir or armorer_config_dir())
    try:
        result = subprocess.run(
            ["ps", "-axo", "pid=,args="],
            capture_output=True,
            text=True,
        )
    except Exception:
        return []
    if result.returncode != 0:
        return []
    pids: List[int] = []
    for raw_line in (result.stdout or "").splitlines():
        line = raw_line.strip()
        if not line:
            continue
        pid_text, _, args = line.partition(" ")
        try:
            pid = int(pid_text.strip())
        except Exception:
            continue
        normalized_args = " ".join(args.split())
        if "-m armorer.main gateway telegram" not in normalized_args and "src/armorer/main.py gateway telegram" not in normalized_args:
            continue
        if current_pid and pid == current_pid:
            continue
        if not _process_matches_armorer_config_dir(pid, target_dir):
            continue
        pids.append(pid)
    return pids


def list_running_host_core_pids(exclude_pid: Optional[int] = None) -> List[int]:
    current_pid = int(exclude_pid or 0)
    try:
        result = subprocess.run(
            ["ps", "-axo", "pid=,args="],
            capture_output=True,
            text=True,
        )
    except Exception:
        return []
    if result.returncode != 0:
        return []
    pids: List[int] = []
    for raw_line in (result.stdout or "").splitlines():
        line = raw_line.strip()
        if not line:
            continue
        pid_text, _, args = line.partition(" ")
        try:
            pid = int(pid_text.strip())
        except Exception:
            continue
        normalized_args = " ".join(args.split())
        if "src/armorer/core.py" not in normalized_args:
            continue
        if current_pid and pid == current_pid:
            continue
        pids.append(pid)
    return pids


@contextmanager
def advisory_lock(lock_path: Path):
    lock_path.parent.mkdir(parents=True, exist_ok=True)
    handle = lock_path.open("a+")
    try:
        try:
            import fcntl

            fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
        except Exception:
            pass
        yield
    finally:
        try:
            try:
                import fcntl

                fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
            except Exception:
                pass
            handle.close()
        except Exception:
            pass


def release_owned_pid_file(pid_file: Path, pid: int) -> None:
    try:
        if pid_file.exists() and pid_file.read_text().strip() == str(pid):
            pid_file.unlink()
    except Exception:
        pass


def claim_telegram_gateway_singleton(config_dir: Path, *, pid: Optional[int] = None) -> Tuple[bool, Optional[int]]:
    owner_pid = int(pid or os.getpid())
    pid_file = config_dir / "gateway-telegram.pid"
    lock_file = config_dir / "gateway-telegram.lock"

    with advisory_lock(lock_file):
        if pid_file.exists():
            try:
                existing_pid = int(pid_file.read_text().strip() or "0")
            except Exception:
                existing_pid = 0
            if existing_pid and existing_pid != owner_pid and is_pid_alive(existing_pid):
                return False, existing_pid
            if existing_pid != owner_pid:
                try:
                    pid_file.unlink()
                except Exception:
                    pass

        running_pids = list_running_telegram_gateway_pids(exclude_pid=owner_pid)
        if running_pids:
            active_pid = running_pids[0]
            try:
                pid_file.write_text(str(active_pid))
            except Exception:
                pass
            return False, active_pid

        pid_file.write_text(str(owner_pid))
        return True, owner_pid


def core_provider_env(config_manager: ConfigManager) -> Dict[str, str]:
    provider = (config_manager.get_key("MODEL_PROVIDER") or "openai").strip().lower() or "openai"
    env = {
        "MODEL_PROVIDER": provider,
        "Armorer_MODEL": config_manager.get_key("Armorer_MODEL") or "",
    }
    if provider == "ollama":
        base_url = config_manager.get_key("OLLAMA_BASE_URL")
        if base_url:
            env["OLLAMA_BASE_URL"] = base_url
        return env
    key_name = _CORE_PROVIDER_ENV_KEYS.get(provider)
    if key_name:
        env[key_name] = config_manager.get_key(key_name) or ""
    return env


def ensure_host_core_process() -> bool:
    """Ensure a host Python Armorer core process is running."""
    config_dir = armorer_config_dir()
    config_dir.mkdir(parents=True, exist_ok=True)
    pid_file = config_dir / "core-host.pid"
    log_file = config_dir / "core-host.log"
    lock_file = config_dir / "core-host.lock"

    with advisory_lock(lock_file):
        if pid_file.exists():
            try:
                pid = int(pid_file.read_text().strip() or "0")
            except Exception:
                pid = 0
            if is_pid_alive(pid):
                return True
            try:
                pid_file.unlink()
            except Exception:
                pass

        running_pids = list_running_host_core_pids()
        if running_pids:
            try:
                pid_file.write_text(str(running_pids[0]))
            except Exception:
                pass
            return True

        env = os.environ.copy()
        env.setdefault("PYTHONPATH", str(Path.cwd() / "src"))
        with open(log_file, "a") as logh:
            proc = subprocess.Popen(
                [sys.executable, "src/armorer/core.py"],
                stdout=logh,
                stderr=logh,
                stdin=subprocess.DEVNULL,
                env=env,
                start_new_session=True,
                cwd=str(Path.cwd()),
            )
        pid_file.write_text(str(proc.pid))
    return True


def stop_host_core_process(config_dir: Optional[Path] = None) -> bool:
    target_config_dir = Path(config_dir or armorer_config_dir())
    pid_file = target_config_dir / "core-host.pid"
    lock_file = target_config_dir / "core-host.lock"

    stopped = False
    with advisory_lock(lock_file):
        known_pids: list[int] = []
        try:
            pid = int(_read_text_file(pid_file) or "0")
        except Exception:
            pid = 0
        if pid:
            known_pids.append(pid)
        for running_pid in list_running_host_core_pids(exclude_pid=pid):
            if running_pid not in known_pids:
                known_pids.append(running_pid)
        for running_pid in known_pids:
            if is_pid_alive(running_pid):
                stopped = True
            _terminate_pid(running_pid)
        try:
            pid_file.unlink()
        except Exception:
            pass
    return stopped


def _proactive_settings() -> Dict[str, Any]:
    from armorer.cli import telegram_admin as telegram_admin_cli

    return telegram_admin_cli.proactive_settings(armorer_config_dir())


def ensure_telegram_gateway_autostart() -> None:
    if os.path.exists("/.dockerenv"):
        return
    if os.getenv("ARMORER_DISABLE_GATEWAY_AUTOSTART") == "1":
        return
    cm = ConfigManager()
    signing_key, _ = ensure_task_signing_key(cm)
    enabled = (cm.get_key("ARMORER_GATEWAY_TELEGRAM_ENABLED") or "true").strip().lower()
    if enabled in {"0", "false", "off", "no"}:
        return
    token = cm.get_armorer_telegram_bot_token() or os.getenv("ARMORER_TELEGRAM_BOT_TOKEN") or os.getenv("TELEGRAM_BOT_TOKEN")
    if not token:
        return
    config_dir = armorer_config_dir()
    try:
        config_dir.mkdir(parents=True, exist_ok=True)
    except PermissionError:
        console.print("[dim]Skipping Telegram gateway autostart: Armorer config dir is not writable.[/dim]")
        return
    pid_file = config_dir / "gateway-telegram.pid"
    log_file = config_dir / "gateway-telegram.log"
    lock_file = config_dir / "gateway-telegram.lock"

    with advisory_lock(lock_file):
        if pid_file.exists():
            try:
                pid = int(pid_file.read_text().strip() or "0")
            except Exception:
                pid = 0
            if is_pid_alive(pid):
                return
            try:
                pid_file.unlink()
            except Exception:
                pass

        running_pids = list_running_telegram_gateway_pids()
        if running_pids:
            try:
                pid_file.write_text(str(running_pids[0]))
            except Exception:
                pass
            return

        env = os.environ.copy()
        env["ARMORER_DISABLE_GATEWAY_AUTOSTART"] = "1"
        env["ARMORER_TELEGRAM_BOT_TOKEN"] = token
        env["TELEGRAM_BOT_TOKEN"] = token
        env["ARMORER_TASK_SIGNING_KEY"] = signing_key
        env.setdefault("PYTHONPATH", str(Path.cwd() / "src"))
        try:
            with open(log_file, "a") as logh:
                proc = subprocess.Popen(
                    [sys.executable, "-m", "armorer.main", "gateway", "telegram"],
                    stdout=logh,
                    stderr=logh,
                    stdin=subprocess.DEVNULL,
                    env=env,
                    start_new_session=True,
                    cwd=str(Path.cwd()),
                )
            pid_file.write_text(str(proc.pid))
        except PermissionError:
            console.print("[dim]Skipping Telegram gateway autostart: Armorer config dir is not writable.[/dim]")
            return
    console.print("[dim]📡 Telegram gateway auto-started.[/dim]")


def ensure_telegram_gateway_restarted() -> bool:
    stop_telegram_gateway_process()
    ensure_telegram_gateway_autostart()
    pids = list_running_telegram_gateway_pids()
    if not pids:
        return False
    config_dir = armorer_config_dir()
    pid_file = config_dir / "gateway-telegram.pid"
    try:
        pid_file.write_text(str(pids[0]))
    except Exception:
        pass
    for extra_pid in pids[1:]:
        _terminate_pid(extra_pid)
    return True


def stop_telegram_gateway_process(config_dir: Optional[Path] = None) -> bool:
    target_config_dir = Path(config_dir or armorer_config_dir())
    pid_file = target_config_dir / "gateway-telegram.pid"
    lock_file = target_config_dir / "gateway-telegram.lock"

    stopped = False
    with advisory_lock(lock_file):
        known_pids: list[int] = []
        try:
            pid = int(_read_text_file(pid_file) or "0")
        except Exception:
            pid = 0
        if pid:
            known_pids.append(pid)
        for running_pid in list_running_telegram_gateway_pids(exclude_pid=pid, config_dir=target_config_dir):
            if running_pid not in known_pids:
                known_pids.append(running_pid)
        for running_pid in known_pids:
            if is_pid_alive(running_pid):
                stopped = True
            _terminate_pid(running_pid)
        try:
            pid_file.unlink()
        except Exception:
            pass
    return stopped


def restart_telegram_gateway_process() -> tuple[bool, str]:
    cm = ConfigManager()
    signing_key, generated_signing_key = ensure_task_signing_key(cm)
    enabled = (cm.get_key("ARMORER_GATEWAY_TELEGRAM_ENABLED") or "true").strip().lower()
    token = str(
        cm.get_armorer_telegram_bot_token()
        or os.getenv("ARMORER_TELEGRAM_BOT_TOKEN")
        or os.getenv("TELEGRAM_BOT_TOKEN")
        or ""
    ).strip()

    core_state = inspect_named_container("armorer-core")
    core_needs_restart = False
    if core_state.get("running"):
        if _container_env_value("armorer-core", "ARMORER_TASK_SIGNING_KEY") != signing_key:
            core_needs_restart = True
    else:
        host_pid_file = armorer_config_dir() / "core-host.pid"
        try:
            host_pid = int(host_pid_file.read_text().strip() or "0")
        except Exception:
            host_pid = 0
        if host_pid and generated_signing_key:
            core_needs_restart = True

    if core_needs_restart:
        stop_host_core_process()
        if core_state.get("exists"):
            docker_runner.docker_rm(["armorer-core"])
        ensure_armorer_core()

    if enabled in {"0", "false", "off", "no"}:
        stop_telegram_gateway_process()
        return True, "Telegram gateway stopped."
    if not token:
        return False, "Telegram bot token is missing, so Armorer could not restart the Telegram gateway."

    if ensure_telegram_gateway_restarted():
        return True, "Telegram gateway restarted."
    return False, "Armorer saved the Telegram settings, but could not restart the Telegram gateway."


def write_otel_collector_config(config_dir: Path) -> Path:
    otel_dir = config_dir / "otel"
    otel_dir.mkdir(parents=True, exist_ok=True)
    events_dir = config_dir / "otel_events"
    events_dir.mkdir(parents=True, exist_ok=True)
    try:
        os.chmod(events_dir, 0o777)
    except Exception:
        pass
    cfg_path = otel_dir / "collector-config.yaml"
    cfg = """
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
processors:
  batch: {}
exporters:
  file/armorer:
    path: /var/lib/armorer-otel/otel-events.jsonl
service:
  pipelines:
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [file/armorer]
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [file/armorer]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [file/armorer]
""".strip()
    cfg_path.write_text(cfg + "\n")
    return cfg_path


def ensure_otel_bridge_autostart() -> None:
    if os.path.exists("/.dockerenv"):
        return
    if os.getenv("ARMORER_DISABLE_OTEL_AUTOSTART") == "1":
        return
    cm = ConfigManager()
    enabled = (cm.get_key("ARMORER_OTEL_COLLECTOR_ENABLED") or "true").strip().lower()
    if enabled in {"0", "false", "off", "no"}:
        return
    config_dir = armorer_config_dir()
    try:
        cfg_path = write_otel_collector_config(config_dir)
    except PermissionError:
        console.print("[dim]Skipping OTEL bridge autostart: Armorer config dir is not writable.[/dim]")
        return
    events_dir = config_dir / "otel_events"
    port = int((cm.get_key("ARMORER_OTEL_COLLECTOR_PORT") or "4318").strip() or "4318")
    image = (cm.get_key("ARMORER_OTEL_COLLECTOR_IMAGE") or "otel/opentelemetry-collector-contrib:0.120.0").strip()
    name = "armorer-otel-collector"

    ps = subprocess.run(
        ["docker", "ps", "--filter", f"name={name}", "--format", "{{.Names}}"],
        capture_output=True,
        text=True,
    )
    if name in (ps.stdout or ""):
        return

    docker_runner.docker_rm([name])
    run_cmd = [
        "docker", "run", "-d",
        "--name", name,
        "--restart", "unless-stopped",
        "--user", "0:0",
        "-p", f"127.0.0.1:{port}:4318",
        "-v", f"{cfg_path}:/etc/otelcol-contrib/config.yaml:ro",
        "-v", f"{events_dir}:/var/lib/armorer-otel",
        image,
        "--config=/etc/otelcol-contrib/config.yaml",
    ]
    result = subprocess.run(run_cmd, capture_output=True, text=True)
    if result.returncode != 0:
        console.print(f"[yellow]⚠️ OTEL bridge autostart failed: {(result.stderr or result.stdout).strip()}[/yellow]")
        return
    cm.set_key("ARMORER_OTEL_ENDPOINT", f"http://127.0.0.1:{port}")
    console.print("[dim]📈 OTEL collector bridge auto-started.[/dim]")


def ensure_self_docker_image(project_root: Optional[Path] = None, *, force_rebuild: Optional[bool] = None) -> bool:
    root = Path(project_root or Path.cwd())
    rebuild = force_rebuild
    if rebuild is None:
        rebuild = str(os.getenv("ARMORER_REBUILD_IMAGE", "")).strip().lower() in {"1", "true", "yes", "on"}
    status = docker_image_status(root, _SELF_DOCKER_IMAGE)
    if not rebuild and status.exists and not status.needs_rebuild:
        return True
    if not rebuild and status.needs_rebuild:
        if sys.stdin.isatty() and sys.stdout.isatty():
            rebuild = ui_confirm(f"{status.reason} Rebuild the Armorer sandbox image now?", default=True)
            if not rebuild:
                console.print(f"[yellow]Reusing existing sandbox image `{_SELF_DOCKER_IMAGE}` even though it may be stale.[/yellow]")
                return True
        else:
            rebuild = True

    build_cmd = [
        "docker",
        "build",
        "-t",
        _SELF_DOCKER_IMAGE,
        "--build-arg",
        f"ARMORER_ENABLE_LLM_GUARD={'1' if device_supports_llm_guard() else '0'}",
    ]
    fingerprint = _docker_build_fingerprint(root, _SELF_DOCKER_IMAGE)
    if fingerprint:
        build_cmd.extend(["--label", f"{_DOCKER_FINGERPRINT_LABEL}={fingerprint}"])
    build_cmd.append(".")
    build_result = subprocess.run(
        build_cmd,
        cwd=root,
        capture_output=True,
        text=True,
    )
    if build_result.returncode == 0:
        return True
    detail = ((build_result.stderr or "") + "\n" + (build_result.stdout or "")).strip()
    _print_docker_build_failure("Armorer sandbox image", detail)
    return False


def best_effort_core_autostarts() -> None:
    for action in (ensure_otel_bridge_autostart, ensure_telegram_gateway_autostart):
        try:
            action()
        except Exception:
            continue


def ensure_armorer_core() -> None:
    """Ensure the Armorer Core background service is running and healthy."""
    if os.path.exists("/.dockerenv"):
        return

    core_name = "armorer-core"
    is_dev = os.getenv("ARMORER_DEV") == "1"
    config_manager = ConfigManager()
    task_signing_key, generated_signing_key = ensure_task_signing_key(config_manager)

    host_pid = armorer_config_dir() / "core-host.pid"
    if host_pid.exists():
        try:
            host_core_pid = int(host_pid.read_text().strip() or "0")
        except Exception:
            host_core_pid = 0
        if is_pid_alive(host_core_pid):
            if generated_signing_key:
                stop_host_core_process()
            else:
                best_effort_core_autostarts()
                return

    core_state = inspect_named_container(core_name)
    image_status = docker_image_status(Path.cwd(), _SELF_DOCKER_IMAGE)
    if core_state.get("running") and not is_dev and not image_status.needs_rebuild:
        if _container_env_value(core_name, "ARMORER_TASK_SIGNING_KEY") == task_signing_key:
            best_effort_core_autostarts()
            return
    if core_state.get("exists"):
        remove_result = docker_runner.docker_rm([core_name])
        if not remove_result.ok:
            detail = (remove_result.stderr or remove_result.stdout).strip()
            console.print(f"[bold red]Failed to remove stale '{core_name}' container.[/bold red]")
            if detail:
                console.print(f"[dim]{detail[:600]}[/dim]")
            raise SystemExit(1)
        if image_status.needs_rebuild:
            console.print(f"[dim]{image_status.reason} Restarting Armorer Core with a rebuilt sandbox image.[/dim]")

    project_root = Path.cwd()
    host_root = os.getenv("HOST_PROJECT_ROOT", str(project_root))
    host_config = os.getenv("HOST_ARMORER_CONFIG", str(armorer_config_dir()))
    host_installed = str(Path(host_root) / "installed")

    if not ensure_self_docker_image(force_rebuild=(is_dev or image_status.needs_rebuild)):
        console.print("[yellow]⚠ Docker image build failed. Falling back to host core process.[/yellow]")
        ensure_host_core_process()
        best_effort_core_autostarts()
        return

    docker_version = "1.41"
    try:
        ver_res = subprocess.run(
            ["docker", "version", "--format", "{{.Server.APIVersion}}"],
            capture_output=True,
            text=True,
        )
        if ver_res.returncode == 0:
            docker_version = ver_res.stdout.strip()
    except Exception:
        pass

    provider_env = core_provider_env(config_manager)
    proactive = _proactive_settings()

    with console.status("[bold cyan]Starting Armorer Core Service..."):
        cmd = [
            "docker", "run", "-d", "--name", core_name,
            "-v", "/var/run/docker.sock:/var/run/docker.sock",
            "-v", f"{project_root / 'src'}:/app/src:ro",
            "-v", f"{host_installed}:/app/installed:rw",
            "-v", f"{host_config}:/root/.armorer:rw",
            "-e", f"DOCKER_API_VERSION={docker_version}",
            "-e", f"HOST_PROJECT_ROOT={host_root}",
            "-e", f"MODEL_PROVIDER={provider_env.get('MODEL_PROVIDER', 'openai')}",
            "-e", f"Armorer_MODEL={provider_env.get('Armorer_MODEL', '')}",
            "-e", f"LANGFUSE_ENABLED={config_manager.get_key('LANGFUSE_ENABLED') or 'false'}",
            "-e", f"LANGFUSE_BASE_URL={config_manager.get_key('LANGFUSE_BASE_URL') or 'https://cloud.langfuse.com'}",
            "-e", f"LANGFUSE_PUBLIC_KEY={config_manager.find_vault_key('LANGFUSE_PUBLIC_KEY') or ''}",
            "-e", f"LANGFUSE_SECRET_KEY={config_manager.find_vault_key('LANGFUSE_SECRET_KEY') or ''}",
            "-e", f"TELEGRAM_BOT_TOKEN={config_manager.find_vault_key('TELEGRAM_BOT_TOKEN') or ''}",
            "-e", f"ARMORER_TASK_SIGNING_KEY={task_signing_key}",
            "-e", f"TELEGRAM_ALERT_CHAT_ID={proactive.get('telegram_alert_chat_id') or config_manager.get_key('TELEGRAM_ALERT_CHAT_ID') or ''}",
            "-e", f"ARMORER_TELEGRAM_ADMIN_CHAT_IDS={','.join(proactive.get('telegram_admin_chat_ids', []) or [])}",
            "-e", f"ARMORER_TELEGRAM_ADMIN_USER_IDS={','.join(proactive.get('telegram_admin_user_ids', []) or [])}",
            "-e", f"ARMORER_TELEGRAM_ALLOW_UNCONFIGURED_ADMINS={str(bool(proactive.get('telegram_allow_unconfigured_admins', False))).lower()}",
            "-e", f"ARMORER_CONFIRM_TTL_S={config_manager.get_key('ARMORER_CONFIRM_TTL_S') or '60'}",
            "-e", "BYPASS_TOOL_CONSENT=true",
        ]
        for key_name in ("OPENAI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OLLAMA_BASE_URL"):
            if key_name in provider_env:
                cmd.extend(["-e", f"{key_name}={provider_env[key_name]}"])
        if os.path.isabs(host_root):
            cmd.extend(["-v", f"{host_root}:{host_root}:rw"])

        ollama_url = provider_env.get("OLLAMA_BASE_URL")
        if ollama_url:
            import platform
            if "localhost" in ollama_url.lower() or "127.0.0.1" in ollama_url.lower():
                if platform.system() in ["Darwin", "Windows"]:
                    ollama_url = ollama_url.replace("localhost", "host.docker.internal").replace("127.0.0.1", "host.docker.internal")
            cmd.extend(["-e", f"OLLAMA_BASE_URL={ollama_url}"])

        cmd += [
            "-e", "PYTHONPATH=/app/src",
            "--entrypoint", "python",
            "armorer-cli", "src/armorer/core.py",
        ]

        try:
            run_result = subprocess.run(cmd, capture_output=True, timeout=25)
        except subprocess.TimeoutExpired:
            console.print("[yellow]⚠ Core container startup timed out. Falling back to host core process.[/yellow]")
            ensure_host_core_process()
            best_effort_core_autostarts()
            return
        if run_result.returncode != 0:
            console.print("[yellow]⚠ Failed to start core container. Falling back to host core process.[/yellow]")
            err = run_result.stderr.decode() if run_result.stderr else (run_result.stdout.decode() if run_result.stdout else "Unknown error")
            console.print(f"[dim]{err.strip()}[/dim]")
            ensure_host_core_process()
            best_effort_core_autostarts()
            return
        console.print("[dim]🛡️  Armorer Core Service is active.[/dim]")
    best_effort_core_autostarts()


def ensure_armorer_ui_container(
    *,
    host_port: int = 3000,
    api_base: Optional[str] = None,
    default_ui_token: Optional[str] = None,
    force_rebuild: bool = False,
) -> bool:
    """Ensure the Armorer UI container is running and reachable on host_port."""
    # region agent log
    def _agent_dbg_ui(hypothesis_id: str, location: str, message: str, data: dict) -> None:
        try:
            _logp = (
                Path(__file__).resolve().parents[3]
                / ".cursor"
                / "debug-047d00.log"
            )
            _logp.parent.mkdir(parents=True, exist_ok=True)
            _line = json.dumps(
                {
                    "sessionId": "047d00",
                    "hypothesisId": hypothesis_id,
                    "location": location,
                    "message": message,
                    "data": data,
                    "timestamp": int(time.time() * 1000),
                },
                ensure_ascii=False,
            )
            with open(_logp, "a", encoding="utf-8") as _lf:
                _lf.write(_line + "\n")
        except Exception:
            pass

    # endregion

    ui_name = _UI_DOCKER_IMAGE
    project_root = Path.cwd()
    ui_dir = project_root / "web" / "armorer-ui-selfhost"
    dockerfile = ui_dir / "Dockerfile"
    _agent_dbg_ui(
        "H3",
        "core_lifecycle:ensure_armorer_ui_container",
        "paths",
        {
            "cwd": str(project_root),
            "ui_dir_exists": ui_dir.is_dir(),
            "dockerfile_exists": dockerfile.exists(),
            "host_port": int(host_port),
        },
    )
    if not dockerfile.exists():
        console.print("[yellow]Armorer UI Dockerfile not found; skipping UI container bootstrap.[/yellow]")
        return False

    ui_state = inspect_named_container(ui_name)
    image_status = docker_image_status(project_root, _UI_DOCKER_IMAGE)
    if ui_state.get("running") and not force_rebuild and not image_status.needs_rebuild:
        return True
    if ui_state.get("exists"):
        remove_result = docker_runner.docker_rm([ui_name])
        if not remove_result.ok:
            detail = (remove_result.stderr or remove_result.stdout).strip()
            console.print(f"[bold red]Failed to remove stale '{ui_name}' container.[/bold red]")
            if detail:
                console.print(f"[dim]{detail[:600]}[/dim]")
            return False
        if force_rebuild:
            console.print(f"[dim]Force rebuilding `{ui_name}`.[/dim]")
        elif image_status.needs_rebuild:
            console.print(f"[dim]{image_status.reason} Restarting Armorer UI with a rebuilt image.[/dim]")

    with console.status("[bold cyan]Starting Armorer UI container..."):
        build_cmd = [
            "docker",
            "build",
            "-t",
            _UI_DOCKER_IMAGE,
            "--label",
            f"{_DOCKER_FINGERPRINT_LABEL}={_docker_build_fingerprint(project_root, _UI_DOCKER_IMAGE)}",
            "-f",
            str(dockerfile),
            str(ui_dir),
        ]
        build_result = subprocess.run(build_cmd, capture_output=True, text=True)
        _agent_dbg_ui(
            "H2",
            "core_lifecycle:ensure_armorer_ui_container",
            "docker_build",
            {
                "returncode": build_result.returncode,
                "stderr_tail": ((build_result.stderr or "")[-400:]),
            },
        )
        if build_result.returncode != 0:
            detail = ((build_result.stderr or "") + "\n" + (build_result.stdout or "")).strip()
            _print_docker_build_failure("Armorer UI image", detail)
            return False

        run_cmd = [
            "docker",
            "run",
            "-d",
            "--name",
            ui_name,
            "-p",
            f"{int(host_port)}:3000",
            "--add-host",
            "host.docker.internal:host-gateway",
            "-e",
            f"ARMORER_API_BASE={api_base or 'http://host.docker.internal:8788'}",
            "-e",
            f"ARMORER_UI_API_TOKEN={default_ui_token or ''}",
            "-e",
            f"ARMORER_UI_DEFAULT_TOKEN={default_ui_token or ''}",
            _UI_DOCKER_IMAGE,
        ]
        run_result = subprocess.run(run_cmd, capture_output=True, text=True)
        _agent_dbg_ui(
            "H2",
            "core_lifecycle:ensure_armorer_ui_container",
            "docker_run",
            {
                "returncode": run_result.returncode,
                "stderr_tail": ((run_result.stderr or "")[-400:]),
            },
        )
        if run_result.returncode != 0:
            detail = ((run_result.stderr or "") + "\n" + (run_result.stdout or "")).strip()
            console.print("[yellow]Failed to start Armorer UI container.[/yellow]")
            if detail:
                console.print(f"[dim]{_tail_detail(detail)}[/dim]")
            return False
    return True


def check_self_dockerize() -> None:
    """Secure-by-default: run Armorer inside Docker unless explicitly disabled."""
    if os.path.exists("/.dockerenv"):
        return
    raw_pref = str(os.getenv("ARMORER_SANDBOX", "")).strip().lower()
    if raw_pref in {"0", "false", "off", "no", "host"}:
        return

    args = sys.argv[1:]
    project_root = Path.cwd()
    armorer_config = armorer_config_dir()
    armorer_config.mkdir(parents=True, exist_ok=True)

    try:
        exec_cmd: Optional[List[str]] = None
        with console.status("[bold cyan]🛡️  Armorer is entering the sandbox (secure default)..."):
            daemon_check = probe_docker_daemon()
            if daemon_check is not None:
                raise RuntimeError(daemon_check)
            if not args:
                api_base = str(os.getenv("ARMORER_API_BASE", "http://127.0.0.1:8788")).strip()
                _ensure_host_ui_api_process(api_base)
            force_rebuild = str(os.getenv("ARMORER_SANDBOX_REBUILD", "")).strip().lower() in {"1", "true", "yes", "on"}
            sandbox_name = os.getenv("ARMORER_SANDBOX_NAME", "armorer-sandbox")
            image_status = self_docker_image_status(project_root, _SELF_DOCKER_IMAGE)
            refresh_container = bool(force_rebuild or image_status.needs_rebuild)

            def _container_state(name: str) -> Optional[str]:
                inspect = subprocess.run(
                    ["docker", "inspect", "-f", "{{.State.Status}}", name],
                    capture_output=True,
                    text=True,
                )
                if inspect.returncode != 0:
                    return None
                return (inspect.stdout or "").strip() or None

            if refresh_container:
                subprocess.run(["docker", "rm", "-f", sandbox_name], capture_output=True, text=True)
            if refresh_container and image_status.reason:
                console.print(f"[dim]{image_status.reason}[/dim]")
            if not ensure_self_docker_image(project_root, force_rebuild=refresh_container):
                raise RuntimeError("Failed to build Armorer sandbox image.")
            elif not refresh_container:
                console.print("[dim]Reusing existing sandbox image 'armorer-cli'.[/dim]")

            state = _container_state(sandbox_name)
            if state != "running":
                if state is not None:
                    subprocess.run(["docker", "rm", "-f", sandbox_name], capture_output=True, text=True)
                installed_root = project_root / "installed"
                installed_root.mkdir(parents=True, exist_ok=True)
                run_cmd = [
                    "docker", "run", "-d",
                    "--name", sandbox_name,
                    "--restart", "unless-stopped",
                    "-v", "/var/run/docker.sock:/var/run/docker.sock",
                    "-v", f"{project_root}:/app",
                    "-v", f"{installed_root}:/app/installed",
                    "-v", f"{project_root}:{project_root}",
                    "-v", f"{armorer_config}:/root/.armorer",
                    "-w", "/app",
                    "-e", f"HOST_PROJECT_ROOT={project_root}",
                    "-e", f"HOST_ARMORER_CONFIG={armorer_config}",
                    "-e", "BYPASS_TOOL_CONSENT=true",
                    "-e", "PYTHONPATH=/app/src",
                    "--entrypoint", "sh",
                    "armorer-cli",
                    "-lc", "while true; do sleep 3600; done",
                ]
                subprocess.run(run_cmd, check=True, capture_output=True, text=True)
                console.print(f"[dim]Started persistent sandbox container '{sandbox_name}'.[/dim]")
            else:
                console.print(f"[dim]Using persistent sandbox container '{sandbox_name}'.[/dim]")

            exec_cmd = ["docker", "exec"]
            if sys.stdin.isatty():
                exec_cmd.append("-it")
            elif sys.stdout.isatty():
                exec_cmd.append("-t")
            else:
                exec_cmd.append("-i")
            exec_cmd.extend([sandbox_name, "python", "src/armorer/main.py"])
            exec_cmd.extend(args)
        console.print()
        if exec_cmd:
            run_result = subprocess.run(exec_cmd)
            return_code = int(getattr(run_result, "returncode", 1))
            auto_open_ui = str(os.getenv("ARMORER_UI_AUTO_OPEN", "true")).strip().lower() in {
                "1",
                "true",
                "yes",
                "on",
            }
            if return_code == 0 and auto_open_ui and not args:
                ui_url = str(os.getenv("ARMORER_UI_URL", "http://127.0.0.1:3000/chat")).strip()

                def _url_reachable(url: str) -> bool:
                    try:
                        req = Request(url, method="GET")
                        with urlopen(req, timeout=1.0) as resp:
                            return int(getattr(resp, "status", 200) or 200) < 500
                    except (HTTPError, URLError, TimeoutError, ValueError):
                        return False
                    except Exception:
                        return False

                for _ in range(10):
                    if _url_reachable(ui_url):
                        try:
                            if sys.platform == "darwin":
                                subprocess.run(["open", ui_url], check=False)
                            elif shutil.which("xdg-open"):
                                subprocess.run(["xdg-open", ui_url], check=False)
                        except Exception:
                            pass
                        break
                    time.sleep(0.5)
            raise SystemExit(return_code)
    except Exception as e:
        from armorer.messaging.bridge import _sanitize_bridge_user_text

        safe_detail = _sanitize_bridge_user_text(str(e).strip() or e.__class__.__name__)[:300]
        if docker_daemon_unavailable(safe_detail):
            console.print("[bold yellow]Docker is not running or unreachable.[/bold yellow]")
            console.print("[yellow]Start Docker Desktop and try again.[/yellow]")
            console.print("[yellow]On macOS, run:[/yellow] [dim]open -a Docker[/dim]")
            console.print("[yellow]Wait until Docker is running (the whale icon is steady) and rerun your command.[/yellow]")
            console.print("[bold red]Refusing insecure host fallback. Set ARMORER_SANDBOX=0 only if you intentionally want host mode.[/bold red]")
        else:
            console.print(f"[bold red]Failed to start Dockerized Armorer: {safe_detail}[/bold red]")
            console.print("[bold red]Refusing insecure host fallback. Set ARMORER_SANDBOX=0 only if you intentionally want host mode.[/bold red]")
        raise SystemExit(1)
