from __future__ import annotations

import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
from threading import Lock, Thread
from collections import deque
import time
import uuid
from typing import Any, Dict, List, Optional

from armorer.agent_sdk import tool
from armorer.registry import AppRegistry
from armorer.security.conversation_guard import ConversationGuard


_JOB_LOCK = Lock()
_JOB_THREADS: dict[str, Thread] = {}
_STALE_JOB_AFTER_S = 120.0
_HEARTBEAT_INTERVAL_S = 5.0


def _project_root() -> Path:
    return Path(__file__).resolve().parents[3]


def _resolve_armorer_invocation() -> tuple[list[str], Path | None]:
    root = _project_root()
    candidates = [
        root / ".venv" / "bin" / "armorer",
        root / ".venv" / "Scripts" / "armorer.exe",
        root / ".venv" / "Scripts" / "armorer",
    ]
    for candidate in candidates:
        if candidate.exists():
            return [str(candidate)], root

    if shutil.which("uv"):
        return ["uv", "run", "armorer"], root

    return ["armorer"], None




def _queue_dir(name: str) -> Path:
    root = Path.home() / ".armorer" / name
    root.mkdir(parents=True, exist_ok=True)
    return root


def _guard() -> ConversationGuard:
    return ConversationGuard(config_dir=Path.home() / ".armorer")


def _format_guard_settings(guard: ConversationGuard) -> Dict[str, Any]:
    settings = guard.get_guard_settings()
    proactive = guard.get_proactive_security_settings()
    disabled = [guard.alert_flag_label(flag) for flag in settings.get("disabled_alert_flags", [])]
    thresholds = {
        guard.alert_flag_label(flag): float(value)
        for flag, value in (settings.get("alert_flag_thresholds", {}) or {}).items()
    }
    return {
        "ok": True,
        "settings_file": str(guard.settings_file),
        "llm_guard_enabled": bool(settings.get("llm_guard_enabled", True)),
        "alerting_enabled": bool(settings.get("alerting_enabled", True)),
        "proactive_security_enabled": bool(proactive.get("proactive_security_enabled", False)),
        "proactive_security_prompted": bool(proactive.get("proactive_security_prompted", False)),
        "telegram_alert_chat_id": proactive.get("telegram_alert_chat_id", ""),
        "telegram_admin_chat_ids": list(proactive.get("telegram_admin_chat_ids", []) or []),
        "telegram_admin_user_ids": list(proactive.get("telegram_admin_user_ids", []) or []),
        "telegram_allow_unconfigured_admins": bool(proactive.get("telegram_allow_unconfigured_admins", False)),
        "alert_confidence_threshold": float(settings.get("alert_confidence_threshold", 0.0) or 0.0),
        "per_alert_thresholds": thresholds,
        "disabled_alert_types": disabled,
        "message": (
            f"Guard settings are user-scoped in {guard.settings_file}. "
            f"Disabled alert types: {', '.join(disabled) if disabled else 'none'}. "
            f"Per-alert thresholds: {thresholds if thresholds else 'default only'}."
        ),
    }


@tool(category="lifecycle", verification_level="settings_read", safe_for_retrieval=True)
def get_guard_alert_settings() -> Dict[str, Any]:
    """Get the current user-scoped proactive security guard alert settings."""
    return _format_guard_settings(_guard())


@tool(category="lifecycle", verification_level="settings_read", safe_for_retrieval=True)
def get_proactive_security_settings() -> Dict[str, Any]:
    """Get the current user-scoped proactive security settings and Telegram routing."""
    return _format_guard_settings(_guard())


@tool(category="lifecycle", verification_level="settings_write")
def set_proactive_security_enabled(enabled: bool) -> Dict[str, Any]:
    """Enable or disable proactive security for the current user."""
    guard = _guard()
    proactive = guard.save_proactive_security_settings(
        {
            "proactive_security_enabled": bool(enabled),
            "proactive_security_prompted": True,
        }
    )
    payload = _format_guard_settings(guard)
    payload["message"] = (
        f"Proactive security {'enabled' if proactive['proactive_security_enabled'] else 'disabled'} "
        f"for the current user."
    )
    return payload


@tool(category="lifecycle", verification_level="settings_write")
def set_proactive_security_alert_chat(chat_id: str) -> Dict[str, Any]:
    """Set the Telegram chat that should receive proactive security alerts for the current user."""
    guard = _guard()
    proactive = guard.save_proactive_security_settings({"telegram_alert_chat_id": str(chat_id or "").strip()})
    payload = _format_guard_settings(guard)
    payload["message"] = (
        f"Proactive security alert chat set to {proactive.get('telegram_alert_chat_id') or 'unset'}."
    )
    return payload


@tool(category="lifecycle", verification_level="settings_write")
def set_proactive_security_admin_chat_ids(chat_ids_csv: str) -> Dict[str, Any]:
    """Set the allowed Telegram admin chat ids for the current user as a comma-separated list."""
    guard = _guard()
    ids = [item.strip() for item in str(chat_ids_csv or "").split(",") if item.strip()]
    proactive = guard.save_proactive_security_settings({"telegram_admin_chat_ids": ids})
    payload = _format_guard_settings(guard)
    payload["message"] = (
        "Updated proactive security admin chat ids to "
        f"{', '.join(proactive.get('telegram_admin_chat_ids', [])) or 'none'}."
    )
    return payload


@tool(category="lifecycle", verification_level="settings_write")
def set_proactive_security_admin_user_ids(user_ids_csv: str) -> Dict[str, Any]:
    """Set the allowed Telegram admin user ids for the current user as a comma-separated list."""
    guard = _guard()
    ids = [item.strip() for item in str(user_ids_csv or "").split(",") if item.strip()]
    proactive = guard.save_proactive_security_settings({"telegram_admin_user_ids": ids})
    payload = _format_guard_settings(guard)
    payload["message"] = (
        "Updated proactive security admin user ids to "
        f"{', '.join(proactive.get('telegram_admin_user_ids', [])) or 'none'}."
    )
    return payload


@tool(category="lifecycle", verification_level="settings_write")
def set_proactive_security_allow_unconfigured_admins(enabled: bool) -> Dict[str, Any]:
    """Allow or disallow Telegram admin access when no explicit allowlist is configured for the current user."""
    guard = _guard()
    proactive = guard.save_proactive_security_settings(
        {"telegram_allow_unconfigured_admins": bool(enabled)}
    )
    payload = _format_guard_settings(guard)
    payload["message"] = (
        "Updated Telegram unconfigured-admin policy to "
        f"{'enabled' if proactive.get('telegram_allow_unconfigured_admins') else 'disabled'}."
    )
    return payload


@tool(category="lifecycle", verification_level="settings_write")
def set_guard_alert_verbosity(alert_type: str, step: float = 0.15) -> Dict[str, Any]:
    """Reduce guard alert frequency for one alert type by increasing its threshold."""
    guard = _guard()
    threshold = guard.increase_alert_threshold(alert_type, step=step)
    label = guard.alert_flag_label(alert_type)
    payload = _format_guard_settings(guard)
    payload["message"] = (
        f"Reduced alert frequency for {label}. "
        f"Future alerts of this type now require confidence >= {threshold:.2f}."
    )
    return payload


@tool(category="lifecycle", verification_level="settings_write")
def disable_guard_alert_type(alert_type: str) -> Dict[str, Any]:
    """Disable one proactive-security alert type for the current user."""
    guard = _guard()
    label = guard.alert_flag_label(alert_type)
    guard.disable_alert_flag(alert_type)
    payload = _format_guard_settings(guard)
    payload["message"] = f"Disabled proactive-security alerts for {label}."
    return payload


@tool(category="lifecycle", verification_level="settings_write")
def enable_guard_alert_type(alert_type: str) -> Dict[str, Any]:
    """Re-enable one proactive-security alert type for the current user."""
    guard = _guard()
    slug = guard.alert_flag_slug(alert_type)
    settings = guard.get_guard_settings()
    disabled = [flag for flag in settings.get("disabled_alert_flags", []) if guard.alert_flag_slug(flag) != slug]
    settings["disabled_alert_flags"] = disabled
    guard.save_guard_settings(settings)
    payload = _format_guard_settings(guard)
    payload["message"] = f"Enabled proactive-security alerts for {guard.alert_flag_label(slug)}."
    return payload


@tool(category="lifecycle", verification_level="settings_write")
def reset_guard_alert_preferences() -> Dict[str, Any]:
    """Reset proactive-security alert thresholds and disabled alert types to defaults for the current user."""
    guard = _guard()
    settings = guard.get_guard_settings()
    settings["alert_flag_thresholds"] = {}
    settings["disabled_alert_flags"] = []
    settings["alert_confidence_threshold"] = 0.0
    settings["alerting_enabled"] = True
    guard.save_guard_settings(settings)
    payload = _format_guard_settings(guard)
    payload["message"] = "Reset proactive-security alert preferences to defaults."
    return payload


def _clear_queue_files(directory: Path) -> int:
    removed = 0
    for path in directory.glob("*.json"):
        try:
            path.unlink()
            removed += 1
        except FileNotFoundError:
            continue
    return removed


def _list_job_files() -> List[Path]:
    return sorted(_jobs_dir().glob("*.json"))


@tool(category="lifecycle", verification_level="queue_mutation")
def clear_chat_backlog(include_outbox: bool = True, include_status: bool = True) -> Dict[str, Any]:
    """Clear queued chat task files without touching training data or installed agents."""
    inbox_removed = _clear_queue_files(_queue_dir("inbox"))
    outbox_removed = _clear_queue_files(_queue_dir("outbox")) if include_outbox else 0
    status_removed = _clear_queue_files(_queue_dir("status")) if include_status else 0
    return {
        "ok": True,
        "inbox_removed": inbox_removed,
        "outbox_removed": outbox_removed,
        "status_removed": status_removed,
        "message": f"Cleared chat backlog: {inbox_removed} inbox, {outbox_removed} outbox, {status_removed} status file(s).",
    }


@tool(category="lifecycle", verification_level="job_mutation")
def clear_job_records(include_finished: bool = True, include_failed: bool = True, include_queued: bool = True, include_running: bool = False) -> Dict[str, Any]:
    """Clear Armorer job record files. Running jobs are preserved by default."""
    removed = []
    kept = []
    for path in _list_job_files():
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except Exception:
            data = {}
        status = str(data.get("status", "")).strip().lower()
        should_remove = (
            (status == "completed" and include_finished)
            or (status == "failed" and include_failed)
            or (status == "queued" and include_queued)
            or (status == "running" and include_running)
        )
        if should_remove:
            try:
                path.unlink()
                removed.append(path.name)
            except FileNotFoundError:
                continue
        else:
            kept.append(path.name)
    return {
        "ok": True,
        "removed": removed,
        "kept": kept,
        "message": f"Removed {len(removed)} job record(s); kept {len(kept)}.",
    }

def _jobs_dir() -> Path:
    primary = Path.home() / ".armorer" / "jobs"
    try:
        primary.mkdir(parents=True, exist_ok=True)
    except Exception:
        pass
    if primary.exists() and os.access(primary, os.W_OK | os.X_OK):
        return primary

    fallback = Path.home() / ".armorer" / "jobs-local"
    fallback.mkdir(parents=True, exist_ok=True)
    return fallback


def _job_path(job_id: str) -> Path:
    return _jobs_dir() / f"{job_id}.json"


def _write_job(job_id: str, data: Dict[str, Any]) -> None:
    path = _job_path(job_id)
    data = dict(data)
    now = time.time()
    data["updated_at"] = now
    data.setdefault("heartbeat_at", now)
    tmp = path.with_suffix(".tmp")
    tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
    tmp.replace(path)


def reconcile_job_state(job_id: str, data: Optional[Dict[str, Any]] = None, persist: bool = True) -> Dict[str, Any]:
    if data is None:
        path = _job_path(job_id)
        if not path.exists():
            return {"ok": False, "message": f"Job '{job_id}' was not found."}
        data = json.loads(path.read_text(encoding="utf-8"))
    status = str(data.get("status", "")).strip().lower()
    if status not in {"running", "queued"}:
        return data

    heartbeat_at = float(data.get("heartbeat_at") or data.get("updated_at") or data.get("started_at") or 0.0)
    age_s = time.time() - heartbeat_at if heartbeat_at else 0.0
    thread_alive = _JOB_THREADS.get(job_id).is_alive() if job_id in _JOB_THREADS else False

    if age_s <= _STALE_JOB_AFTER_S:
        return data
    if thread_alive:
        return data

    stale = {
        **data,
        "status": "failed",
        "phase": "stale",
        "message": "Job became stale; no active worker process was found.",
        "heartbeat_at": heartbeat_at,
        "result": {
            "ok": False,
            "error": "stale_job",
            "stale": True,
        },
        "returncode": data.get("returncode") or 1,
    }
    if persist:
        _write_job(job_id, stale)
    return stale


def _start_background_job(job_type: str, args: List[str], description: str) -> Dict[str, Any]:
    job_id = str(uuid.uuid4())
    command_prefix, command_cwd = _resolve_armorer_invocation()
    command = command_prefix + args
    initial = {
        "job_id": job_id,
        "type": job_type,
        "status": "queued",
        "phase": "queued",
        "message": description,
        "command": " ".join(command),
        "started_at": time.time(),
        "recent_output": [],
        "returncode": None,
        "result": None,
    }
    _write_job(job_id, initial)

    def runner() -> None:
        recent: deque[str] = deque(maxlen=60)
        last_heartbeat = time.time()
        with _JOB_LOCK:
            _write_job(
                job_id,
                {
                    **initial,
                    "status": "running",
                    "phase": "starting",
                    "message": description,
                    "recent_output": [],
                    "heartbeat_at": time.time(),
                },
            )
        try:
            proc = subprocess.Popen(
                command,
                cwd=str(command_cwd) if command_cwd is not None else None,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
            )
            _write_job(
                job_id,
                {
                    **initial,
                    "status": "running",
                    "phase": "starting",
                    "message": description,
                    "recent_output": [],
                    "worker_pid": proc.pid,
                    "heartbeat_at": time.time(),
                },
            )
            assert proc.stdout is not None
            for raw_line in proc.stdout:
                now = time.time()
                if now - last_heartbeat >= _HEARTBEAT_INTERVAL_S:
                    _write_job(
                        job_id,
                        {
                            "job_id": job_id,
                            "type": job_type,
                            "status": "running",
                            "phase": "running",
                            "message": description,
                            "command": " ".join(command),
                            "started_at": initial["started_at"],
                            "recent_output": list(recent),
                            "worker_pid": proc.pid,
                            "returncode": None,
                            "result": None,
                            "heartbeat_at": now,
                        },
                    )
                    last_heartbeat = now
                line = str(raw_line or "").rstrip()
                if not line:
                    continue
                recent.append(line[-500:])
                lowered = line.lower()
                phase = "running"
                if "deploy" in lowered or "launch" in lowered:
                    phase = "deploying"
                elif "install" in lowered or "clone" in lowered or "build" in lowered:
                    phase = "installing"
                elif "config" in lowered or "setup" in lowered:
                    phase = "configuring"
                elif "health" in lowered or "stable" in lowered or "success" in lowered:
                    phase = "verifying"
                _write_job(
                    job_id,
                    {
                        "job_id": job_id,
                        "type": job_type,
                        "status": "running",
                        "phase": phase,
                        "message": line[-200:],
                        "command": " ".join(command),
                        "started_at": initial["started_at"],
                        "recent_output": list(recent),
                        "worker_pid": proc.pid,
                        "returncode": None,
                        "result": None,
                        "heartbeat_at": now,
                    },
                )
                last_heartbeat = now
            returncode = proc.wait()
            status = "completed" if returncode == 0 else "failed"
            phase = "done" if returncode == 0 else "failed"
            message = "Job completed successfully." if returncode == 0 else "Job failed."
            _write_job(
                job_id,
                {
                    "job_id": job_id,
                    "type": job_type,
                    "status": status,
                    "phase": phase,
                    "message": message,
                    "command": " ".join(command),
                    "started_at": initial["started_at"],
                    "recent_output": list(recent),
                    "worker_pid": proc.pid,
                    "returncode": returncode,
                    "heartbeat_at": time.time(),
                    "result": {
                        "ok": returncode == 0,
                        "returncode": returncode,
                        "stdout_tail": "\n".join(recent)[-12000:],
                    },
                },
            )
        except Exception as e:
            _write_job(
                job_id,
                {
                    "job_id": job_id,
                    "type": job_type,
                    "status": "failed",
                    "phase": "failed",
                    "message": f"Job failed: {e}",
                    "command": " ".join(command),
                    "started_at": initial["started_at"],
                    "recent_output": list(recent),
                    "worker_pid": getattr(locals().get("proc", None), "pid", None),
                    "returncode": 1,
                    "heartbeat_at": time.time(),
                    "result": {"ok": False, "error": str(e)},
                },
            )

    thread = Thread(target=runner, daemon=True)
    _JOB_THREADS[job_id] = thread
    thread.start()
    return {
        "ok": True,
        "job_id": job_id,
        "status": "queued",
        "message": description,
        "command": " ".join(command),
    }


def _run_armorer(args: List[str], timeout: Optional[int] = None) -> Dict[str, Any]:
    """Run an armorer CLI command non-interactively and capture output."""
    command_prefix, command_cwd = _resolve_armorer_invocation()
    cmd = command_prefix + args
    try:
        run_kwargs = {"capture_output": True, "text": True}
        if command_cwd is not None:
            run_kwargs["cwd"] = str(command_cwd)
        if timeout is not None and timeout > 0:
            run_kwargs["timeout"] = timeout
        res = subprocess.run(cmd, **run_kwargs)
        return {
            "ok": res.returncode == 0,
            "returncode": res.returncode,
            "stdout": (res.stdout or "").strip()[-12000:],
            "stderr": (res.stderr or "").strip()[-12000:],
            "command": " ".join(cmd),
        }
    except subprocess.TimeoutExpired as e:
        out = (e.stdout or "") if isinstance(e.stdout, str) else ""
        err = (e.stderr or "") if isinstance(e.stderr, str) else ""
        return {
            "ok": False,
            "returncode": 124,
            "stdout": out[-12000:],
            "stderr": f"Timeout after {timeout}s.\n{err[-12000:]}".strip(),
            "command": " ".join(cmd),
        }
    except Exception as e:
        return {
            "ok": False,
            "returncode": 1,
            "stdout": "",
            "stderr": str(e),
            "command": " ".join(cmd),
        }


@tool(category="lifecycle", verification_level="cli_mutation")
def install_agent(agent: str, alias: Optional[str] = None, skip_scan: bool = True) -> Dict[str, Any]:
    """
    Install an agent via Armorer in non-interactive mode.

    Notes:
    - Uses --yes for deterministic non-interactive execution.
    - Uses --skip-scan by default for faster execution from chat.
    """
    args = ["install", agent, "--yes"]
    if skip_scan:
        args.append("--skip-scan")
    if alias:
        args.extend(["--alias", alias.strip()])
    return _run_armorer(args)


@tool(category="lifecycle", verification_level="job_start")
def start_install_agent_job(agent: str, alias: Optional[str] = None, skip_scan: bool = True) -> Dict[str, Any]:
    """Start a long-running `armorer install` job in the background and return a job id."""
    args = ["install", agent, "--yes"]
    if skip_scan:
        args.append("--skip-scan")
    if alias:
        args.extend(["--alias", alias.strip()])
    return _start_background_job("install_agent", args, f"Installing {agent}...")


@tool(category="lifecycle", verification_level="cli_mutation")
def run_agent(agent: str, mode: str = "background", command: Optional[str] = None) -> Dict[str, Any]:
    """
    Run an installed agent.

    Args:
    - agent: alias or agent id.
    - mode: usually 'background' for non-interactive core execution.
    - command: optional command passed after '--' (e.g. 'status').
    """
    args = ["run", agent]
    if mode:
        args.extend(["--mode", mode])
    if command:
        parts = [p for p in str(command).strip().split(" ") if p]
        if parts:
            args.append("--")
            args.extend(parts)
    return _run_armorer(args)


@tool(category="lifecycle", verification_level="job_start")
def start_run_agent_job(agent: str, mode: str = "background", command: Optional[str] = None) -> Dict[str, Any]:
    """Start a long-running `armorer run` job in the background and return a job id."""
    args = ["run", agent]
    if mode:
        args.extend(["--mode", mode])
    if command:
        parts = [p for p in str(command).strip().split(" ") if p]
        if parts:
            args.append("--")
            args.extend(parts)
    return _start_background_job("run_agent", args, f"Running {agent}...")


@tool(category="lifecycle", verification_level="cli_mutation")
def stop_agent(agent: str) -> Dict[str, Any]:
    """Stop an installed agent's running services."""
    return _run_armorer(["stop", agent])


@tool(category="lifecycle", verification_level="cli_mutation")
def uninstall_agent(agent: str, force: bool = True) -> Dict[str, Any]:
    """Uninstall an installed agent (force by default for non-interactive usage)."""
    args = ["uninstall", agent]
    if force:
        args.append("--force")
    return _run_armorer(args)


@tool(category="lifecycle", verification_level="job_start")
def start_uninstall_agent_job(agent: str, force: bool = True) -> Dict[str, Any]:
    """Start a long-running `armorer uninstall` job in the background and return a job id."""
    args = ["uninstall", agent]
    if force:
        args.append("--force")
    return _start_background_job("uninstall_agent", args, f"Uninstalling {agent}...")


@tool(category="lifecycle", verification_level="cli_read", safe_for_retrieval=True)
def list_agents() -> Dict[str, Any]:
    """Return the output of `armorer list`."""
    return _run_armorer(["list"])


@tool(category="lifecycle", verification_level="cli_readwrite")
def armorer_cli(command: str, timeout: Optional[int] = None) -> Dict[str, Any]:
    """
    Run any Armorer CLI command (without using a shell).

    Examples:
    - "list"
    - "run openclaw --mode background"
    - "install openclaw --yes --skip-scan"
    """
    raw = (command or "").strip()
    if not raw:
        return {
            "ok": False,
            "returncode": 2,
            "stdout": "",
            "stderr": "No command provided.",
            "command": "armorer",
        }

    # Allow both "list" and "armorer list" inputs.
    if raw.startswith("armorer "):
        raw = raw[len("armorer ") :].strip()

    try:
        args = shlex.split(raw)
    except Exception as e:
        return {
            "ok": False,
            "returncode": 2,
            "stdout": "",
            "stderr": f"Invalid command parsing: {e}",
            "command": f"armorer {raw}",
        }

    if not args:
        return {
            "ok": False,
            "returncode": 2,
            "stdout": "",
            "stderr": "No valid command tokens provided.",
            "command": "armorer",
        }

    return _run_armorer(args, timeout=timeout)


@tool(category="lifecycle", verification_level="job_read", safe_for_retrieval=True)
def get_job_status(job_id: str) -> Dict[str, Any]:
    """Return current status for a background Armorer job."""
    path = _job_path(str(job_id).strip())
    if not path.exists():
        return {"ok": False, "message": f"Job '{job_id}' was not found."}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except Exception as e:
        return {"ok": True, "job_id": job_id, "status": "starting", "phase": "starting", "message": f"Job '{job_id}' is starting."}
    data = reconcile_job_state(job_id, data=data, persist=True)
    data["ok"] = True
    return data


_SECRET_TOKENS = ("KEY", "SECRET", "TOKEN", "PASSWORD", "PASS", "AUTH")


def _is_secret_name(name: str) -> bool:
    upper = str(name or "").upper()
    return any(token in upper for token in _SECRET_TOKENS)


def _mask_value(key: str, value: Any) -> Any:
    if _is_secret_name(key):
        if value in (None, ""):
            return ""
        return "***REDACTED***"
    if isinstance(value, dict):
        return {k: _mask_value(str(k), v) for k, v in value.items()}
    if isinstance(value, list):
        return [_mask_value(key, item) for item in value]
    return value


def _load_app(agent: str):
    registry = AppRegistry()
    app = registry.get_app(agent)
    if not app:
        return None, {
            "ok": False,
            "message": f"Agent '{agent}' is not installed.",
        }
    return app, None


def _is_ignorable_cli_exit(agent_id: str, name: str, status: str) -> bool:
    lower_agent_id = (agent_id or "").lower()
    lower_name = (name or "").lower()
    lower_status = (status or "").lower()
    if "cli" not in lower_name or "exited" not in lower_status:
        return False
    if lower_agent_id == "openclaw":
        return True
    return "(0)" in lower_status


@tool(category="runtime_state", verification_level="live_state", safe_for_retrieval=True)
def list_agents_structured() -> Dict[str, Any]:
    """Return installed agents as structured metadata instead of CLI table text."""
    registry = AppRegistry()
    apps = registry.list_apps()
    status_map = _collect_agent_statuses(apps)
    return {
        "ok": True,
        "count": len(apps),
        "agents": [
            {
                "alias": app.name,
                "agent_id": app.agent_id or app.name,
                "install_path": app.install_path,
                "description": app.description,
                "config_files": dict(app.config_files or {}),
                "managed_files": list(app.managed_files or []),
                "channels_enabled": list(app.channels_enabled or []),
                "skills_enabled": list(app.skills_enabled or []),
                "status": status_map.get(app.name, {}),
            }
            for app in apps
        ],
    }


@tool(category="runtime_state", verification_level="live_state", safe_for_retrieval=True)
def get_agent_details(agent: str) -> Dict[str, Any]:
    """Return structured metadata for one installed agent."""
    app, error = _load_app(agent)
    if error:
        return error
    return {
        "ok": True,
        "agent": {
            "alias": app.name,
            "agent_id": app.agent_id or app.name,
            "install_path": app.install_path,
            "description": app.description,
            "install_type": app.install_type,
            "version": app.version,
            "config_files": dict(app.config_files or {}),
            "managed_files": list(app.managed_files or []),
            "monitor_targets": list(app.monitor_targets or []),
            "run_commands": list(app.run_commands or []),
            "channels_enabled": list(app.channels_enabled or []),
            "skills_enabled": list(app.skills_enabled or []),
            "capabilities": list(app.capabilities or []),
            "app_config_state": _mask_value("app_config_state", app.app_config_state or {}),
            "status": _collect_agent_statuses([app]).get(app.name, {}),
        },
    }


def _collect_agent_statuses(apps: List[Any]) -> Dict[str, Dict[str, Any]]:
    container_statuses: dict[str, str] = {}
    try:
        result = subprocess.run(
            ["docker", "ps", "-a", "--format", "{{.Names}}\t{{.Status}}"],
            capture_output=True,
            text=True,
        )
        for line in result.stdout.strip().splitlines():
            if "\t" in line:
                name, status = line.split("\t", 1)
                container_statuses[name] = status
    except Exception:
        pass

    out: Dict[str, Dict[str, Any]] = {}
    for app in apps:
        app_agent_id = str(getattr(app, "agent_id", "") or getattr(app, "name", "") or "").strip()
        agent_statuses: List[str] = []
        agent_health: List[str] = []
        for cname, cstatus in container_statuses.items():
            if cname.startswith(f"{app.name}-") or cname == app.name:
                if _is_ignorable_cli_exit(app_agent_id, cname, cstatus):
                    continue
                agent_statuses.append(cstatus)
                lower = cstatus.lower()
                if "unhealthy" in lower:
                    agent_health.append("unhealthy")
                elif "healthy" in lower:
                    agent_health.append("healthy")
                elif "health: starting" in lower or "starting" in lower:
                    agent_health.append("starting")

        if not agent_statuses:
            state = "not_running"
            summary = "Not running"
        elif all("running" in s.lower() or "up" in s.lower() for s in agent_statuses):
            state = "running"
            summary = f"Running ({len(agent_statuses)} container{'s' if len(agent_statuses) != 1 else ''})"
        elif any("running" in s.lower() or "up" in s.lower() for s in agent_statuses):
            running = sum(1 for s in agent_statuses if "running" in s.lower() or "up" in s.lower())
            state = "partial"
            summary = f"Partial ({running}/{len(agent_statuses)} up)"
        else:
            state = "stopped"
            summary = f"Stopped ({len(agent_statuses)} container{'s' if len(agent_statuses) != 1 else ''})"

        running_count = sum(1 for s in agent_statuses if "running" in s.lower() or "up" in s.lower())
        if not agent_statuses:
            health = "unknown"
        elif any("unhealthy" in h for h in agent_health):
            health = "unhealthy"
        elif any("starting" in h for h in agent_health):
            health = "starting"
        elif running_count == 0:
            health = "down"
        elif running_count < len(agent_statuses):
            health = "degraded"
        elif any("healthy" in h for h in agent_health):
            health = "healthy"
        else:
            health = "running"

        out[app.name] = {
            "state": state,
            "summary": summary,
            "health": health,
            "container_statuses": agent_statuses,
        }
    return out


@tool(category="runtime_state", verification_level="live_state", safe_for_retrieval=True)
def get_agent_status(agent: str) -> Dict[str, Any]:
    """Return generic structured runtime status for one installed agent."""
    app, error = _load_app(agent)
    if error:
        return error
    status = _collect_agent_statuses([app]).get(app.name, {})
    return {
        "ok": True,
        "agent": app.name,
        "status": status,
    }


@tool(category="setup_state", verification_level="config_read", safe_for_retrieval=True)
def get_agent_config(agent: str) -> Dict[str, Any]:
    """Return masked config content for one installed agent using registry-declared config files."""
    app, error = _load_app(agent)
    if error:
        return error

    install_path = Path(app.install_path)
    config_files = dict(app.config_files or {})
    files: Dict[str, Any] = {}

    for label, rel_path in config_files.items():
        if not rel_path:
            continue
        path = install_path / rel_path
        if not path.exists():
            files[label] = {"path": str(path), "exists": False}
            continue
        try:
            raw = path.read_text()
        except Exception as e:
            files[label] = {"path": str(path), "exists": True, "error": str(e)}
            continue

        if path.suffix.lower() == ".json":
            try:
                parsed = json.loads(raw)
                files[label] = {
                    "path": str(path),
                    "exists": True,
                    "format": "json",
                    "content": _mask_value(label, parsed),
                }
                continue
            except Exception:
                pass

        if path.name == ".env" or "=" in raw:
            env_map: Dict[str, str] = {}
            for line in raw.splitlines():
                line = line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                k, v = line.split("=", 1)
                env_map[k.strip()] = _mask_value(k.strip(), v.strip())
            files[label] = {
                "path": str(path),
                "exists": True,
                "format": "env",
                "content": env_map,
            }
            continue

        files[label] = {
            "path": str(path),
            "exists": True,
            "format": "text",
            "content": raw[:4000],
        }

    return {
        "ok": True,
        "agent": app.name,
        "config_files": files,
    }
