"""OpenClaw setup flow: parsing, state management, and execution."""

import ast
import copy
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Callable

from armorer.catalog import resolve_playbook as _resolve_playbook
from armorer.messaging.telegram import TelegramGateway as _TelegramGateway
from armorer.registry import AppMetadata
from armorer.skills import SkillManager as _SkillManager

from armorer.core.prompt_parsing import (
    _normalize_prompt,
    _extract_reply_value,
    _is_yes,
    _is_no,
    _is_openclaw_setup_yes,
    _is_openclaw_setup_no,
    _is_openclaw_skip_reply,
    _is_openclaw_cancel_reply,
    _is_openclaw_clear_reply,
    _looks_like_openclaw_compact_value,
    _extract_openclaw_model_reply_value,
    _extract_openclaw_provider_key_reply_value,
    _extract_openclaw_telegram_admin_reply_value,
    _extract_openclaw_alert_chat_reply_value,
    _extract_openclaw_telegram_token_reply_value,
    _extract_openclaw_base_url_reply_value,
    _extract_openclaw_armory_skills_reply_value,
    _extract_openclaw_skill_paths_reply_value,
    _parse_csv_values,
    _is_valid_telegram_id,
)
from armorer.core.telegram_setup import (
    _looks_like_question,
    _is_clearly_unrelated_to_openclaw_setup,
    _parse_telegram_admin_ids,
)

logger = logging.getLogger("ArmorerCore")


def _resolve_from_core(name: str, fallback):
    """Resolve a symbol through ``armorer.core`` so monkeypatching in tests takes effect."""
    core = sys.modules.get("armorer.core")
    if core and hasattr(core, name):
        return getattr(core, name)
    return fallback


# ---------------------------------------------------------------------------
# SupersededTaskError lives in the handler module.  Resolve lazily so that
# the import cycle  handler -> openclaw_setup  is broken at load time.
# ---------------------------------------------------------------------------
_SupersededTaskError: type | None = None  # noqa: N816


def _superseded_error() -> type:
    global _SupersededTaskError
    if _SupersededTaskError is None:
        from armorer.core.handler import SupersededTaskError
        _SupersededTaskError = SupersededTaskError
    return _SupersededTaskError


_OpenClawSetupExecutionError: type | None = None  # noqa: N816


def _setup_exec_error() -> type:
    """Lazily import OpenClawSetupExecutionError from handler to avoid circular import."""
    global _OpenClawSetupExecutionError
    if _OpenClawSetupExecutionError is None:
        from armorer.core.handler import OpenClawSetupExecutionError
        _OpenClawSetupExecutionError = OpenClawSetupExecutionError
    return _OpenClawSetupExecutionError

    def __init__(
        self,
        message: str,
        *,
        rollback_warnings: list[str] | None = None,
        rollback_summary: str | None = None,
    ):
        super().__init__(message)
        self.rollback_warnings = [str(item).strip() for item in (rollback_warnings or []) if str(item).strip()]
        self.rollback_summary = str(rollback_summary or "").strip()


# ---------------------------------------------------------------------------
# Pure / static helpers (no handler state needed)
# ---------------------------------------------------------------------------


def _looks_like_openclaw_setup_request(prompt: str) -> bool:
    p = str(prompt or "").strip().lower()
    if not re.search(r"\bopen[\s_-]?claw\b", p):
        return False
    return any(term in p for term in ("install", "setup", "set up", "configure", "onboard"))


def _provider_choices() -> list[str]:
    return ["ollama", "openai", "anthropic", "openrouter", "gemini", "skip"]


def _normalize_openclaw_provider_choice(value: str) -> str:
    raw = str(value or "").strip().lower()
    aliases = {
        "google": "gemini",
        "google ai": "gemini",
        "google-ai": "gemini",
        "open ai": "openai",
        "open-ai": "openai",
        "open router": "openrouter",
        "open-router": "openrouter",
    }
    normalized = aliases.get(raw, raw)
    return normalized if normalized in set(_provider_choices()) else raw


def _extract_openclaw_provider_choice(text: str) -> str:
    raw = str(text or "").strip().lower()
    if not raw:
        return ""
    direct = _normalize_openclaw_provider_choice(raw)
    if direct in set(_provider_choices()):
        return direct

    patterns: list[tuple[str, str]] = [
        ("ollama", r"\bollama\b"),
        ("openai", r"\bopen[\s_-]?ai\b"),
        ("anthropic", r"\banthropic\b"),
        ("openrouter", r"\bopen[\s_-]?router\b"),
        ("gemini", r"\bgemini\b"),
        ("gemini", r"\bgoogle(?:\s+ai)?\b"),
        ("skip", r"\bskip\b"),
        ("skip", r"\bnone\b"),
        ("skip", r"\bn/?a\b"),
    ]
    hits: list[str] = []
    for provider, pattern in patterns:
        if re.search(pattern, raw) and provider not in hits:
            hits.append(provider)
    if len(hits) == 1:
        return hits[0]
    return ""


def _extract_openclaw_alias_choice(text: str, alias_options: list[str]) -> str:
    raw = str(text or "").strip()
    if not raw:
        return ""
    options = [str(alias).strip() for alias in alias_options if str(alias).strip()]
    if not options:
        return ""
    normalized_map = {alias.lower(): alias for alias in options}
    option_compact_map = {re.sub(r"[\s_-]+", "", alias.lower()): alias for alias in options}
    has_default_openclaw_option = any(alias.lower() == "openclaw" for alias in options)

    def _match_candidate(candidate_text: str) -> str:
        candidate = str(candidate_text or "").strip().lower()
        if not candidate:
            return ""
        candidate = re.sub(r"\s+(?:please|pls|thanks|thank you)$", "", candidate, flags=re.IGNORECASE).strip()
        candidate = re.sub(r"^(?:the\s+)", "", candidate, flags=re.IGNORECASE).strip()
        if not candidate:
            return ""
        if has_default_openclaw_option and candidate in {
            "default",
            "default alias",
            "default instance",
            "new default",
            "new default alias",
            "new default instance",
            "new alias",
            "new instance",
        }:
            return "openclaw"
        direct_match = normalized_map.get(candidate)
        if direct_match:
            return direct_match
        compact = re.sub(r"[\s_-]+", "", candidate)
        if not compact:
            return ""
        return option_compact_map.get(compact, "")

    direct = _match_candidate(raw)
    if direct:
        return direct

    low = raw.lower()
    patterns = (
        r"^(?:use|choose|select|pick|configure)(?:\s+alias|\s+instance)?(?:\s+(?:to|as))?\s+([a-z0-9_.-]+(?:\s+[a-z0-9_.-]+)?)(?:\s+(?:please|pls|thanks|thank you))?$",
        r"^(?:alias|instance)(?:\s+(?:is|=|:|to))?\s+([a-z0-9_.-]+(?:\s+[a-z0-9_.-]+)?)(?:\s+(?:please|pls|thanks|thank you))?$",
    )
    for pattern in patterns:
        match = re.match(pattern, low, flags=re.IGNORECASE)
        if not match:
            continue
        matched = _match_candidate(str(match.group(1) or "").strip())
        if matched:
            return matched

    hits: list[str] = []
    for alias in options:
        escaped = re.escape(alias.lower())
        alias_low = alias.lower()
        alias_pattern = rf"(?<![a-z0-9_.-]){escaped}(?![a-z0-9_.-])"
        if alias_low == "openclaw":
            alias_pattern = r"\bopen[\s_-]?claw\b"
        if re.search(alias_pattern, low):
            if alias not in hits:
                hits.append(alias)
    if len(hits) == 1:
        return hits[0]
    return ""


def _looks_like_openclaw_default_alias_choice(text: str) -> bool:
    raw = str(text or "").strip().lower()
    if not raw:
        return False
    patterns = (
        r"\b(?:use|choose|select|pick|configure|set)\s+(?:the\s+)?default\s+(?:alias|instance)\b",
        r"\bas\s+default\s+(?:alias|instance)\b",
        r"\bdefault\s+(?:alias|instance)\b",
        r"\bnew\s+(?:default\s+)?(?:alias|instance)\b",
        r"\bcreate\s+(?:a\s+)?new\s+(?:alias|instance)\b",
    )
    return any(re.search(pattern, raw) for pattern in patterns)


def _mentions_openclaw_provider_keyword(text: str) -> bool:
    raw = str(text or "").strip().lower()
    if not raw:
        return False
    patterns = (
        r"\bollama\b",
        r"\bopen[\s_-]?ai\b",
        r"\banthropic\b",
        r"\bopen[\s_-]?router\b",
        r"\bgemini\b",
        r"\bgoogle(?:\s+ai)?\b",
        r"\bskip\b",
        r"\bnone\b",
        r"\bn/?a\b",
    )
    return any(re.search(pattern, raw) for pattern in patterns)


def _openclaw_state(pending: dict) -> dict:
    state = pending.get("state", {}) if isinstance(pending, dict) else {}
    return dict(state) if isinstance(state, dict) else {}


def _is_sensitive_ui_text_prompt(prompt_message: str) -> bool:
    text = str(prompt_message or "").strip().lower()
    if not text:
        return False
    sensitive_terms = ("token", "secret", "password", "api key", "api_key")
    return any(term in text for term in sensitive_terms)


def _is_sensitive_openclaw_text_step(step: str) -> bool:
    return str(step or "").strip().lower() in {"provider_key", "telegram_token"}


def _list_armory_skill_names() -> list[str]:
    try:
        SM = _resolve_from_core("SkillManager", _SkillManager)
        return [str(skill.name).strip() for skill in SM().list_skills() if str(skill.name).strip()]
    except Exception:
        return []


def _armorer_setup_command_prefix() -> list[str]:
    return [sys.executable, "-m", "armorer.main"]


def _armorer_setup_env(extra_env: dict[str, str] | None = None) -> dict[str, str]:
    env = os.environ.copy()
    package_root = str(Path(__file__).resolve().parents[2])
    existing_pythonpath = str(env.get("PYTHONPATH") or "").strip()
    pythonpath_parts = [package_root]
    if existing_pythonpath:
        pythonpath_parts.extend(
            part for part in existing_pythonpath.split(os.pathsep) if part and part != package_root
        )
    env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts)
    env["ARMORER_SANDBOX"] = "0"
    if extra_env:
        env.update({k: str(v) for k, v in extra_env.items()})
    return env


def _parse_openclaw_config_value(raw: str) -> object:
    text = str(raw or "").strip()
    if not text:
        return ""
    for parser in (json.loads, ast.literal_eval):
        try:
            return parser(text)
        except Exception:
            continue
    return text


def _openclaw_provider_name(provider: str) -> str:
    return {"gemini": "google"}.get(str(provider or "").strip().lower(), str(provider or "").strip().lower())


def _openclaw_display_provider_name(provider: str) -> str:
    return {"google": "gemini"}.get(str(provider or "").strip().lower(), str(provider or "").strip().lower())


def _openclaw_model_ref(provider: str, model: str) -> str:
    provider_name = _openclaw_provider_name(provider)
    raw = str(model or "").strip()
    if not provider_name or not raw:
        return raw
    low = raw.lower()
    if provider_name == "openrouter":
        return raw if low.startswith("openrouter/") else f"openrouter/{raw}"
    for alias in {provider_name, "gemini" if provider_name == "google" else provider_name}:
        prefix = f"{alias}/"
        if low.startswith(prefix):
            return f"{provider_name}/{raw[len(prefix):]}"
    if "/" in raw:
        return raw
    return f"{provider_name}/{raw}"


def _openclaw_provider_from_model_ref(model_ref: str) -> str:
    raw = str(model_ref or "").strip().lower()
    if not raw or "/" not in raw:
        return ""
    return _openclaw_provider_name(raw.split("/", 1)[0])


def _openclaw_model_name_from_ref(model_ref: str) -> str:
    raw = str(model_ref or "").strip()
    if not raw or "/" not in raw:
        return raw
    if raw.lower().startswith("openrouter/"):
        return raw.split("/", 1)[1]
    return raw.split("/", 1)[1]


def _openclaw_model_provider_mismatch(provider: str, model_or_ref: str) -> str:
    provider_name = _openclaw_provider_name(provider)
    raw = str(model_or_ref or "").strip()
    if not provider_name or not raw or provider_name == "openrouter":
        return ""
    model_provider = _openclaw_provider_from_model_ref(raw)
    if model_provider and model_provider != provider_name:
        return model_provider
    return ""


def _provider_key_name_for_openclaw(provider: str) -> str:
    return {
        "openai": "OPENAI_API_KEY",
        "anthropic": "ANTHROPIC_API_KEY",
        "openrouter": "OPENROUTER_API_KEY",
        "google": "GEMINI_API_KEY",
        "ollama": "OLLAMA_API_KEY",
    }.get(_openclaw_provider_name(provider), "")


def _parse_openclaw_skill_paths(text: str) -> list[str]:
    vals: list[str] = []
    for raw in re.split(r"[\s,]+", str(text or "").strip()):
        token = raw.strip()
        if token and token not in vals:
            vals.append(token)
    return vals


def _resolve_openclaw_skill_path(compose_dir: Path, raw_path: str) -> Path:
    path_text = str(raw_path or "").strip()
    if not path_text:
        return compose_dir
    candidate = Path(path_text).expanduser()
    if not candidate.is_absolute():
        candidate = compose_dir / candidate
    return candidate


def _load_env_file(path: Path) -> dict[str, str]:
    data: dict[str, str] = {}
    if not path.exists():
        return data
    try:
        for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
            stripped = line.strip()
            if not stripped or stripped.startswith("#") or "=" not in line:
                continue
            key, value = line.split("=", 1)
            data[key.strip()] = value.strip()
    except Exception:
        return {}
    return data


def _upsert_env_file(path: Path, updates: dict[str, str]) -> None:
    filtered = {str(k).strip(): str(v) for k, v in (updates or {}).items() if str(k).strip() and v is not None}
    if not filtered:
        return
    existing_lines = path.read_text(encoding="utf-8", errors="ignore").splitlines() if path.exists() else []
    merged: list[str] = []
    seen: set[str] = set()
    for line in existing_lines:
        stripped = line.strip()
        if not stripped or stripped.startswith("#") or "=" not in line:
            merged.append(line)
            continue
        key, _value = line.split("=", 1)
        name = key.strip()
        if name in filtered:
            merged.append(f"{name}={filtered[name]}")
            seen.add(name)
        else:
            merged.append(line)
    for key, value in filtered.items():
        if key in seen:
            continue
        merged.append(f"{key}={value}")
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text("\n".join(merged).rstrip() + "\n", encoding="utf-8")


def _delete_env_file_keys(path: Path, keys: list[str]) -> None:
    filtered = {str(key).strip() for key in (keys or []) if str(key).strip()}
    if not filtered or not path.exists():
        return
    existing_lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
    merged: list[str] = []
    for line in existing_lines:
        stripped = line.strip()
        if not stripped or stripped.startswith("#") or "=" not in line:
            merged.append(line)
            continue
        key, _value = line.split("=", 1)
        if key.strip() in filtered:
            continue
        merged.append(line)
    path.write_text("\n".join(merged).rstrip() + ("\n" if merged else ""), encoding="utf-8")


def _load_openclaw_json(path: Path) -> dict[str, object]:
    if not path.exists():
        return {}
    try:
        loaded = json.loads(path.read_text(encoding="utf-8", errors="ignore") or "{}")
    except Exception:
        return {}
    return loaded if isinstance(loaded, dict) else {}


def _write_openclaw_json(path: Path, data: dict[str, object]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")


def _set_nested_openclaw_value(data: dict[str, object], path: list[str], value: object) -> None:
    node: dict[str, object] = data
    for key in path[:-1]:
        current = node.get(key)
        if not isinstance(current, dict):
            current = {}
            node[key] = current
        node = current
    node[path[-1]] = value


def _delete_nested_openclaw_value(data: dict[str, object], path: list[str]) -> None:
    node: object = data
    parents: list[tuple[dict[str, object], str]] = []
    for key in path[:-1]:
        if not isinstance(node, dict):
            return
        current = node.get(key)
        if not isinstance(current, dict):
            return
        parents.append((node, key))
        node = current
    if not isinstance(node, dict):
        return
    node.pop(path[-1], None)
    for parent, key in reversed(parents):
        child = parent.get(key)
        if isinstance(child, dict) and not child:
            parent.pop(key, None)
        else:
            break


def _get_nested_openclaw_value(data: dict[str, object], path: list[str]) -> object:
    current: object = data
    for key in path:
        if not isinstance(current, dict):
            return None
        current = current.get(key)
    return current


def _resolve_openclaw_compose_dir(install_path: str) -> Path:
    compose_dir = Path(str(install_path or ""))
    if os.path.exists("/.dockerenv"):
        return compose_dir
    raw = str(compose_dir)
    if raw.startswith("/app/installed/"):
        tail = raw.split("/app/installed/", 1)[1]
        candidates = [
            Path.cwd() / "installed" / tail,
            Path.home() / "workplace" / "SecureNeural" / "Armorer" / "installed" / tail,
        ]
        for candidate in candidates:
            if candidate.exists():
                return candidate
    return compose_dir


def _looks_like_openclaw_runtime_dir(path: Path) -> bool:
    target = Path(path)
    return target.exists() and (
        (target / "docker-compose.yml").exists()
        or (target / "docker-compose.yaml").exists()
        or (target / ".openclaw" / "openclaw.json").exists()
    )


def _candidate_openclaw_install_roots(app_name: str) -> list[Path]:
    alias = str(app_name or "openclaw").strip() or "openclaw"
    candidates: list[Path] = []
    seen: set[str] = set()

    def _add(path: Path) -> None:
        key = str(path)
        if key in seen:
            return
        seen.add(key)
        candidates.append(path)

    host_root = str(os.getenv("HOST_PROJECT_ROOT") or "").strip()
    if host_root:
        _add((Path(host_root) / "installed" / alias).resolve())
    _add((Path.cwd() / "installed" / alias).resolve())
    _add(Path("/app/installed") / alias)
    _add((Path.home() / "workplace" / "SecureNeural" / "Armorer" / "installed" / alias).resolve())
    return candidates


def _is_expected_openclaw_install_path(app_name: str, path: Path) -> bool:
    playbook = _resolve_from_core("resolve_playbook", _resolve_playbook)("openclaw")
    compose_subdir = str(getattr(playbook, "compose_subdir", ".") or ".").strip() or "."
    try:
        target = Path(path).resolve()
    except Exception:
        target = Path(path)
    for install_root in _candidate_openclaw_install_roots(app_name):
        candidates = [install_root.resolve()]
        if compose_subdir not in {"", "."}:
            candidates.insert(0, (install_root / compose_subdir).resolve())
        for candidate in candidates:
            if candidate == target:
                return True
    return False


def _looks_like_openclaw_secret_arg_name(raw: str) -> bool:
    text = str(raw or "").strip().lower()
    if not text:
        return False
    if any(marker in text for marker in ("token", "secret", "password")):
        return True
    if "api_key" in text or "apikey" in text:
        return True
    return bool(text in {"key"} or text.endswith("_key") or text.endswith("-key") or text.endswith(".key"))


def _looks_like_openclaw_secret_arg_value(raw: str) -> bool:
    text = str(raw or "").strip()
    if not text:
        return False
    if re.fullmatch(r"\d{5,}:[A-Za-z0-9_-]{20,}", text):
        return True
    if re.fullmatch(r"sk-[A-Za-z0-9_-]{20,}", text, flags=re.IGNORECASE):
        return True
    if re.fullmatch(r"AIza[0-9A-Za-z\-_]{20,}", text, flags=re.IGNORECASE):
        return True
    return False


def _redact_openclaw_command_args(run_args: tuple[str, ...]) -> str:
    parts = [str(arg) for arg in run_args]
    redacted: list[str] = []
    for idx, arg in enumerate(parts):
        prev = parts[idx - 1] if idx > 0 else ""
        if _looks_like_openclaw_secret_arg_name(prev):
            redacted.append("<redacted>")
            continue
        if _looks_like_openclaw_secret_arg_value(arg):
            redacted.append("<redacted>")
            continue
        redacted.append(arg)
    return " ".join(redacted)


def _openclaw_health_check_unavailable(error: Exception) -> bool:
    detail = str(error or "").strip().lower()
    patterns = (
        r"(?:unexpected|unknown(?: run)?) command[^\n]{0,80}\b(?:doctor|health)\b",
        r"(?:command not found|no such command)[^\n]{0,80}\b(?:doctor|health)\b",
        r"\b(?:doctor|health)\b\s+command\s+not\s+found\b",
        r"\b(?:doctor|health)\b\s+is\s+not\s+a\s+recognized\s+command\b",
        r"(?:^|[\"'` ])(?:doctor|health)(?:[\"'` ]|\b)\s+is\s+not\s+recognized\s+as\s+an?\s+internal\s+or\s+external\s+command\b",
        r"(?:not a recognized command)[^\n]{0,80}\b(?:doctor|health)\b",
        r"invalid choice[^\n]{0,80}[\"'` ](?:doctor|health)(?:[\"'` ]|$)",
        r"[\"'`](?:doctor|health)[\"'`]\s+is\s+an?\s+invalid choice\b",
        r"unsupported command[^\n]{0,80}\b(?:doctor|health)\b",
        r"(?:^|/bin/sh:.*\s)(?:doctor|health)\s*:\s*not found\b",
        r"`(?:doctor|health)`\s+not found\b",
    )
    if any(re.search(pattern, detail) for pattern in patterns):
        return True
    return False


def _openclaw_setup_failure_hint(error: Exception) -> str:
    detail = str(error or "").strip()
    low_detail = detail.lower()
    docker_cli_missing_markers = (
        "docker: command not found",
        "no such file or directory: 'docker'",
        'no such file or directory: "docker"',
        "exec: \"docker\": executable file not found",
        "exec: \"docker\": file not found",
    )
    windows_cli_missing = (
        "system cannot find the file specified" in low_detail
        and "docker" in low_detail
        and "//./pipe/docker_engine" not in low_detail
        and "docker_engine" not in low_detail
    )
    windows_not_recognized_docker = (
        "is not recognized as an internal or external command" in low_detail
        and "docker" in low_detail
    )
    if any(marker in low_detail for marker in docker_cli_missing_markers) or windows_cli_missing or windows_not_recognized_docker:
        return (
            "Docker CLI is required to finish OpenClaw setup. "
            "Install Docker and ensure `docker` is on PATH, then retry."
        )
    daemon_error_markers = (
        "/var/run/docker.sock",
        "permission denied while trying to connect",
        "cannot connect to the docker daemon",
        "is the docker daemon running?",
        "error during connect: this error may indicate that the docker daemon is not running",
        "//./pipe/docker_engine",
    )
    if any(marker in low_detail for marker in daemon_error_markers):
        return (
            "Docker daemon access is required to finish OpenClaw setup. "
            "Start Docker Desktop or grant access to /var/run/docker.sock, then retry."
        )
    return ""


def _openclaw_missing_runtime_prerequisites(runtime: dict[str, object]) -> list[str]:
    missing: list[str] = []
    provider = str(runtime.get("provider") or "").strip().lower()
    if not provider:
        missing.append("model provider")
    model = str(runtime.get("model") or "").strip()
    if not model:
        missing.append("model name")
    if provider and model and _openclaw_model_provider_mismatch(provider, str(runtime.get("model_ref") or model)):
        missing.append("model/provider alignment")
    provider_key_name = str(runtime.get("provider_key_name") or "").strip() or _provider_key_name_for_openclaw(provider)
    provider_key_value = str(runtime.get("provider_key_value") or "").strip()
    if provider in {"openai", "anthropic", "openrouter", "google"} and not provider_key_value:
        missing.append(provider_key_name)
    return missing


def _assert_openclaw_runtime_prerequisites(runtime: dict[str, object]) -> None:
    provider = str(runtime.get("provider") or "").strip().lower()
    if not provider:
        raise RuntimeError(
            "OpenClaw setup needs a model provider. Choose one in chat or configure MODEL_PROVIDER before retrying."
        )
    model = str(runtime.get("model") or "").strip()
    if not model:
        raise RuntimeError(
            "OpenClaw setup needs a model name. Enter one in chat or configure Armorer_MODEL before retrying."
        )
    mismatched_provider = _openclaw_model_provider_mismatch(provider, str(runtime.get("model_ref") or model))
    if mismatched_provider:
        selected_label = _openclaw_display_provider_name(provider) or provider
        mismatch_label = _openclaw_display_provider_name(mismatched_provider) or mismatched_provider
        raise RuntimeError(
            f"OpenClaw setup model/provider mismatch: the model prefix targets {mismatch_label}, "
            f"but the selected provider is {selected_label}. Enter a matching model or switch providers."
        )
    provider_key_name = str(runtime.get("provider_key_name") or "").strip() or _provider_key_name_for_openclaw(provider)
    provider_key_value = str(runtime.get("provider_key_value") or "").strip()
    if provider in {"openai", "anthropic", "openrouter", "google"} and not provider_key_value:
        raise RuntimeError(
            f"OpenClaw setup needs {provider_key_name}. Paste it in chat or configure it before retrying."
        )


def _truncate_openclaw_verification_output(text: str, limit: int = 1200) -> str:
    raw = str(text or "")
    safe_limit = max(120, int(limit or 0))
    if len(raw) <= safe_limit:
        return raw
    marker = "[output truncated; showing latest lines]\n"
    tail_len = max(1, safe_limit - len(marker))
    return marker + raw[-tail_len:].lstrip()


def _capture_openclaw_runtime_file(path: Path) -> dict[str, object]:
    try:
        if path.exists():
            return {"exists": True, "content": path.read_bytes()}
    except Exception:
        pass
    return {"exists": False, "content": b""}


def _restore_openclaw_runtime_file(path: Path, snapshot: dict[str, object]) -> None:
    exists = bool(snapshot.get("exists"))
    content = snapshot.get("content", b"")
    if not exists:
        path.unlink(missing_ok=True)
        return
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(bytes(content if isinstance(content, (bytes, bytearray)) else b""))


def _looks_like_agent_setup_request(prompt: str) -> bool:
    p = str(prompt or "").strip().lower()
    if not p:
        return False
    if re.search(r"\b(status|list|show|what(?:'s| is)?|which)\b", p) and re.search(r"\bagent", p):
        return False
    setup_match = bool(
        re.search(r"\b(install|setup|configure|onboard|provision|deploy)\b", p)
        or ("set up" in p)
    )
    agent_terms = ("agent", "assistant", "autogpt", "openclaw", "clawbot", "github.com/")
    return setup_match and any(t in p for t in agent_terms)


def _sanitize_openclaw_setup_detail_text(handler, detail: str) -> str:
    message = str(detail or "").strip()
    if not message:
        return ""
    message = re.sub(r"\b\d{5,}:[A-Za-z0-9_-]{20,}\b", "[REDACTED_TELEGRAM_TOKEN]", message)
    message = re.sub(r"(?i)\bsk-[A-Za-z0-9_-]{20,}\b", "[REDACTED_OPENAI_KEY]", message)
    message = re.sub(r"(?i)\bAIza[0-9A-Za-z\-_]{20,}\b", "[REDACTED_GEMINI_KEY]", message)
    try:
        message = handler.guard._regex_redact(message)
    except Exception:
        pass
    return message


def _allow_pending_openclaw_reply_through_focus_gate(handler, pending: dict, response: str) -> bool:
    if str(pending.get("type") or "").strip() != "openclaw_setup":
        return False
    step = str(pending.get("step") or "").strip().lower()
    raw = str(response or "").strip()
    low = raw.lower()
    if not step or not low:
        return False
    if _is_openclaw_cancel_reply(raw):
        return True
    if step == "alias":
        state = pending.get("state", {}) if isinstance(pending, dict) else {}
        alias_options = [x for x in _parse_csv_values(str(state.get("available_aliases") or "")) if x] if isinstance(state, dict) else []
        if "openclaw" not in alias_options:
            alias_options.append("openclaw")
        return bool(_extract_openclaw_alias_choice(raw, alias_options))
    if step == "provider":
        if _extract_openclaw_provider_choice(raw) in set(_provider_choices()):
            return True
        if _mentions_openclaw_provider_keyword(raw):
            return True
    if (_is_openclaw_skip_reply(raw) or _is_openclaw_clear_reply(raw)) and step in {
        "model",
        "base_url",
        "provider_key",
        "telegram_token",
        "telegram_admins",
        "alert_chat",
        "armory_skills",
        "skills",
    }:
        return True
    if step == "confirm" and (_is_openclaw_setup_yes(raw) or _is_openclaw_setup_no(raw)):
        return True
    if step == "model":
        if low in {"status", "help", "list", "agents", "running", "scan", "restart", "install", "setup"}:
            return False
        return bool(_extract_openclaw_model_reply_value(raw))
    if step == "provider_key":
        if low in {"status", "help", "list", "agents", "running", "scan", "restart", "install", "setup"}:
            return False
        return bool(_extract_openclaw_provider_key_reply_value(raw))
    if step == "base_url":
        if low in {"status", "help", "list", "agents", "running", "scan", "restart", "install", "setup"}:
            return False
        return bool(raw)
    if step == "armory_skills":
        if low in {"status", "help", "list", "agents", "running", "scan", "restart", "install", "setup"}:
            return False
        return bool(raw)
    if step == "skills":
        if low in {"status", "help", "list", "agents", "running", "scan", "restart", "install", "setup"}:
            return False
        return bool(raw)
    if step == "telegram_token":
        if low in {"status", "help", "list", "agents", "running", "scan", "restart", "install", "setup"}:
            return False
        return bool(raw)
    if step == "telegram_admins":
        if low in {"status", "help", "list", "agents", "running", "scan", "restart", "install", "setup"}:
            return False
        return bool(raw)
    if step == "alert_chat":
        if low in {"status", "help", "list", "agents", "running", "scan", "restart", "install", "setup"}:
            return False
        return bool(raw)
    return False


# ---------------------------------------------------------------------------
# Handler-dependent helpers
# ---------------------------------------------------------------------------


def _existing_openclaw_telegram_state(handler, app_name: str = "openclaw") -> dict[str, str]:
    config_token = str(
        handler.config_manager.get_scoped_secret(app_name, "TELEGRAM_BOT_TOKEN", fallback_to_global=False) or ""
    ).strip()
    config_open_mode = str(
        handler.config_manager.get_key("ARMORER_TELEGRAM_ALLOW_UNCONFIGURED_ADMINS") or ""
    ).strip().lower() in {"1", "true", "on", "yes"}
    config_admin_ids: list[str] = []
    for key_name in ("ARMORER_TELEGRAM_ADMIN_USER_IDS", "ARMORER_TELEGRAM_ADMIN_CHAT_IDS"):
        raw = str(handler.config_manager.get_key(key_name) or "").strip()
        ids, _invalid = _parse_telegram_admin_ids(raw)
        for admin_id in ids:
            if admin_id not in config_admin_ids:
                config_admin_ids.append(admin_id)

    config_alert_chat_id = str(handler.config_manager.get_key("TELEGRAM_ALERT_CHAT_ID") or "").strip()
    runtime_token = ""
    runtime_open_mode = False
    runtime_admin_ids: list[str] = []
    runtime_alert_chat_id = ""

    compose_dir = _existing_openclaw_install_dir(handler, app_name)
    if compose_dir is not None:
        cfg_data = _load_openclaw_json(compose_dir / ".openclaw" / "openclaw.json")
        env_data = _load_env_file(compose_dir / ".env")
        env_data.update(_load_env_file(compose_dir / ".openclaw" / ".env"))

        runtime_token = str(
            _get_nested_openclaw_value(cfg_data, ["channels", "telegram", "botToken"])
            or env_data.get("TELEGRAM_BOT_TOKEN")
            or ""
        ).strip()

        runtime_alert_chat = str(env_data.get("TELEGRAM_ALERT_CHAT_ID") or "").strip()
        if _is_valid_telegram_id(runtime_alert_chat):
            runtime_alert_chat_id = runtime_alert_chat

        runtime_group_policy = str(
            _get_nested_openclaw_value(cfg_data, ["channels", "telegram", "groupPolicy"]) or ""
        ).strip().lower()
        if runtime_group_policy == "open":
            runtime_open_mode = True
        elif runtime_group_policy == "allowlist":
            for key_path in (
                ["channels", "telegram", "allowFrom"],
                ["channels", "telegram", "groupAllowFrom"],
            ):
                raw_ids = _get_nested_openclaw_value(cfg_data, key_path)
                if isinstance(raw_ids, list):
                    tokens = [str(x).strip() for x in raw_ids]
                else:
                    tokens = _parse_csv_values(str(raw_ids or ""))
                ids, _invalid = _parse_telegram_admin_ids(",".join(tokens))
                for admin_id in ids:
                    if admin_id not in runtime_admin_ids:
                        runtime_admin_ids.append(admin_id)

    token = runtime_token or config_token
    alert_chat_id = runtime_alert_chat_id or config_alert_chat_id
    if runtime_admin_ids:
        admin_ids = runtime_admin_ids
        open_mode = False
    elif runtime_open_mode:
        admin_ids = []
        open_mode = True
    else:
        admin_ids = config_admin_ids
        open_mode = config_open_mode and not config_admin_ids

    if not token:
        return {}

    state = {"telegram_token": token}
    if admin_ids and not open_mode:
        state["telegram_admin_ids"] = ",".join(admin_ids)
    if alert_chat_id and _is_valid_telegram_id(alert_chat_id):
        state["alert_chat_id"] = alert_chat_id
    return state


def _merge_existing_openclaw_telegram_state(
    handler,
    state: dict,
    app_name: str = "openclaw",
    *,
    include_token: bool = True,
) -> dict:
    merged = dict(state)
    existing = _existing_openclaw_telegram_state(handler, app_name)
    for key in ("telegram_token", "telegram_admin_ids", "alert_chat_id"):
        if key == "telegram_token" and not include_token:
            continue
        if merged.get(key):
            continue
        value = str(existing.get(key) or "").strip()
        if value:
            merged[key] = value
    return merged


def _save_openclaw_state(handler, conversation_id: str, step: str, state: dict) -> None:
    handler._set_pending_decision(
        conversation_id,
        {
            "type": "openclaw_setup",
            "step": step,
            "created_at": time.time(),
            "state": state,
        },
    )


def _reprompt_openclaw_text_step(
    handler,
    conversation_id: str,
    step: str,
    state: dict,
    *,
    message: str,
    prompt_message: str,
    default: str | None = None,
) -> tuple[str, dict]:
    _save_openclaw_state(handler, conversation_id, step, state)
    ui_action: dict[str, object] = {"type": "text", "message": prompt_message}
    is_sensitive = _is_sensitive_openclaw_text_step(step) or _is_sensitive_ui_text_prompt(prompt_message)
    if is_sensitive:
        ui_action["sensitive"] = True
    if default is not None and not is_sensitive:
        ui_action["default"] = default
    return "", {"message": message, "ui_action": ui_action}


def _run_openclaw_setup_command(
    handler,
    args: list[str],
    *,
    task_id: str,
    label: str,
    cwd: Path | None = None,
    extra_env: dict[str, str] | None = None,
    retries: int = 1,
    timeout_seconds: int = 300,
) -> subprocess.CompletedProcess:
    SupersededTaskError = _superseded_error()
    env = _armorer_setup_env(extra_env)
    cmd = _armorer_setup_command_prefix() + args
    last_result: subprocess.CompletedProcess | None = None
    last_error_detail = ""

    def _format_labeled_failure(detail: str) -> str:
        text = str(detail or "").strip()
        if not text:
            text = "unknown error"
        prefix = f"{label} failed:"
        if text.lower().startswith(prefix.lower()):
            return text[:700]
        return f"{prefix} {text[:700]}"

    for attempt in range(1, retries + 1):
        handler._raise_if_task_superseded(task_id)
        note = label if retries == 1 else f"{label} (attempt {attempt}/{retries})"
        handler._write_task_status(task_id, note)
        try:
            result = subprocess.run(
                cmd,
                cwd=str(cwd) if cwd else None,
                env=env,
                capture_output=True,
                text=True,
                timeout=timeout_seconds,
            )
        except SupersededTaskError:
            raise
        except subprocess.TimeoutExpired as exc:
            timeout_value = int(exc.timeout) if exc.timeout is not None else timeout_seconds
            last_error_detail = f"timed out after {timeout_value}s"
            if attempt < retries:
                handler._write_task_status(task_id, f"{note} failed, retrying: {last_error_detail}")
                time.sleep(1)
            continue
        except Exception as exc:
            detail = str(exc).strip() or exc.__class__.__name__
            safe_detail = _sanitize_openclaw_setup_detail_text(handler, detail)
            last_error_detail = safe_detail or detail
            if attempt < retries:
                handler._write_task_status(task_id, f"{note} failed, retrying: {(safe_detail or detail)[:180]}")
                time.sleep(1)
                continue
            raise RuntimeError(_format_labeled_failure(safe_detail or detail)) from exc
        last_result = result
        if result.returncode == 0:
            return result
        detail = ((result.stderr or "") + "\n" + (result.stdout or "")).strip()
        if not detail:
            detail = f"exit code {result.returncode}"
        safe_detail = _sanitize_openclaw_setup_detail_text(handler, detail)
        last_error_detail = safe_detail or detail
        if attempt < retries:
            handler._write_task_status(task_id, f"{note} failed, retrying: {(safe_detail or detail)[:180]}")
            time.sleep(1)
    if last_result is None:
        detail = last_error_detail or f"timed out after {timeout_seconds}s"
        raise RuntimeError(_format_labeled_failure(detail))
    detail = last_error_detail or ((last_result.stderr or "") + "\n" + (last_result.stdout or "")).strip()
    raise RuntimeError(_format_labeled_failure(detail))


def _openclaw_compose_run(
    handler,
    compose_dir: Path,
    task_id: str,
    *run_args: str,
    timeout_seconds: int = 120,
    retries: int = 2,
) -> subprocess.CompletedProcess:
    SupersededTaskError = _superseded_error()
    command_label = f"OpenClaw config: {_redact_openclaw_command_args(tuple(run_args[:4]))}"
    last_error: str | None = None
    for attempt in range(1, retries + 1):
        handler._raise_if_task_superseded(task_id)
        note = command_label if retries == 1 else f"{command_label} (attempt {attempt}/{retries})"
        handler._write_task_status(task_id, note)
        try:
            from armorer.docker_runner import compose_run as _compose_run
            result = _compose_run(
                cwd=compose_dir,
                service="openclaw-cli",
                run_args=run_args,
                timeout=timeout_seconds,
            )
            if result.returncode == 124 and "timeout" in result.stderr.lower():
                raise subprocess.TimeoutExpired(
                    cmd=["docker", "compose", "run", "openclaw-cli", *run_args],
                    timeout=timeout_seconds,
                )
        except SupersededTaskError:
            raise
        except subprocess.TimeoutExpired as exc:
            timeout_value = int(exc.timeout) if exc.timeout is not None else timeout_seconds
            last_error = (
                f"OpenClaw command timed out ({_redact_openclaw_command_args(run_args)}): "
                f"timed out after {timeout_value}s"
            )
            if attempt < retries:
                handler._write_task_status(task_id, f"{note} failed, retrying: timed out after {timeout_value}s")
                time.sleep(1)
                continue
            raise RuntimeError(last_error) from exc
        except Exception as exc:
            detail = str(exc).strip() or exc.__class__.__name__
            safe_detail = _sanitize_openclaw_setup_detail_text(handler, detail)
            last_error = (
                f"OpenClaw command failed ({_redact_openclaw_command_args(run_args)}): "
                f"{(safe_detail or detail)[:700]}"
            )
            if attempt < retries:
                handler._write_task_status(task_id, f"{note} failed, retrying: {(safe_detail or detail)[:180]}")
                time.sleep(1)
                continue
            raise RuntimeError(last_error) from exc
        if result.returncode == 0:
            return result
        detail = ((result.stderr or "") + "\n" + (result.stdout or "")).strip()
        if not detail:
            detail = f"exit code {result.returncode}"
        safe_detail = _sanitize_openclaw_setup_detail_text(handler, detail)
        last_error = (
            f"OpenClaw command failed ({_redact_openclaw_command_args(run_args)}): "
            f"{(safe_detail or detail)[:700]}"
        )
        if attempt < retries:
            handler._write_task_status(task_id, f"{note} failed, retrying: {(safe_detail or detail)[:180]}")
            time.sleep(1)
            continue
        raise RuntimeError(last_error)
    raise RuntimeError(
        last_error
        or f"OpenClaw command failed ({_redact_openclaw_command_args(run_args)})."
    )


def _sanitize_openclaw_setup_error_message(handler, source: str, error: Exception | str) -> str:
    fallback = error.__class__.__name__ if isinstance(error, Exception) else "OpenClaw setup error"
    message = _sanitize_openclaw_setup_detail_text(handler, str(error or "").strip())
    if not message:
        message = fallback
    try:
        scan = handler.guard.inspect_operator(message) if source == "cli" else handler.guard.inspect_output(message)
        sanitized = str(scan.sanitized_text or "").strip()
        if sanitized:
            message = sanitized
    except Exception:
        pass
    return message


def _run_openclaw_telegram_verification_step(
    handler,
    task_id: str,
    label: str,
    call: Callable[[], object],
    *,
    retries: int = 3,
) -> object:
    SupersededTaskError = _superseded_error()
    last_exc: Exception | None = None
    for attempt in range(1, retries + 1):
        handler._raise_if_task_superseded(task_id)
        note = label if retries == 1 else f"{label} (attempt {attempt}/{retries})"
        handler._write_task_status(task_id, note)
        try:
            return call()
        except SupersededTaskError:
            raise
        except Exception as exc:
            last_exc = exc
            if attempt < retries:
                detail = str(exc).strip() or exc.__class__.__name__
                safe_detail = _sanitize_openclaw_setup_detail_text(handler, detail)
                handler._write_task_status(task_id, f"{note} failed, retrying: {(safe_detail or detail)[:180]}")
                time.sleep(1)
                continue
            break
    assert last_exc is not None
    raise last_exc


def _verify_openclaw_telegram_channel(
    handler,
    app_name: str,
    state: dict,
    runtime: dict[str, object],
    task_id: str,
) -> dict[str, object]:
    SupersededTaskError = _superseded_error()
    existing = _existing_openclaw_telegram_state(handler, app_name)
    token = str(state.get("telegram_token") or existing.get("telegram_token") or "").strip()
    if not token:
        return {"bot_username": "", "verified_chat_ids": []}

    import sys as _sys
    _core = _sys.modules.get("armorer.core")
    _TG = getattr(_core, "TelegramGateway", _TelegramGateway) if _core else _TelegramGateway
    gateway = _TG(bot_token=token)
    try:
        bot_info = _run_openclaw_telegram_verification_step(
            handler,
            task_id,
            "Verifying Telegram bot token with Telegram API",
            gateway.get_me,
            retries=3,
        )
    except SupersededTaskError:
        raise
    except Exception as exc:
        detail = str(exc).strip() or exc.__class__.__name__
        safe_detail = _sanitize_openclaw_setup_detail_text(handler, detail)
        raise RuntimeError(f"Telegram bot token verification failed: {safe_detail or detail}") from exc
    bot_username = str(bot_info.get("username") or "").strip()

    admin_source = ""
    if not bool(state.get("clear_telegram_admin_ids")):
        admin_source = str(state.get("telegram_admin_ids") or existing.get("telegram_admin_ids") or "")
    admin_ids = [
        value
        for value in _parse_csv_values(admin_source)
        if _is_valid_telegram_id(str(value))
    ]
    alert_chat_id = ""
    if not bool(state.get("clear_alert_chat_id")):
        alert_chat_id = str(runtime.get("alert_chat_id") or "").strip()
    chat_ids: list[str] = []
    seen: set[str] = set()
    for raw in admin_ids + ([alert_chat_id] if _is_valid_telegram_id(alert_chat_id) else []):
        chat_id = str(raw or "").strip()
        if not chat_id or chat_id in seen:
            continue
        seen.add(chat_id)
        try:
            _run_openclaw_telegram_verification_step(
                handler,
                task_id,
                f"Verifying Telegram chat {chat_id}",
                lambda cid=chat_id: gateway.get_chat(cid),
                retries=3,
            )
        except SupersededTaskError:
            raise
        except Exception as exc:
            detail = str(exc).strip() or exc.__class__.__name__
            safe_detail = _sanitize_openclaw_setup_detail_text(handler, detail)
            raise RuntimeError(f"Telegram chat verification failed for {chat_id}: {safe_detail or detail}") from exc
        chat_ids.append(chat_id)

    return {
        "bot_username": bot_username,
        "verified_chat_ids": chat_ids,
    }


def _resolve_openclaw_runtime_compose_dir(handler, app_name: str) -> Path | None:
    playbook = _resolve_from_core("resolve_playbook", _resolve_playbook)("openclaw")
    compose_subdir = str(getattr(playbook, "compose_subdir", ".") or ".").strip() or "."
    registry_app = handler.registry.get_app(app_name)
    if registry_app:
        install_root = _resolve_openclaw_compose_dir(registry_app.install_path)
        registry_candidates = [install_root.resolve()]
        if compose_subdir not in {"", "."}:
            registry_candidates.insert(0, (install_root / compose_subdir).resolve())
        for candidate in registry_candidates:
            if _looks_like_openclaw_runtime_dir(candidate):
                return candidate

    seen: set[str] = set()
    for install_root in _candidate_openclaw_install_roots(app_name):
        candidates = [install_root.resolve()]
        if compose_subdir not in {"", "."}:
            candidates.insert(0, (install_root / compose_subdir).resolve())
        for candidate in candidates:
            key = str(candidate)
            if key in seen:
                continue
            seen.add(key)
            if _looks_like_openclaw_runtime_dir(candidate):
                return candidate
    return None


def _existing_openclaw_install_dir(handler, app_name: str) -> Path | None:
    compose_dir = _resolve_openclaw_runtime_compose_dir(handler, app_name)
    if compose_dir is not None:
        return compose_dir
    registry_app = handler.registry.get_app(app_name)
    if registry_app is None:
        return None
    install_dir = _resolve_openclaw_compose_dir(registry_app.install_path)
    if install_dir.exists() and _is_expected_openclaw_install_path(app_name, install_dir):
        return install_dir
    return None


def _recover_openclaw_registry_entry(handler, app_name: str, task_id: str) -> AppMetadata | None:
    existing = handler.registry.get_app(app_name)
    existing_compose_dir: Path | None = None
    if existing:
        existing_compose_dir = _resolve_openclaw_compose_dir(existing.install_path)
        if _looks_like_openclaw_runtime_dir(existing_compose_dir):
            return existing

    playbook = _resolve_from_core("resolve_playbook", _resolve_playbook)("openclaw")
    run_commands: list[dict[str, str]] = []
    monitor_targets: list[str] = []
    monitoring_mode = "hybrid"
    if playbook:
        monitor_targets = [str(x) for x in (getattr(playbook, "monitor_targets", []) or []) if str(x).strip()]
        monitoring_mode = str(getattr(playbook, "monitoring_mode", "hybrid") or "hybrid").strip() or "hybrid"
        run_commands = [
            {
                "label": str(cmd.label),
                "service": str(cmd.service),
                "command": str(cmd.command),
                "category": str(cmd.category or "run"),
                "description": str(cmd.description or ""),
            }
            for cmd in (getattr(playbook, "run_commands", []) or [])
            if str(getattr(cmd, "command", "") or "").strip() and str(getattr(cmd, "service", "") or "").strip()
        ]

    compose_dir = _resolve_openclaw_runtime_compose_dir(handler, app_name)
    if (
        compose_dir is None
        and bool(str(task_id or "").strip())
        and existing_compose_dir is not None
        and existing_compose_dir.exists()
        and _is_expected_openclaw_install_path(app_name, existing_compose_dir)
    ):
        compose_dir = existing_compose_dir
    if compose_dir is not None:
        action = "Refreshing stale" if existing is not None else "Recovering missing"
        handler._write_task_status(task_id, f"{action} OpenClaw registry entry from {compose_dir}")
        recovered = AppMetadata(
            name=app_name,
            install_path=str(compose_dir),
            agent_id="openclaw",
            installed_at=time.time(),
            description="Recovered OpenClaw install",
            monitor_targets=monitor_targets,
            monitoring_mode=monitoring_mode,
            run_commands=run_commands,
        )
        try:
            handler.registry.add_app(recovered)
        except OSError as exc:
            logger.debug("Continuing without persisted OpenClaw registry metadata for %s: %s", app_name, exc)
            handler._write_task_status(
                task_id,
                "OpenClaw install completed, but Armorer could not save registry metadata. Continuing from the install root.",
            )
        persisted = handler.registry.get_app(app_name)
        if persisted is not None:
            persisted_compose_dir = _resolve_openclaw_compose_dir(persisted.install_path)
            if _looks_like_openclaw_runtime_dir(persisted_compose_dir):
                return persisted
        return recovered
    return None


def _get_openclaw_app_metadata_with_fallback(handler, app_name: str) -> AppMetadata | None:
    existing = handler.registry.get_app(app_name)
    if str(app_name or "").strip() != "openclaw":
        return existing

    if existing is not None:
        install_dir = _resolve_openclaw_compose_dir(existing.install_path)
        if _looks_like_openclaw_runtime_dir(install_dir):
            return existing

    recovered = _recover_openclaw_registry_entry(handler, app_name, "")
    return recovered or existing


def _list_apps_with_openclaw_fallback(handler) -> list[AppMetadata]:
    apps = handler.registry.list_apps()
    fallback = _get_openclaw_app_metadata_with_fallback(handler, "openclaw")
    if fallback is None:
        return apps

    def _is_default_openclaw_alias(app_meta: AppMetadata) -> bool:
        app_name = str(app_meta.name or "").strip()
        agent_id = str(app_meta.agent_id or app_name).strip().lower()
        return app_name == "openclaw" and agent_id == "openclaw"

    def _is_live_default_openclaw(app_meta: AppMetadata) -> bool:
        if not _is_default_openclaw_alias(app_meta):
            return False
        install_dir = _resolve_openclaw_compose_dir(app_meta.install_path)
        return _looks_like_openclaw_runtime_dir(install_dir)

    refreshed_apps = handler.registry.list_apps()
    if any(_is_live_default_openclaw(app_meta) for app_meta in refreshed_apps):
        return refreshed_apps

    merged: list[AppMetadata] = []
    replaced_default = False
    for app_meta in refreshed_apps:
        if _is_default_openclaw_alias(app_meta):
            if not replaced_default:
                merged.append(fallback)
                replaced_default = True
            continue
        merged.append(app_meta)
    if not replaced_default:
        merged.append(fallback)
    return merged


def _preferred_openclaw_setup_alias(handler) -> str:
    aliases = _openclaw_setup_alias_candidates(handler)
    if "openclaw" in aliases:
        return "openclaw"
    if len(aliases) == 1:
        return aliases[0]
    return "openclaw"


def _openclaw_setup_alias_candidates(handler) -> list[str]:
    aliases: list[str] = []
    for app_meta in handler.registry.list_apps():
        name = str(getattr(app_meta, "name", "") or "").strip()
        if not name:
            continue
        agent_id = str(getattr(app_meta, "agent_id", "") or name).strip().lower()
        if agent_id == "openclaw":
            aliases.append(name)
    return sorted({alias for alias in aliases if alias})


def _existing_openclaw_runtime_settings(handler, app_name: str) -> dict[str, object]:
    compose_dir = _existing_openclaw_install_dir(handler, app_name)
    if compose_dir is None:
        return {}
    cfg_data = _load_openclaw_json(compose_dir / ".openclaw" / "openclaw.json")
    env_data = _load_env_file(compose_dir / ".env")
    env_data.update(_load_env_file(compose_dir / ".openclaw" / ".env"))
    model_ref = str(_get_nested_openclaw_value(cfg_data, ["agents", "defaults", "model", "primary"]) or "").strip()
    provider = _openclaw_provider_from_model_ref(model_ref)
    model = _openclaw_model_name_from_ref(model_ref)
    base_url = str(_get_nested_openclaw_value(cfg_data, ["models", "providers", "ollama", "baseUrl"]) or "").strip()
    skill_paths_raw = _get_nested_openclaw_value(cfg_data, ["skills", "load", "extraDirs"])
    skill_paths = [str(x).strip() for x in skill_paths_raw] if isinstance(skill_paths_raw, list) else []
    if not provider:
        provider = _openclaw_provider_name(str(env_data.get("MODEL_PROVIDER") or "").strip().lower())
    if not model:
        model = str(env_data.get("Armorer_MODEL") or "").strip()
    if not base_url:
        base_url = str(env_data.get("OLLAMA_BASE_URL") or "").strip()
    if not skill_paths:
        skill_paths = _parse_openclaw_skill_paths(str(env_data.get("ARMORER_SKILL_SCAN_PATHS") or ""))
    alert_chat_id = str(env_data.get("TELEGRAM_ALERT_CHAT_ID") or "").strip()

    provider_key_name = ""
    provider_key_value = ""
    env_key_order = [
        "OPENAI_API_KEY",
        "ANTHROPIC_API_KEY",
        "OPENROUTER_API_KEY",
        "GEMINI_API_KEY",
        "GOOGLE_API_KEY",
        "OLLAMA_API_KEY",
    ]
    if provider:
        provider_key_name = _provider_key_name_for_openclaw(provider)
        if provider_key_name == "GEMINI_API_KEY":
            provider_key_value = str(env_data.get("GEMINI_API_KEY") or env_data.get("GOOGLE_API_KEY") or "").strip()
        else:
            provider_key_value = str(env_data.get(provider_key_name) or "").strip()
    if not provider_key_name:
        for key_name in env_key_order:
            value = str(env_data.get(key_name) or "").strip()
            if not value:
                continue
            provider_key_name = "GEMINI_API_KEY" if key_name == "GOOGLE_API_KEY" else key_name
            provider_key_value = value
            break

    return {
        "provider": provider,
        "model": model,
        "provider_key_name": provider_key_name,
        "provider_key_value": provider_key_value,
        "base_url": base_url,
        "skill_paths": skill_paths,
        "alert_chat_id": alert_chat_id if _is_valid_telegram_id(alert_chat_id) else "",
    }


def _resolve_openclaw_runtime_settings(handler, state: dict) -> dict[str, object]:
    app_name = str(state.get("alias") or "openclaw").strip() or "openclaw"
    existing = _existing_openclaw_runtime_settings(handler, app_name)
    chosen_provider = str(state.get("provider") or "").strip().lower()
    configured_provider = str(handler.config_manager.get_key("MODEL_PROVIDER") or "").strip().lower()
    provider = (
        chosen_provider
        if chosen_provider and chosen_provider != "skip"
        else (str(existing.get("provider") or "").strip().lower() or configured_provider)
    )
    provider_name = _openclaw_provider_name(provider)
    model = str(state.get("model") or existing.get("model") or handler.config_manager.get_key("Armorer_MODEL") or "").strip()
    provider_key_name = str(state.get("provider_key_name") or "").strip()
    if not provider_key_name and chosen_provider and chosen_provider != "skip":
        provider_key_name = _provider_key_name_for_openclaw(provider_name)
    if not provider_key_name:
        provider_key_name = str(existing.get("provider_key_name") or "").strip()
    if not provider_key_name:
        provider_key_name = _provider_key_name_for_openclaw(provider_name)
    provider_key_value = str(state.get("provider_key_value") or "").strip()
    if not provider_key_value and provider_key_name:
        existing_provider = str(existing.get("provider") or "").strip().lower()
        existing_key_name = str(existing.get("provider_key_name") or "").strip()
        existing_key_value = ""
        if existing_provider == provider_name and existing_key_name == provider_key_name:
            existing_key_value = str(existing.get("provider_key_value") or "").strip()
        if provider_key_name == "GEMINI_API_KEY":
            provider_key_value = str(
                existing_key_value
                or handler.config_manager.get_key("GEMINI_API_KEY")
                or handler.config_manager.get_key("GOOGLE_API_KEY")
                or ""
            ).strip()
        else:
            provider_key_value = str(existing_key_value or handler.config_manager.get_key(provider_key_name) or "").strip()
    base_url = str(state.get("base_url") or "").strip()
    if not base_url and provider_name == "ollama":
        base_url = str(existing.get("base_url") or handler.config_manager.get_key("OLLAMA_BASE_URL") or "").strip()
    alert_chat_id = ""
    if not bool(state.get("clear_alert_chat_id")):
        alert_chat_id = str(
            state.get("alert_chat_id")
            or existing.get("alert_chat_id")
            or handler.config_manager.get_key("TELEGRAM_ALERT_CHAT_ID")
            or ""
        ).strip()
    existing_skill_paths = existing.get("skill_paths") or []
    skill_paths_raw = ""
    if not bool(state.get("clear_skill_paths")):
        skill_paths_raw = str(
            state.get("skills_paths")
            or " ".join(str(x) for x in existing_skill_paths)
            or handler.config_manager.get_key("ARMORER_SKILL_SCAN_PATHS")
            or ""
        ).strip()
    skill_paths = _parse_openclaw_skill_paths(skill_paths_raw)

    env_updates: dict[str, str] = {}
    if provider_key_name and provider_key_value:
        env_updates[provider_key_name] = provider_key_value
        if provider_name == "google":
            env_updates.setdefault("GOOGLE_API_KEY", provider_key_value)
    if provider_name == "ollama":
        env_updates.setdefault("OLLAMA_API_KEY", provider_key_value or "ollama-local")
    if _is_valid_telegram_id(alert_chat_id):
        env_updates["TELEGRAM_ALERT_CHAT_ID"] = alert_chat_id

    return {
        "provider": provider_name,
        "model": model,
        "model_ref": _openclaw_model_ref(provider_name, model),
        "provider_key_name": provider_key_name,
        "provider_key_value": provider_key_value,
        "env_updates": env_updates,
        "base_url": base_url,
        "skill_paths": skill_paths,
        "alert_chat_id": alert_chat_id if _is_valid_telegram_id(alert_chat_id) else "",
    }


def _existing_openclaw_injected_skills(handler, app_name: str) -> list[str]:
    seen: set[str] = set()
    names: list[str] = []
    registry_app = handler.registry.get_app(app_name)
    if registry_app:
        for raw in registry_app.injected_skills or []:
            name = str(raw).strip()
            if not name or name in seen:
                continue
            names.append(name)
            seen.add(name)

    available_skill_names = {str(name).strip() for name in _list_armory_skill_names() if str(name).strip()}
    compose_dir = _resolve_openclaw_runtime_compose_dir(handler, app_name)
    if available_skill_names and compose_dir is not None:
        cfg_data = _load_openclaw_json(compose_dir / ".openclaw" / "openclaw.json")
        mcp_servers = cfg_data.get("mcpServers", {}) if isinstance(cfg_data.get("mcpServers"), dict) else {}
        for raw in mcp_servers.keys():
            name = str(raw).strip()
            if not name or name in seen or name not in available_skill_names:
                continue
            names.append(name)
            seen.add(name)
    return names


def _openclaw_registry_entry_matches_runtime(handler, app_name: str, compose_dir: Path) -> bool:
    registry_app = handler.registry.get_app(app_name)
    if registry_app is None:
        return False
    registry_compose_dir = _resolve_openclaw_compose_dir(registry_app.install_path)
    if not _looks_like_openclaw_runtime_dir(registry_compose_dir):
        return False
    return registry_compose_dir.resolve() == compose_dir.resolve()


def _openclaw_registry_has_health_check(handler, app_name: str) -> bool:
    def _has_health_check(run_commands: object) -> bool:
        if not isinstance(run_commands, list):
            return False
        for raw_cmd in run_commands:
            if isinstance(raw_cmd, dict):
                command = str(raw_cmd.get("command") or "").strip().lower()
            else:
                command = str(getattr(raw_cmd, "command", "") or "").strip().lower()
            if not command:
                continue
            if command.split()[0] in {"doctor", "health"}:
                return True
        return False

    registry_app = _get_openclaw_app_metadata_with_fallback(handler, app_name)
    if registry_app is not None and _has_health_check(registry_app.run_commands):
        return True

    playbook = _resolve_from_core("resolve_playbook", _resolve_playbook)("openclaw")
    if playbook is not None and _has_health_check(getattr(playbook, "run_commands", None)):
        return True
    return False


def _resolved_openclaw_armory_skill_names(handler, state: dict, app_name: str) -> list[str]:
    if bool(state.get("clear_armory_skills")):
        return []
    for key_name in ("armory_skills", "resolved_armory_skills"):
        names = [x for x in _parse_csv_values(str(state.get(key_name) or "")) if x]
        if names:
            return names
    return _existing_openclaw_injected_skills(handler, app_name)


def _openclaw_resume_to_confirm_step(handler, state: dict) -> str:
    runtime = _resolve_openclaw_runtime_settings(handler, state)
    provider = str(runtime.get("provider") or "").strip().lower()
    if not provider:
        return "provider"
    model = str(runtime.get("model") or "").strip()
    if not model:
        return "model"
    if _openclaw_model_provider_mismatch(provider, str(runtime.get("model_ref") or model)):
        return "model_provider_mismatch"
    provider_key_name = str(runtime.get("provider_key_name") or "").strip() or _provider_key_name_for_openclaw(provider)
    provider_key_value = str(runtime.get("provider_key_value") or "").strip()
    if provider in {"openai", "anthropic", "openrouter", "google"} and provider_key_name and not provider_key_value:
        return "provider_key"
    return "confirm"


def _resume_openclaw_missing_runtime_prerequisite(
    handler,
    conversation_id: str,
    state: dict,
) -> tuple[str, dict]:
    state = dict(state)
    state["resume_to_confirm"] = True
    next_step = _openclaw_resume_to_confirm_step(handler, state)
    if next_step == "provider":
        _save_openclaw_state(handler, conversation_id, "provider", state)
        return "", {
            "message": "OpenClaw still needs a model provider before install. Choose one now.",
            "ui_action": {
                "type": "select",
                "message": "Choose OpenClaw provider",
                "options": _provider_choices(),
            },
        }

    if next_step == "model":
        provider = str(_resolve_openclaw_runtime_settings(handler, state).get("provider") or state.get("provider") or "").strip().lower()
        return _reprompt_openclaw_text_step(
            handler,
            conversation_id,
            "model",
            state,
            message=(
                "OpenClaw still needs a model name before install. "
                "Enter it now. `skip` will not work until a model is configured."
            ),
            prompt_message="Enter model name",
            default="llama3.2" if provider == "ollama" else "",
        )

    if next_step == "model_provider_mismatch":
        runtime = _resolve_openclaw_runtime_settings(handler, state)
        provider = str(runtime.get("provider") or state.get("provider") or "").strip().lower()
        mismatched_provider = _openclaw_model_provider_mismatch(
            provider,
            str(runtime.get("model_ref") or runtime.get("model") or ""),
        )
        selected_label = _openclaw_display_provider_name(provider) or "the selected provider"
        mismatch_label = _openclaw_display_provider_name(mismatched_provider) or mismatched_provider or "another provider"
        return _reprompt_openclaw_text_step(
            handler,
            conversation_id,
            "model",
            state,
            message=(
                f"OpenClaw still needs a {selected_label} model before install. "
                f"The current model prefix targets {mismatch_label}, which does not match the selected provider. "
                "Enter a matching model name now. `skip` will not work until the mismatch is resolved."
            ),
            prompt_message="Enter model name",
            default="llama3.2" if provider == "ollama" else "",
        )

    if next_step == "provider_key":
        provider = str(_resolve_openclaw_runtime_settings(handler, state).get("provider") or "").strip().lower()
        key_name = _provider_key_name_for_openclaw(provider) or str(state.get("provider_key_name") or "API key").strip()
        state["provider_key_name"] = key_name
        return _reprompt_openclaw_text_step(
            handler,
            conversation_id,
            "provider_key",
            state,
            message=(
                f"OpenClaw still needs {key_name} before install. "
                "Paste it now. `skip` will not work until the key is configured."
            ),
            prompt_message=f"Enter {key_name}",
        )

    state.pop("resume_to_confirm", None)
    _save_openclaw_state(handler, conversation_id, "confirm", state)
    return "", {
        "message": _build_openclaw_confirmation_message(handler, state),
        "ui_action": {
            "type": "confirm",
            "message": "Install and configure OpenClaw now?",
            "default": True,
        },
    }


def _build_openclaw_confirmation_message(handler, state: dict) -> str:
    app_name = str(state.get("alias") or "openclaw").strip() or "openclaw"
    runtime = _resolve_openclaw_runtime_settings(handler, state)
    existing_telegram = _existing_openclaw_telegram_state(handler, app_name)
    admin_ids = []
    if not bool(state.get("clear_telegram_admin_ids")):
        admin_ids = [
            x
            for x in _parse_csv_values(
                str(state.get("telegram_admin_ids") or existing_telegram.get("telegram_admin_ids") or "")
            )
            if x
        ]

    chosen_provider = str(state.get("provider") or "").strip().lower()
    provider_display = (
        chosen_provider
        if chosen_provider and chosen_provider != "skip"
        else _openclaw_display_provider_name(str(runtime.get("provider") or "").strip())
    )
    provider_display = provider_display or "(missing)"

    model_display = str(state.get("model") or "").strip() or str(runtime.get("model") or "").strip() or "(missing)"
    telegram_enabled = "yes" if str(state.get("telegram_token") or existing_telegram.get("telegram_token") or "").strip() else "no"
    telegram_access = "disabled"
    if telegram_enabled == "yes":
        telegram_access = f"allowlist ({', '.join(admin_ids)})" if admin_ids else "open"
    alert_chat_display = str(runtime.get("alert_chat_id") or "").strip() or "none"

    requested_skills = _resolved_openclaw_armory_skill_names(handler, state, app_name)
    if bool(state.get("clear_armory_skills")):
        cleared_skills = [x for x in _parse_csv_values(str(state.get("cleared_armory_skills") or "")) if x]
        if not cleared_skills:
            cleared_skills = _existing_openclaw_injected_skills(handler, app_name)
        armory_skills = f"clear ({', '.join(cleared_skills)})" if cleared_skills else "clear"
    else:
        armory_skills = ", ".join(requested_skills) if requested_skills else "none"

    skill_paths = runtime.get("skill_paths") or []
    normalized_skill_paths = [str(x).strip() for x in skill_paths if str(x).strip()] if isinstance(skill_paths, list) else []
    if bool(state.get("clear_skill_paths")):
        skill_paths_display = "default (clear extra dirs)"
    else:
        skill_paths_display = ", ".join(normalized_skill_paths) if normalized_skill_paths else "default"

    lines = [
        "Ready to install and configure OpenClaw with these settings:",
        f"- provider: {provider_display}",
        f"- model: {model_display}",
        f"- telegram: {telegram_enabled}",
        f"- telegram access: {telegram_access}",
        f"- alert chat: {alert_chat_display}",
        f"- armory skills: {armory_skills}",
        f"- skill paths: {skill_paths_display}",
    ]

    missing = _openclaw_missing_runtime_prerequisites(runtime)
    if missing:
        lines.append(f"- missing prerequisites: {', '.join(missing)}")
        lines.append("")
        lines.append("If you proceed, I will prompt for any missing required values before install.")

    lines.append("")
    lines.append("Proceed?")
    return "\n".join(lines)


def _sync_openclaw_runtime_settings(handler, compose_dir: Path, state: dict) -> dict[str, object]:
    resolved = _resolve_openclaw_runtime_settings(handler, state)
    env_updates = resolved.get("env_updates", {})
    if isinstance(env_updates, dict) and env_updates:
        _upsert_env_file(compose_dir / ".env", {str(k): str(v) for k, v in env_updates.items()})
        _upsert_env_file(compose_dir / ".openclaw" / ".env", {str(k): str(v) for k, v in env_updates.items()})
    if bool(state.get("clear_alert_chat_id")):
        _delete_env_file_keys(compose_dir / ".env", ["TELEGRAM_ALERT_CHAT_ID"])
        _delete_env_file_keys(compose_dir / ".openclaw" / ".env", ["TELEGRAM_ALERT_CHAT_ID"])

    cfg_path = compose_dir / ".openclaw" / "openclaw.json"
    cfg_data = _load_openclaw_json(cfg_path)
    model_ref = str(resolved.get("model_ref") or "").strip()
    if model_ref:
        _set_nested_openclaw_value(cfg_data, ["agents", "defaults", "model", "primary"], model_ref)

    skill_paths = resolved.get("skill_paths", [])
    if bool(state.get("clear_skill_paths")):
        _delete_env_file_keys(compose_dir / ".env", ["ARMORER_SKILL_SCAN_PATHS"])
        _delete_env_file_keys(compose_dir / ".openclaw" / ".env", ["ARMORER_SKILL_SCAN_PATHS"])
        _delete_nested_openclaw_value(cfg_data, ["skills", "load", "extraDirs"])
    elif isinstance(skill_paths, list) and skill_paths:
        skill_paths_value = " ".join(str(x).strip() for x in skill_paths if str(x).strip())
        if skill_paths_value:
            _upsert_env_file(
                compose_dir / ".env",
                {"ARMORER_SKILL_SCAN_PATHS": skill_paths_value},
            )
            _upsert_env_file(
                compose_dir / ".openclaw" / ".env",
                {"ARMORER_SKILL_SCAN_PATHS": skill_paths_value},
            )
        _set_nested_openclaw_value(cfg_data, ["skills", "load", "extraDirs"], [str(x) for x in skill_paths])

    provider = str(resolved.get("provider") or "").strip().lower()
    base_url = str(resolved.get("base_url") or "").strip()
    if provider == "ollama" and base_url:
        _set_nested_openclaw_value(cfg_data, ["models", "providers", "ollama", "baseUrl"], base_url)
        _set_nested_openclaw_value(
            cfg_data,
            ["models", "providers", "ollama", "apiKey"],
            str((resolved.get("env_updates") or {}).get("OLLAMA_API_KEY") or "ollama-local"),
        )
        if base_url.rstrip("/").endswith("/v1"):
            _set_nested_openclaw_value(cfg_data, ["models", "providers", "ollama", "api"], "openai-completions")
        else:
            _set_nested_openclaw_value(cfg_data, ["models", "providers", "ollama", "api"], "ollama")

    _write_openclaw_json(cfg_path, cfg_data)
    return resolved


def _verify_openclaw_setup(handler, app_name: str, state: dict, task_id: str) -> str:
    compose_dir = _resolve_openclaw_runtime_compose_dir(handler, app_name)
    if compose_dir is None:
        raise RuntimeError("OpenClaw install completed but runtime files could not be located.")
    cfg_path = compose_dir / ".openclaw" / "openclaw.json"
    cfg_data = _load_openclaw_json(cfg_path)
    runtime = _resolve_openclaw_runtime_settings(handler, state)
    env_data = _load_env_file(compose_dir / ".env")
    env_data.update(_load_env_file(compose_dir / ".openclaw" / ".env"))
    provider = str(runtime.get("provider") or "").strip().lower()

    env_updates = runtime.get("env_updates", {})
    if isinstance(env_updates, dict) and env_updates:
        mismatched_env_keys = []
        for key, expected in env_updates.items():
            expected_value = str(expected or "").strip()
            actual_value = str(env_data.get(str(key)) or "").strip()
            if expected_value and actual_value != expected_value:
                mismatched_env_keys.append(str(key))
        if mismatched_env_keys:
            raise RuntimeError(
                "OpenClaw runtime env verification failed for: "
                + ", ".join(sorted(mismatched_env_keys))
                + "."
            )
    if bool(state.get("clear_alert_chat_id")) and str(env_data.get("TELEGRAM_ALERT_CHAT_ID") or "").strip():
        raise RuntimeError("OpenClaw runtime env verification failed to clear TELEGRAM_ALERT_CHAT_ID.")

    model_ref = str(runtime.get("model_ref") or "").strip()
    if model_ref:
        actual_model_ref = str(
            _get_nested_openclaw_value(cfg_data, ["agents", "defaults", "model", "primary"]) or ""
        ).strip()
        if actual_model_ref != model_ref:
            raise RuntimeError("OpenClaw default model verification failed inside openclaw.json.")
        mismatched_provider = _openclaw_model_provider_mismatch(provider, actual_model_ref)
        if mismatched_provider:
            selected_label = _openclaw_display_provider_name(provider) or provider
            mismatch_label = _openclaw_display_provider_name(mismatched_provider) or mismatched_provider
            raise RuntimeError(
                f"OpenClaw model/provider verification failed: the configured model targets {mismatch_label}, "
                f"but the selected provider is {selected_label}."
            )

    skill_paths = runtime.get("skill_paths", [])
    if bool(state.get("clear_skill_paths")):
        actual_skill_paths = _get_nested_openclaw_value(cfg_data, ["skills", "load", "extraDirs"])
        normalized_skill_paths = [str(x).strip() for x in actual_skill_paths] if isinstance(actual_skill_paths, list) else []
        if normalized_skill_paths:
            raise RuntimeError("OpenClaw skill directory clear verification failed inside openclaw.json.")
        if str(env_data.get("ARMORER_SKILL_SCAN_PATHS") or "").strip():
            raise RuntimeError("OpenClaw runtime env verification failed to clear ARMORER_SKILL_SCAN_PATHS.")
    elif isinstance(skill_paths, list) and skill_paths:
        actual_skill_paths = _get_nested_openclaw_value(cfg_data, ["skills", "load", "extraDirs"])
        normalized_skill_paths = [str(x).strip() for x in actual_skill_paths] if isinstance(actual_skill_paths, list) else []
        if normalized_skill_paths != [str(x).strip() for x in skill_paths]:
            raise RuntimeError("OpenClaw skill directory verification failed inside openclaw.json.")
        expected_skill_paths = " ".join(str(x).strip() for x in skill_paths if str(x).strip())
        if expected_skill_paths and str(env_data.get("ARMORER_SKILL_SCAN_PATHS") or "").strip() != expected_skill_paths:
            raise RuntimeError("OpenClaw runtime env verification failed for ARMORER_SKILL_SCAN_PATHS.")
        missing_skill_paths = [
            path
            for path in normalized_skill_paths
            if path and not _resolve_openclaw_skill_path(compose_dir, path).is_dir()
        ]
        if missing_skill_paths:
            raise RuntimeError(
                "OpenClaw skill directory verification failed; missing directories: "
                + ", ".join(missing_skill_paths)
                + "."
            )

    base_url = str(runtime.get("base_url") or "").strip()
    if provider == "ollama" and base_url:
        actual_base_url = str(
            _get_nested_openclaw_value(cfg_data, ["models", "providers", "ollama", "baseUrl"]) or ""
        ).strip()
        if actual_base_url != base_url:
            raise RuntimeError("OpenClaw Ollama base URL verification failed inside openclaw.json.")

    if state.get("telegram_token"):
        token_out = _openclaw_compose_run(
            handler, compose_dir, task_id, "config", "get", "channels.telegram.botToken"
        ).stdout
        if str(_parse_openclaw_config_value(token_out)).strip() != str(state.get("telegram_token")).strip():
            raise RuntimeError("Telegram token verification failed inside OpenClaw config.")
        enabled_out = _openclaw_compose_run(
            handler, compose_dir, task_id, "config", "get", "channels.telegram.enabled"
        ).stdout
        enabled = str(_parse_openclaw_config_value(enabled_out)).strip().lower()
        if enabled not in {"true", "1"}:
            raise RuntimeError("Telegram channel is not enabled after setup.")
        admin_ids = [x for x in _parse_csv_values(str(state.get("telegram_admin_ids") or "")) if x]
        group_policy_out = _openclaw_compose_run(
            handler, compose_dir, task_id, "config", "get", "channels.telegram.groupPolicy"
        ).stdout
        group_policy = str(_parse_openclaw_config_value(group_policy_out)).strip().lower()
        if admin_ids:
            if group_policy != "allowlist":
                raise RuntimeError("Telegram group policy verification failed inside OpenClaw config.")
            allow_out = _openclaw_compose_run(
                handler, compose_dir, task_id, "config", "get", "channels.telegram.allowFrom"
            ).stdout
            allow_values = _parse_openclaw_config_value(allow_out)
            normalized = [str(x) for x in allow_values] if isinstance(allow_values, list) else []
            if set(normalized) != set(admin_ids):
                raise RuntimeError("Telegram allowlist verification failed inside OpenClaw config.")
            group_allow_out = _openclaw_compose_run(
                handler, compose_dir, task_id, "config", "get", "channels.telegram.groupAllowFrom"
            ).stdout
            group_allow_values = _parse_openclaw_config_value(group_allow_out)
            normalized_group = [str(x) for x in group_allow_values] if isinstance(group_allow_values, list) else []
            if set(normalized_group) != set(admin_ids):
                raise RuntimeError("Telegram group allowlist verification failed inside OpenClaw config.")
        else:
            if group_policy != "open":
                raise RuntimeError("Telegram open-group policy verification failed inside OpenClaw config.")
            allow_out = _openclaw_compose_run(
                handler, compose_dir, task_id, "config", "get", "channels.telegram.allowFrom"
            ).stdout
            allow_values = _parse_openclaw_config_value(allow_out)
            normalized = [str(x) for x in allow_values] if isinstance(allow_values, list) else []
            if normalized:
                raise RuntimeError("Telegram open-group policy retained stale allowFrom entries.")
            group_allow_out = _openclaw_compose_run(
                handler, compose_dir, task_id, "config", "get", "channels.telegram.groupAllowFrom"
            ).stdout
            group_allow_values = _parse_openclaw_config_value(group_allow_out)
            normalized_group = [str(x) for x in group_allow_values] if isinstance(group_allow_values, list) else []
            if normalized_group:
                raise RuntimeError("Telegram open-group policy retained stale groupAllowFrom entries.")

    injected_skills = _resolved_openclaw_armory_skill_names(handler, state, app_name)
    if injected_skills:
        mcp_servers = cfg_data.get("mcpServers", {}) if isinstance(cfg_data.get("mcpServers"), dict) else {}
        for skill_name in injected_skills:
            if skill_name not in mcp_servers:
                raise RuntimeError(f"Injected OpenClaw skill `{skill_name}` is missing from mcpServers config.")
    elif bool(state.get("clear_armory_skills")):
        cleared_skills = [x for x in _parse_csv_values(str(state.get("cleared_armory_skills") or "")) if x]
        if cleared_skills:
            mcp_servers = cfg_data.get("mcpServers", {}) if isinstance(cfg_data.get("mcpServers"), dict) else {}
            lingering = [skill_name for skill_name in cleared_skills if skill_name in mcp_servers]
            if lingering:
                raise RuntimeError(
                    "Injected OpenClaw skill clear verification failed for: " + ", ".join(lingering) + "."
                )

    status_result = _run_openclaw_setup_command(
        handler,
        ["run", app_name, "status"],
        task_id=task_id,
        label="Verifying OpenClaw status",
        retries=2,
        timeout_seconds=180,
    )
    verification_output = [
        _sanitize_openclaw_setup_detail_text(
            handler,
            ((status_result.stdout or "") + "\n" + (status_result.stderr or "")).strip()
        )
    ]
    if _openclaw_registry_has_health_check(handler, app_name):
        try:
            doctor_result = _run_openclaw_setup_command(
                handler,
                ["run", app_name, "doctor"],
                task_id=task_id,
                label="Running OpenClaw health check",
                retries=1,
                timeout_seconds=180,
            )
        except Exception as exc:
            if not _openclaw_health_check_unavailable(exc):
                raise
            logger.debug("Skipping unavailable OpenClaw health check for %s: %s", app_name, exc)
        else:
            verification_output.append(
                _sanitize_openclaw_setup_detail_text(
                    handler,
                    ((doctor_result.stdout or "") + "\n" + (doctor_result.stderr or "")).strip()
                )
            )
    return _truncate_openclaw_verification_output(
        "\n".join(chunk for chunk in verification_output if chunk),
        limit=1200,
    )


def _sync_openclaw_armory_skills(
    handler,
    compose_dir: Path,
    app_name: str,
    skill_names: list[str],
    *,
    clear_existing: bool = False,
) -> list[str]:
    selected = [str(name).strip() for name in skill_names if str(name).strip()]
    existing_managed = _existing_openclaw_injected_skills(handler, app_name)
    if not selected and not clear_existing and not existing_managed:
        return []
    SM = _resolve_from_core("SkillManager", _SkillManager)
    manager = SM()
    skills = []
    missing = []
    selected_set = set(selected)

    cfg_dir = compose_dir / ".openclaw"
    cfg_path = cfg_dir / "openclaw.json"
    data = _load_openclaw_json(cfg_path)
    mcp_servers = data.get("mcpServers", {}) if isinstance(data.get("mcpServers"), dict) else {}

    for name in selected:
        skill = manager.get_skill(name)
        if skill is None and name in mcp_servers:
            continue
        if skill is None:
            missing.append(name)
        else:
            skills.append(skill)
    if missing:
        raise RuntimeError(f"Unknown Armory skill(s): {', '.join(missing)}")

    for skill_name in existing_managed:
        if skill_name not in selected_set:
            mcp_servers.pop(skill_name, None)
    for skill in skills:
        mcp_servers[skill.name] = skill.config
    if mcp_servers:
        data["mcpServers"] = mcp_servers
    else:
        data.pop("mcpServers", None)
    cfg_dir.mkdir(parents=True, exist_ok=True)
    cfg_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")

    app_meta = handler.registry.get_app(app_name)
    if app_meta:
        app_meta.injected_skills = selected
        try:
            handler.registry.add_app(app_meta)
        except OSError as exc:
            logger.debug("Skipping OpenClaw injected skill registry metadata update for %s: %s", app_name, exc)
    return selected


def _capture_openclaw_runtime_snapshot(handler, app_name: str, compose_dir: Path | None) -> dict[str, object]:
    runtime_dir = Path(compose_dir) if compose_dir is not None else None
    files = {}
    if runtime_dir is not None:
        files = {
            ".env": _capture_openclaw_runtime_file(runtime_dir / ".env"),
            ".openclaw/.env": _capture_openclaw_runtime_file(runtime_dir / ".openclaw" / ".env"),
            ".openclaw/openclaw.json": _capture_openclaw_runtime_file(runtime_dir / ".openclaw" / "openclaw.json"),
        }
    registry_app = handler.registry.get_app(app_name)
    return {
        "app_name": app_name,
        "compose_dir": str(runtime_dir) if runtime_dir is not None else "",
        "had_runtime": runtime_dir is not None,
        "install_roots": {
            str(path): bool(path.exists())
            for path in _candidate_openclaw_install_roots(app_name)
        },
        "files": files,
        "registry_app": copy.deepcopy(registry_app) if registry_app is not None else None,
    }


def _restore_openclaw_runtime_snapshot(handler, snapshot: dict[str, object], task_id: str) -> list[str]:
    warnings: list[str] = []
    app_name = str(snapshot.get("app_name") or "openclaw").strip() or "openclaw"
    compose_dir_raw = str(snapshot.get("compose_dir") or "").strip()
    compose_dir = Path(compose_dir_raw) if compose_dir_raw else None
    files = snapshot.get("files", {})
    if isinstance(files, dict):
        for rel_path, file_snapshot in files.items():
            if not isinstance(file_snapshot, dict):
                continue
            if compose_dir is None:
                continue
            try:
                _restore_openclaw_runtime_file(compose_dir / str(rel_path), file_snapshot)
            except Exception as exc:
                warnings.append(f"restore {rel_path}: {str(exc).strip() or exc.__class__.__name__}")

    install_roots = snapshot.get("install_roots", {})
    if isinstance(install_roots, dict):
        for raw_path, existed_before in install_roots.items():
            install_root = Path(str(raw_path or ""))
            if bool(existed_before) or not install_root.exists():
                continue
            try:
                shutil.rmtree(install_root)
            except Exception as exc:
                warnings.append(
                    f"remove install root {install_root.name or install_root}: "
                    f"{str(exc).strip() or exc.__class__.__name__}"
                )

    registry_app = snapshot.get("registry_app")
    try:
        if isinstance(registry_app, AppMetadata):
            handler.registry.add_app(registry_app)
        else:
            handler.registry.remove_app(app_name)
    except OSError as exc:
        warnings.append(f"restore registry metadata: {str(exc).strip() or exc.__class__.__name__}")

    if compose_dir is not None and compose_dir.exists():
        try:
            reload_result = subprocess.run(
                _armorer_setup_command_prefix() + ["run", app_name, "--mode", "background"],
                env=_armorer_setup_env(),
                capture_output=True,
                text=True,
                timeout=300,
            )
            if int(getattr(reload_result, "returncode", 0) or 0) != 0:
                detail = _sanitize_openclaw_setup_detail_text(
                    handler,
                    ((reload_result.stdout or "") + "\n" + (reload_result.stderr or "")).strip()
                )
                warning = f"reload services: exit code {reload_result.returncode}"
                if detail:
                    warning += f" ({detail[:180]})"
                warnings.append(warning)
        except Exception as exc:
            warnings.append(f"reload services: {str(exc).strip() or exc.__class__.__name__}")

    safe_warnings = [_sanitize_openclaw_setup_detail_text(handler, item) for item in warnings if str(item).strip()]
    try:
        if safe_warnings:
            handler._write_task_status(
                task_id,
                "Rollback completed with warnings: " + "; ".join(str(item) for item in safe_warnings[:3]),
            )
        else:
            handler._write_task_status(task_id, "Rolled back failed OpenClaw changes.")
    except Exception:
        pass
    return safe_warnings


def _openclaw_rollback_summary_for_snapshot(handler, snapshot: dict[str, object]) -> str:
    if bool(snapshot.get("had_runtime")):
        return "Armorer restored the previous OpenClaw runtime state before retry."

    install_roots = snapshot.get("install_roots", {})
    if isinstance(install_roots, dict):
        for raw_path, existed_before in install_roots.items():
            install_root = Path(str(raw_path or ""))
            if not bool(existed_before) and install_root.exists():
                return "Armorer removed the partial OpenClaw install before retry."

    app_name = str(snapshot.get("app_name") or "openclaw").strip() or "openclaw"
    previous_registry_app = snapshot.get("registry_app")
    current_registry_app = handler.registry.get_app(app_name)
    if current_registry_app != previous_registry_app:
        return "Armorer rolled back the partial OpenClaw setup state before retry."

    return ""


def _execute_openclaw_setup(handler, state: dict, task_id: str) -> dict:
    app_name = str(state.get("alias") or "openclaw").strip() or "openclaw"
    state = dict(state)
    provider = str(state.get("provider") or "skip").strip().lower()
    model = str(state.get("model") or "").strip()
    skills_paths = " ".join(_parse_csv_values(str(state.get("skills_paths") or "")))
    if bool(state.get("clear_armory_skills")) and not str(state.get("cleared_armory_skills") or "").strip():
        existing_to_clear = handler._existing_openclaw_injected_skills(app_name)
        if existing_to_clear:
            state["cleared_armory_skills"] = ",".join(existing_to_clear)
    armory_skill_names = handler._resolved_openclaw_armory_skill_names(state, app_name)
    if armory_skill_names and not str(state.get("armory_skills") or "").strip():
        state["resolved_armory_skills"] = ",".join(armory_skill_names)
    runtime = handler._resolve_openclaw_runtime_settings(state)
    handler._assert_openclaw_runtime_prerequisites(runtime)

    handler._write_task_status(task_id, "Preparing OpenClaw setup plan")
    registry_app = handler.registry.get_app(app_name)
    compose_dir = handler._resolve_openclaw_runtime_compose_dir(app_name)
    runtime_snapshot = _capture_openclaw_runtime_snapshot(handler, app_name, compose_dir)
    stale_registered_install = registry_app is not None and compose_dir is None
    reused_existing_install = compose_dir is not None

    try:
        if reused_existing_install:
            if registry_app is None:
                handler._write_task_status(
                    task_id,
                    f"Reusing existing OpenClaw install `{app_name}` from the live install root and recovering metadata.",
                )
                registry_app = handler._recover_openclaw_registry_entry(app_name, task_id)
            else:
                handler._write_task_status(task_id, f"Reusing existing OpenClaw install `{app_name}`")
        else:
            if stale_registered_install:
                handler._write_task_status(
                    task_id,
                    f"Existing OpenClaw registry entry `{app_name}` points to a missing runtime; reinstalling it in place.",
                )
            handler._run_openclaw_setup_command(
                ["install", "openclaw", "--yes", "--skip-scan", "--alias", app_name],
                task_id=task_id,
                label="Installing OpenClaw",
                retries=2,
                timeout_seconds=1200,
            )
            recovered_registry_app = handler._recover_openclaw_registry_entry(app_name, task_id)
            if recovered_registry_app is None:
                raise RuntimeError("OpenClaw installed, but Armorer could not find or recover its registry entry.")
        handler._run_openclaw_setup_command(
            ["run", app_name, "--mode", "background"],
            task_id=task_id,
            label="Starting OpenClaw services",
            retries=2,
            timeout_seconds=300,
        )

        registry_app = handler.registry.get_app(app_name)
        registry_install_dir = (
            _resolve_openclaw_compose_dir(registry_app.install_path) if registry_app is not None else None
        )
        compose_dir = handler._resolve_openclaw_runtime_compose_dir(app_name)
        if (
            compose_dir is None
            and registry_app is not None
            and registry_install_dir is not None
            and registry_install_dir.exists()
            and _is_expected_openclaw_install_path(app_name, registry_install_dir)
        ):
            compose_dir = registry_install_dir
        if compose_dir is None:
            raise RuntimeError("OpenClaw installed but runtime files could not be located.")
        if not registry_app or not handler._openclaw_registry_entry_matches_runtime(app_name, compose_dir):
            registry_app = handler._recover_openclaw_registry_entry(app_name, task_id)

        runtime = handler._sync_openclaw_runtime_settings(compose_dir, state)

        if str(state.get("telegram_token") or "").strip():
            token = str(state.get("telegram_token")).strip()
            handler._openclaw_compose_run(compose_dir, task_id, "config", "set", "channels.telegram.botToken", token)
            handler._openclaw_compose_run(compose_dir, task_id, "config", "set", "channels.telegram.enabled", "true")
            admin_ids = [x for x in _parse_csv_values(str(state.get("telegram_admin_ids") or "")) if x]
            if admin_ids:
                ids_json = json.dumps(admin_ids)
                handler._openclaw_compose_run(compose_dir, task_id, "config", "set", "channels.telegram.groupPolicy", "allowlist")
                handler._openclaw_compose_run(compose_dir, task_id, "config", "set", "channels.telegram.allowFrom", ids_json, "--strict-json")
                handler._openclaw_compose_run(compose_dir, task_id, "config", "set", "channels.telegram.groupAllowFrom", ids_json, "--strict-json")
            else:
                handler._openclaw_compose_run(compose_dir, task_id, "config", "set", "channels.telegram.groupPolicy", "open")
                handler._openclaw_compose_run(compose_dir, task_id, "config", "set", "channels.telegram.allowFrom", "[]", "--strict-json")
                handler._openclaw_compose_run(compose_dir, task_id, "config", "set", "channels.telegram.groupAllowFrom", "[]", "--strict-json")

        injected_skills = handler._sync_openclaw_armory_skills(
            compose_dir,
            app_name,
            armory_skill_names,
            clear_existing=bool(state.get("clear_armory_skills")),
        )
        handler._run_openclaw_setup_command(
            ["run", app_name, "--mode", "background"],
            task_id=task_id,
            label="Reloading OpenClaw services",
            retries=2,
            timeout_seconds=300,
        )

        status_text = handler._verify_openclaw_setup(app_name, state, task_id)
        telegram_verification = handler._verify_openclaw_telegram_channel(app_name, state, runtime, task_id)
        config_mirror_failures: list[str] = []

        def _set_armorer_config_best_effort(key: str, value: str) -> None:
            try:
                handler.config_manager.set_key(key, value)
            except OSError as exc:
                logger.debug("Skipping Armorer config mirror for %s during OpenClaw setup: %s", key, exc)
                if key not in config_mirror_failures:
                    config_mirror_failures.append(key)

        runtime_provider = str(runtime.get("provider") or "").strip().lower()
        if runtime_provider:
            _set_armorer_config_best_effort(
                "MODEL_PROVIDER", "gemini" if runtime_provider == "google" else runtime_provider
            )
        runtime_model = str(runtime.get("model") or "").strip()
        if runtime_model:
            _set_armorer_config_best_effort("Armorer_MODEL", runtime_model)
        runtime_provider_key = str(runtime.get("provider_key_name") or "").strip()
        runtime_provider_key_value = str(runtime.get("provider_key_value") or "").strip()
        if runtime_provider_key and runtime_provider_key_value:
            _set_armorer_config_best_effort(runtime_provider_key, runtime_provider_key_value)
        runtime_base_url = str(runtime.get("base_url") or "").strip()
        if runtime_provider == "ollama" and runtime_base_url:
            _set_armorer_config_best_effort("OLLAMA_BASE_URL", runtime_base_url)
        runtime_skill_paths = runtime.get("skill_paths") or []
        if bool(state.get("clear_skill_paths")):
            _set_armorer_config_best_effort("ARMORER_SKILL_SCAN_PATHS", "")
        elif isinstance(runtime_skill_paths, list) and runtime_skill_paths:
            _set_armorer_config_best_effort(
                "ARMORER_SKILL_SCAN_PATHS",
                " ".join(str(path).strip() for path in runtime_skill_paths if str(path).strip()),
            )
        if str(state.get("telegram_token") or "").strip():
            _set_armorer_config_best_effort("TELEGRAM_BOT_TOKEN", str(state.get("telegram_token")).strip())
        if bool(state.get("clear_alert_chat_id")):
            _set_armorer_config_best_effort("TELEGRAM_ALERT_CHAT_ID", "")
        elif str(state.get("alert_chat_id") or "").strip():
            _set_armorer_config_best_effort("TELEGRAM_ALERT_CHAT_ID", str(state.get("alert_chat_id")).strip())
        runtime_provider = _openclaw_display_provider_name(str(runtime.get("provider") or "").strip())
        provider_line = (
            _openclaw_display_provider_name(provider)
            if provider != "skip"
            else (f"unchanged ({runtime_provider})" if runtime_provider else "unchanged")
        )
        lines = [
            f"OpenClaw is installed and verified as `{app_name}`.",
            f"Provider: {provider_line}",
        ]
        if reused_existing_install:
            lines.append("Reused the existing OpenClaw install for this setup run.")
        if model:
            lines.append(f"Model: {model}")
        if str(runtime.get("model_ref") or "").strip():
            lines.append(f"OpenClaw default model verified: {runtime.get('model_ref')}")
        bot_username = str(telegram_verification.get("bot_username") or "").strip()
        verified_chat_ids = [str(x).strip() for x in (telegram_verification.get("verified_chat_ids") or []) if str(x).strip()]
        if str(state.get("telegram_token") or "").strip():
            if verified_chat_ids:
                bot_label = f" (@{bot_username})" if bot_username else ""
                lines.append(f"Telegram channel configuration verified via Telegram API{bot_label}.")
            else:
                bot_label = f" (@{bot_username})" if bot_username else ""
                lines.append(
                    f"Telegram bot token verified via Telegram API{bot_label}. "
                    "OpenClaw Telegram channel is enabled with no allowlisted chats."
                )
        if bool(state.get("clear_alert_chat_id")):
            lines.append("Alert chat cleared.")
        elif str(runtime.get("alert_chat_id") or "").strip() and str(runtime.get("alert_chat_id")).strip() in verified_chat_ids:
            lines.append(f"Alert chat verified via Telegram API: {runtime.get('alert_chat_id')}")
        if skills_paths:
            lines.append(f"Skill scan paths: {skills_paths}")
        if bool(state.get("clear_skill_paths")):
            lines.append("OpenClaw extra skill directories cleared; default scan paths remain.")
        elif runtime.get("skill_paths"):
            lines.append(f"OpenClaw skill directories verified: {', '.join(runtime.get('skill_paths') or [])}")
        if bool(state.get("clear_armory_skills")):
            cleared_skills = [x for x in _parse_csv_values(str(state.get("cleared_armory_skills") or "")) if x]
            if cleared_skills:
                lines.append(f"Injected Armory skills cleared: {', '.join(cleared_skills)}")
            else:
                lines.append("Injected Armory skills cleared.")
        elif injected_skills:
            lines.append(f"Injected Armory skills: {', '.join(injected_skills)}")
        if config_mirror_failures:
            lines.append(
                "Warning: Armorer local config mirror could not be updated for: "
                + ", ".join(config_mirror_failures)
                + ". OpenClaw runtime is configured and verified."
            )
        if not _openclaw_registry_entry_matches_runtime(handler, app_name, compose_dir):
            if app_name == "openclaw":
                lines.append(
                    "Warning: Armorer could not persist OpenClaw registry metadata. "
                    "The default install-root fallback remains available for `armorer run openclaw ...`."
                )
            else:
                lines.append(
                    f"Warning: Armorer could not persist OpenClaw registry metadata for `{app_name}`. "
                    f"The runtime is configured and verified, but future `armorer run {app_name} ...` commands "
                    "require a writable Armorer config directory to restore registry metadata."
                )
        lines.append(f"Final status check passed via `armorer run {app_name} status`.")
        return {"message": "\n".join(lines) + f"\n\nStatus output:\n{status_text}"}
    except Exception as exc:
        rollback_summary = handler._openclaw_rollback_summary_for_snapshot(runtime_snapshot)
        rollback_warnings = _restore_openclaw_runtime_snapshot(handler, runtime_snapshot, task_id)
        if rollback_summary or rollback_warnings:
            raise _setup_exec_error()(
                str(exc).strip() or exc.__class__.__name__,
                rollback_warnings=rollback_warnings,
                rollback_summary=rollback_summary,
            ) from exc
        raise


# ---------------------------------------------------------------------------
# Main setup flow
# ---------------------------------------------------------------------------


def _handle_openclaw_setup_flow(
    handler,
    source: str,
    conversation_id: str,
    prompt: str,
    task_id: str,
) -> tuple[str, dict | None]:
    SupersededTaskError = _superseded_error()
    raw_prompt = str(prompt or "").strip()
    reply = _extract_reply_value(raw_prompt)
    pending = handler._pending_decisions.get(conversation_id, {})
    pending_type = str(pending.get("type") or "").strip()
    has_ui_prefix = str(raw_prompt).strip().lower().startswith(("ui input:", "ui selection:", "ui confirmation:"))
    if pending_type != "openclaw_setup" and not _looks_like_openclaw_setup_request(raw_prompt):
        return raw_prompt, None

    state = _openclaw_state(pending)
    step = str(pending.get("step") or "provider")
    unrelated_question = _looks_like_question(reply) and _is_clearly_unrelated_to_openclaw_setup(reply)

    focus_mode = str(handler._get_focus(conversation_id).get("mode") or "")
    if pending_type == "openclaw_setup" and focus_mode != "assistant_setup_config":
        raw_reply = str(reply or "").strip()
        allow_plain_confirm = step == "confirm" and (
            _is_openclaw_setup_yes(raw_reply) or _is_openclaw_setup_no(raw_reply)
        )
        allow_plain_alias = False
        if step == "alias":
            alias_options = [x for x in _parse_csv_values(str(state.get("available_aliases") or "")) if x]
            if "openclaw" not in alias_options:
                alias_options.append("openclaw")
            allow_plain_alias = bool(_extract_openclaw_alias_choice(raw_reply, alias_options))
        if not (
            has_ui_prefix
            or _looks_like_openclaw_setup_request(raw_prompt)
            or _is_openclaw_cancel_reply(reply)
            or allow_plain_confirm
            or allow_plain_alias
        ):
            return raw_prompt, None

    if _is_openclaw_cancel_reply(reply):
        handler._clear_pending_decision(conversation_id)
        handler._clear_focus(conversation_id)
        return "", {"message": "Cancelled OpenClaw setup."}

    if pending_type != "openclaw_setup":
        existing_aliases = _openclaw_setup_alias_candidates(handler)
        if "openclaw" not in existing_aliases and len(existing_aliases) > 1:
            inferred_alias = _extract_openclaw_alias_choice(raw_prompt, existing_aliases)
            if not inferred_alias and _looks_like_openclaw_default_alias_choice(raw_prompt):
                inferred_alias = "openclaw"
            if inferred_alias:
                state = {"alias": inferred_alias}
                handler._set_focus(conversation_id, mode="assistant_setup_config", target="openclaw")
                _save_openclaw_state(handler, conversation_id, "provider", state)
                start_message = (
                    f"I'll configure OpenClaw instance `{inferred_alias}` from chat. "
                    "First choose the model provider."
                )
                if inferred_alias == "openclaw":
                    start_message = (
                        "I'll install and configure OpenClaw entirely from chat. "
                        "First choose the model provider."
                    )
                return "", {
                    "message": start_message,
                    "ui_action": {
                        "type": "select",
                        "message": "Choose OpenClaw provider",
                        "options": _provider_choices(),
                    },
                }
            state = {"available_aliases": ",".join(existing_aliases)}
            handler._set_focus(conversation_id, mode="assistant_setup_config", target="openclaw")
            _save_openclaw_state(handler, conversation_id, "alias", state)
            return "", {
                "message": (
                    "I found multiple OpenClaw instances. Choose the alias to configure, "
                    "or choose `openclaw` to install/configure a new default instance."
                ),
                "ui_action": {
                    "type": "select",
                    "message": "Choose OpenClaw instance alias",
                    "options": existing_aliases + ["openclaw"],
                },
            }
        preferred_alias = _preferred_openclaw_setup_alias(handler)
        state = {"alias": preferred_alias}
        handler._set_focus(conversation_id, mode="assistant_setup_config", target="openclaw")
        _save_openclaw_state(handler, conversation_id, "provider", state)
        start_message = "I'll install and configure OpenClaw entirely from chat. First choose the model provider."
        if preferred_alias != "openclaw":
            start_message = (
                f"I found an existing OpenClaw instance `{preferred_alias}` and will configure it from chat. "
                "First choose the model provider."
            )
        return "", {
            "message": start_message,
            "ui_action": {
                "type": "select",
                "message": "Choose OpenClaw provider",
                "options": _provider_choices(),
            },
        }

    if step == "alias":
        if unrelated_question:
            return reply, None
        available_aliases = [
            alias for alias in _parse_csv_values(str(state.get("available_aliases") or "")) if alias
        ]
        alias_options = sorted({alias for alias in available_aliases if alias})
        if "openclaw" not in alias_options:
            alias_options.append("openclaw")
        selected_alias = _extract_openclaw_alias_choice(reply, alias_options)
        if not selected_alias:
            _save_openclaw_state(handler, conversation_id, "alias", state)
            return "", {
                "message": (
                    "Choose an OpenClaw instance alias to configure, "
                    "or choose `openclaw` to install/configure a new default instance."
                ),
                "ui_action": {
                    "type": "select",
                    "message": "Choose OpenClaw instance alias",
                    "options": alias_options,
                },
            }
        state["alias"] = selected_alias
        state.pop("available_aliases", None)
        _save_openclaw_state(handler, conversation_id, "provider", state)
        start_message = (
            f"I'll configure OpenClaw instance `{selected_alias}` from chat. "
            "First choose the model provider."
        )
        if selected_alias == "openclaw":
            start_message = (
                "I'll install and configure OpenClaw entirely from chat. "
                "First choose the model provider."
            )
        return "", {
            "message": start_message,
            "ui_action": {
                "type": "select",
                "message": "Choose OpenClaw provider",
                "options": _provider_choices(),
            },
        }

    if step == "provider":
        if unrelated_question:
            return reply, None
        provider = _extract_openclaw_provider_choice(reply)
        if provider not in set(_provider_choices()):
            _save_openclaw_state(handler, conversation_id, "provider", state)
            return "", {
                "message": "Choose one of: ollama, openai, anthropic, openrouter, gemini, skip.",
                "ui_action": {
                    "type": "select",
                    "message": "Choose OpenClaw provider",
                    "options": _provider_choices(),
                },
            }
        state["provider"] = provider
        if state.get("resume_to_confirm"):
            return _resume_openclaw_missing_runtime_prerequisite(handler, conversation_id, state)
        _save_openclaw_state(handler, conversation_id, "model", state)
        return "", {
            "message": "Enter the model name OpenClaw should use, or `skip` to keep the current value.",
            "ui_action": {
                "type": "text",
                "message": "Enter model name",
                "default": "llama3.2" if provider == "ollama" else "",
            },
        }

    if step == "model":
        model_reply_raw = str(reply or "").strip()
        model_reply = _extract_openclaw_model_reply_value(model_reply_raw) or model_reply_raw
        if unrelated_question:
            return model_reply, None
        if _looks_like_question(model_reply_raw):
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "model",
                state,
                message="Enter the model name OpenClaw should use, such as `gpt-4.1-mini` or `llama3.2`, or reply `skip` to keep the current value.",
                prompt_message="Enter model name",
                default="llama3.2" if str(state.get("provider") or "").strip().lower() == "ollama" else "",
            )
        if not model_reply_raw:
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "model",
                state,
                message="Enter a model name or reply `skip` to keep the current value.",
                prompt_message="Enter model name",
                default="llama3.2" if str(state.get("provider") or "").strip().lower() == "ollama" else "",
            )
        if not model_reply:
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "model",
                state,
                message=(
                    "Enter a model identifier such as `gpt-4.1-mini` or `llama3.2`, "
                    "or reply `skip` to keep the current value."
                ),
                prompt_message="Enter model name",
                default="llama3.2" if str(state.get("provider") or "").strip().lower() == "ollama" else "",
            )
        if _is_openclaw_skip_reply(model_reply):
            state.pop("model", None)
        else:
            effective_provider = str(
                _resolve_openclaw_runtime_settings(handler, {**state, "model": model_reply}).get("provider")
                or state.get("provider")
                or ""
            ).strip().lower()
            mismatched_provider = _openclaw_model_provider_mismatch(effective_provider, model_reply)
            if mismatched_provider:
                selected_label = _openclaw_display_provider_name(effective_provider) or "the selected provider"
                mismatch_label = _openclaw_display_provider_name(mismatched_provider) or mismatched_provider
                return _reprompt_openclaw_text_step(
                    handler,
                    conversation_id,
                    "model",
                    state,
                    message=(
                        f"The model prefix targets {mismatch_label}, but the selected provider is {selected_label}. "
                        f"Enter a {selected_label} model name, or switch providers."
                    ),
                    prompt_message="Enter model name",
                    default="llama3.2" if str(state.get("provider") or "").strip().lower() == "ollama" else "",
                )
            state["model"] = model_reply
        if state.get("resume_to_confirm"):
            return _resume_openclaw_missing_runtime_prerequisite(handler, conversation_id, state)
        provider = str(state.get("provider") or "skip").strip().lower()
        if provider == "ollama":
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "base_url",
                state,
                message="Enter the Ollama base URL, or `skip` to keep the current value.",
                prompt_message="Enter OLLAMA_BASE_URL",
                default="http://127.0.0.1:11434",
            )
        if provider in {"openai", "anthropic", "openrouter", "gemini"}:
            key_name = {
                "openai": "OPENAI_API_KEY",
                "anthropic": "ANTHROPIC_API_KEY",
                "openrouter": "OPENROUTER_API_KEY",
                "gemini": "GEMINI_API_KEY",
            }[provider]
            state["provider_key_name"] = key_name
            _save_openclaw_state(handler, conversation_id, "provider_key", state)
            return "", {
                "message": f"Paste {key_name}, or `skip` to keep the current configured key.",
                "ui_action": {"type": "text", "message": f"Enter {key_name}", "sensitive": True},
            }
        _save_openclaw_state(handler, conversation_id, "telegram_token", state)
        return "", {
            "message": "Optional: paste TELEGRAM_BOT_TOKEN for the OpenClaw Telegram channel, or `skip`.",
            "ui_action": {"type": "text", "message": "Enter TELEGRAM_BOT_TOKEN (or `skip`)", "sensitive": True},
        }

    if step == "base_url":
        base_url_raw = str(reply or "").strip()
        base_url_reply = _extract_openclaw_base_url_reply_value(base_url_raw)
        if unrelated_question:
            return base_url_raw, None
        if _looks_like_question(base_url_raw):
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "base_url",
                state,
                message="Use the Ollama HTTP endpoint reachable from Armorer, usually `http://127.0.0.1:11434`, or reply `skip` to keep the current value.",
                prompt_message="Enter OLLAMA_BASE_URL",
                default="http://127.0.0.1:11434",
            )
        if not base_url_raw:
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "base_url",
                state,
                message="Enter the Ollama base URL or reply `skip` to keep the current value.",
                prompt_message="Enter OLLAMA_BASE_URL",
                default="http://127.0.0.1:11434",
            )
        if not base_url_reply:
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "base_url",
                state,
                message="Enter a valid Ollama base URL such as `http://127.0.0.1:11434`, or reply `skip` to keep the current value.",
                prompt_message="Enter OLLAMA_BASE_URL",
                default="http://127.0.0.1:11434",
            )
        if not _is_openclaw_skip_reply(base_url_reply):
            state["base_url"] = base_url_reply
        _save_openclaw_state(handler, conversation_id, "telegram_token", state)
        return "", {
            "message": "Optional: paste TELEGRAM_BOT_TOKEN for the OpenClaw Telegram channel, or `skip`.",
            "ui_action": {"type": "text", "message": "Enter TELEGRAM_BOT_TOKEN (or `skip`)", "sensitive": True},
        }

    if step == "provider_key":
        key_reply_raw = str(reply or "").strip()
        key_reply = _extract_openclaw_provider_key_reply_value(key_reply_raw) or key_reply_raw
        key_name = str(state.get("provider_key_name") or "API key").strip()
        provider_name = str(state.get("provider") or "your provider").strip().lower() or "your provider"
        if unrelated_question:
            return key_reply_raw, None
        if _looks_like_question(key_reply_raw):
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "provider_key",
                state,
                message=(
                    f"Create or copy an API key from the {provider_name} dashboard, then paste it here. "
                    "Reply `skip` to keep the current configured key."
                ),
                prompt_message=f"Enter {key_name}",
            )
        if not key_reply_raw:
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "provider_key",
                state,
                message=f"Paste {key_name}, or reply `skip` to keep the current configured key.",
                prompt_message=f"Enter {key_name}",
            )
        if not key_reply:
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "provider_key",
                state,
                message=f"Paste a valid {key_name} value, or reply `skip` to keep the current configured key.",
                prompt_message=f"Enter {key_name}",
            )
        if not _is_openclaw_skip_reply(key_reply):
            state["provider_key_value"] = key_reply
        else:
            state.pop("provider_key_value", None)
        if state.get("resume_to_confirm"):
            return _resume_openclaw_missing_runtime_prerequisite(handler, conversation_id, state)
        _save_openclaw_state(handler, conversation_id, "telegram_token", state)
        return "", {
            "message": "Optional: paste TELEGRAM_BOT_TOKEN for the OpenClaw Telegram channel, or `skip`.",
            "ui_action": {"type": "text", "message": "Enter TELEGRAM_BOT_TOKEN (or `skip`)", "sensitive": True},
        }

    if step == "telegram_token":
        token_raw = str(reply or "").strip()
        token_reply = _extract_openclaw_telegram_token_reply_value(token_raw)
        if unrelated_question:
            return reply, None
        if _looks_like_question(token_raw):
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "telegram_token",
                state,
                message=(
                    "Create a bot via @BotFather in Telegram:\n"
                    "1. Send `/newbot`\n"
                    "2. Choose a name and username\n"
                    "3. Copy the token in the format `<digits>:<secret>`\n"
                    "Then paste it here, or reply `skip`."
                ),
                prompt_message="Enter TELEGRAM_BOT_TOKEN (or `skip`)",
            )
        if not token_raw:
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "telegram_token",
                state,
                message="Enter TELEGRAM_BOT_TOKEN (for example `<digits>:<secret>`), or reply `skip`.",
                prompt_message="Enter TELEGRAM_BOT_TOKEN (or `skip`)",
            )
        if not token_reply:
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "telegram_token",
                state,
                message="Telegram bot tokens usually look like `<digits>:<secret>`. Paste a valid token or `skip`.",
                prompt_message="Enter TELEGRAM_BOT_TOKEN (or `skip`)",
            )
        if not _is_openclaw_skip_reply(token_reply):
            if len(token_reply) < 20:
                return _reprompt_openclaw_text_step(
                    handler,
                    conversation_id,
                    "telegram_token",
                    state,
                    message="Telegram bot tokens usually look like `<digits>:<secret>`. Paste a valid token or `skip`.",
                    prompt_message="Enter TELEGRAM_BOT_TOKEN (or `skip`)",
                )
            state = _merge_existing_openclaw_telegram_state(
                handler,
                state,
                str(state.get("alias") or "openclaw").strip() or "openclaw",
                include_token=False,
            )
            state["telegram_token"] = str(token_reply).strip()
            _save_openclaw_state(handler, conversation_id, "telegram_admins", state)
            admin_message = "Optional: enter Telegram admin user/chat IDs as comma-separated values so OpenClaw allowlists the channel, or `skip`."
            if str(state.get("telegram_admin_ids") or "").strip():
                admin_message = (
                    "Optional: enter Telegram admin user/chat IDs as comma-separated values so OpenClaw allowlists the channel, "
                    "or `skip` to keep the current settings, or `clear` to remove the current allowlist."
                )
            return "", {
                "message": admin_message,
                "ui_action": {"type": "text", "message": "Enter Telegram admin IDs (or `skip`)"},
            }
        state = _merge_existing_openclaw_telegram_state(
            handler,
            state,
            str(state.get("alias") or "openclaw").strip() or "openclaw",
        )
        if str(state.get("telegram_token") or "").strip():
            _save_openclaw_state(handler, conversation_id, "telegram_admins", state)
            admin_message = "Optional: enter Telegram admin user/chat IDs as comma-separated values so OpenClaw allowlists the channel, or `skip` to keep the current settings."
            if str(state.get("telegram_admin_ids") or "").strip():
                admin_message = (
                    "Optional: enter Telegram admin user/chat IDs as comma-separated values so OpenClaw allowlists the channel, "
                    "or `skip` to keep the current settings, or `clear` to remove the current allowlist."
                )
            return "", {
                "message": admin_message,
                "ui_action": {"type": "text", "message": "Enter Telegram admin IDs (or `skip`)"},
            }
        _save_openclaw_state(handler, conversation_id, "armory_skills", state)
        available_skills = _list_armory_skill_names()
        skill_hint = f" Available: {', '.join(available_skills)}." if available_skills else ""
        return "", {
            "message": f"Optional: enter Armory skill names to inject into OpenClaw, comma-separated, or `skip`.{skill_hint}",
            "ui_action": {
                "type": "text",
                "message": "Enter Armory skill names (or `skip`)",
                "default": "",
            },
        }

    if step == "telegram_admins":
        admin_reply_raw = str(reply or "").strip()
        admin_reply = _extract_openclaw_telegram_admin_reply_value(admin_reply_raw) or admin_reply_raw
        if unrelated_question:
            return admin_reply_raw, None
        if _looks_like_question(admin_reply_raw):
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "telegram_admins",
                state,
                message=(
                    "Use Telegram `getUpdates` or inspect an incoming message payload and copy the numeric "
                    "`from.id` / `chat.id` values you want OpenClaw to allowlist. "
                    "Reply `skip` to leave the Telegram channel open."
                ),
                prompt_message="Enter Telegram admin IDs (or `skip`)",
            )
        if not admin_reply_raw:
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "telegram_admins",
                state,
                message="Enter Telegram admin IDs as comma-separated numbers, or reply `skip` to leave the channel open.",
                prompt_message="Enter Telegram admin IDs (or `skip`)",
            )
        if _is_openclaw_clear_reply(admin_reply):
            state["telegram_admin_ids"] = ""
            state["clear_telegram_admin_ids"] = True
        elif not _is_openclaw_skip_reply(admin_reply):
            ids, invalid = _parse_telegram_admin_ids(admin_reply)
            if invalid:
                return _reprompt_openclaw_text_step(
                    handler,
                    conversation_id,
                    "telegram_admins",
                    state,
                    message=f"Telegram admin IDs must be numeric. Invalid values: {', '.join(invalid[:5])}.",
                    prompt_message="Enter Telegram admin IDs (or `skip`)",
                )
            state["telegram_admin_ids"] = ",".join(ids)
            state.pop("clear_telegram_admin_ids", None)
        _save_openclaw_state(handler, conversation_id, "alert_chat", state)
        alert_message = "Optional: enter TELEGRAM_ALERT_CHAT_ID for Armorer alerts, or `skip`."
        if str(state.get("alert_chat_id") or "").strip():
            alert_message = (
                "Optional: enter TELEGRAM_ALERT_CHAT_ID for Armorer alerts, "
                "or `skip` to keep the current setting, or `clear` to remove it."
            )
        return "", {
            "message": alert_message,
            "ui_action": {"type": "text", "message": "Enter TELEGRAM_ALERT_CHAT_ID (or `skip`)"},
        }

    if step == "alert_chat":
        alert_chat_raw = str(reply or "").strip()
        alert_chat_id = _extract_openclaw_alert_chat_reply_value(alert_chat_raw) or alert_chat_raw
        if unrelated_question:
            return alert_chat_raw, None
        if _looks_like_question(alert_chat_raw):
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "alert_chat",
                state,
                message=(
                    "Use the numeric Telegram `chat.id` where Armorer should send alerts. "
                    "You can obtain it from `getUpdates` or by inspecting a message payload. "
                    "Reply `skip` to continue without a dedicated alert chat."
                ),
                prompt_message="Enter TELEGRAM_ALERT_CHAT_ID (or `skip`)",
            )
        if not alert_chat_raw:
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "alert_chat",
                state,
                message="Enter TELEGRAM_ALERT_CHAT_ID or reply `skip` to continue without a dedicated alert chat.",
                prompt_message="Enter TELEGRAM_ALERT_CHAT_ID (or `skip`)",
            )
        if _is_openclaw_clear_reply(alert_chat_id):
            state["alert_chat_id"] = ""
            state["clear_alert_chat_id"] = True
        elif not _is_openclaw_skip_reply(alert_chat_id):
            if not _is_valid_telegram_id(alert_chat_id):
                return _reprompt_openclaw_text_step(
                    handler,
                    conversation_id,
                    "alert_chat",
                    state,
                    message="TELEGRAM_ALERT_CHAT_ID must be a numeric Telegram chat ID. Example: `-1001234567890`.",
                    prompt_message="Enter TELEGRAM_ALERT_CHAT_ID (or `skip`)",
                )
            state["alert_chat_id"] = alert_chat_id
            state.pop("clear_alert_chat_id", None)
        _save_openclaw_state(handler, conversation_id, "armory_skills", state)
        available_skills = _list_armory_skill_names()
        skill_hint = f" Available: {', '.join(available_skills)}." if available_skills else ""
        skill_message = f"Optional: enter Armory skill names to inject into OpenClaw, comma-separated, or `skip`.{skill_hint}"
        if _resolved_openclaw_armory_skill_names(handler, state, str(state.get('alias') or 'openclaw').strip() or 'openclaw'):
            skill_message = (
                "Optional: enter Armory skill names to inject into OpenClaw, comma-separated, "
                f"or `skip` to keep the current injections, or `clear` to remove them.{skill_hint}"
            )
        return "", {
            "message": skill_message,
            "ui_action": {
                "type": "text",
                "message": "Enter Armory skill names (or `skip`)",
                "default": "",
            },
        }

    if step == "armory_skills":
        armory_skills_raw = str(reply or "").strip()
        armory_skills_reply = _extract_openclaw_armory_skills_reply_value(armory_skills_raw)
        if unrelated_question:
            return armory_skills_raw, None
        if _looks_like_question(armory_skills_raw):
            available_skills = _list_armory_skill_names()
            skill_hint = f" Available: {', '.join(available_skills)}." if available_skills else ""
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "armory_skills",
                state,
                message=f"Enter comma-separated Armory skill names to inject into OpenClaw, or reply `skip` to continue without injection.{skill_hint}",
                prompt_message="Enter Armory skill names (or `skip`)",
                default="",
            )
        if not armory_skills_raw:
            available_skills = _list_armory_skill_names()
            skill_hint = f" Available: {', '.join(available_skills)}." if available_skills else ""
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "armory_skills",
                state,
                message=f"Enter Armory skill names or reply `skip` to continue without injection.{skill_hint}",
                prompt_message="Enter Armory skill names (or `skip`)",
                default="",
            )
        if not armory_skills_reply:
            available_skills = _list_armory_skill_names()
            skill_hint = f" Available: {', '.join(available_skills)}." if available_skills else ""
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "armory_skills",
                state,
                message=(
                    "Enter comma-separated Armory skill names (for example `filesystem,brave-search`) "
                    f"or reply `skip`.{skill_hint}"
                ),
                prompt_message="Enter Armory skill names (or `skip`)",
                default="",
            )
        if _is_openclaw_clear_reply(armory_skills_reply):
            state.pop("armory_skills", None)
            state.pop("resolved_armory_skills", None)
            state["clear_armory_skills"] = True
        elif not _is_openclaw_skip_reply(armory_skills_reply):
            state["armory_skills"] = ",".join(_parse_csv_values(armory_skills_reply))
            state.pop("clear_armory_skills", None)
        _save_openclaw_state(handler, conversation_id, "skills", state)
        next_runtime = _resolve_openclaw_runtime_settings(handler, state)
        next_skill_paths = next_runtime.get("skill_paths") or []
        skill_dirs_message = "Optional: enter OpenClaw skill directories to scan, comma-separated, or `skip`."
        if next_skill_paths:
            skill_dirs_message = (
                "Optional: enter OpenClaw skill directories to scan, comma-separated, "
                "or `skip` to keep the current paths, or `clear` to reset to the default scan paths."
            )
        return "", {
            "message": skill_dirs_message,
            "ui_action": {
                "type": "text",
                "message": "Enter skill directories (or `skip`)",
                "default": "skills,.openclaw/skills",
            },
        }

    if step == "skills":
        skills_raw = str(reply or "").strip()
        skills_reply = _extract_openclaw_skill_paths_reply_value(skills_raw)
        if unrelated_question:
            return skills_raw, None
        if _looks_like_question(skills_raw):
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "skills",
                state,
                message="Enter comma-separated OpenClaw skill directories to scan, such as `skills,.openclaw/skills`, or reply `skip` to keep the current paths.",
                prompt_message="Enter skill directories (or `skip`)",
                default="skills,.openclaw/skills",
            )
        if not skills_raw:
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "skills",
                state,
                message="Enter skill directories or reply `skip` to keep the default scan paths.",
                prompt_message="Enter skill directories (or `skip`)",
                default="skills,.openclaw/skills",
            )
        if not skills_reply:
            return _reprompt_openclaw_text_step(
                handler,
                conversation_id,
                "skills",
                state,
                message=(
                    "Enter comma-separated OpenClaw skill directories, such as "
                    "`skills,.openclaw/skills`, or reply `skip`."
                ),
                prompt_message="Enter skill directories (or `skip`)",
                default="skills,.openclaw/skills",
            )
        if _is_openclaw_clear_reply(skills_reply):
            state.pop("skills_paths", None)
            state["clear_skill_paths"] = True
        elif not _is_openclaw_skip_reply(skills_reply):
            state["skills_paths"] = ",".join(_parse_csv_values(skills_reply))
            state.pop("clear_skill_paths", None)
        _save_openclaw_state(handler, conversation_id, "confirm", state)
        return "", {
            "message": _build_openclaw_confirmation_message(handler, state),
            "ui_action": {
                "type": "confirm",
                "message": "Install and configure OpenClaw now?",
                "default": True,
            },
        }

    if step == "confirm":
        if unrelated_question:
            return reply, None
        if _is_openclaw_setup_no(reply):
            handler._clear_pending_decision(conversation_id)
            handler._clear_focus(conversation_id)
            return "", {"message": "Cancelled OpenClaw setup."}
        confirm_yes = _is_openclaw_setup_yes(reply)
        if not confirm_yes:
            _save_openclaw_state(handler, conversation_id, "confirm", state)
            return "", {
                "message": "Reply `yes` to start the OpenClaw install or `cancel` to stop.",
                "ui_action": {
                    "type": "confirm",
                    "message": "Install and configure OpenClaw now?",
                    "default": True,
                },
            }
        if _openclaw_missing_runtime_prerequisites(_resolve_openclaw_runtime_settings(handler, state)):
            return _resume_openclaw_missing_runtime_prerequisite(handler, conversation_id, state)
        handler._clear_pending_decision(conversation_id)
        try:
            payload = _execute_openclaw_setup(handler, state, task_id)
            handler._clear_focus(conversation_id)
            return "", payload
        except SupersededTaskError as e:
            handler._clear_focus(conversation_id)
            return "", {"message": str(e)}
        except Exception as e:
            handler._clear_focus(conversation_id)
            safe_error = _sanitize_openclaw_setup_error_message(handler, source, e)
            try:
                handler._write_task_status(task_id, f"OpenClaw setup failed: {safe_error}")
            except SupersededTaskError as superseded:
                handler._clear_pending_decision(conversation_id)
                return "", {"message": str(superseded)}
            hint = _openclaw_setup_failure_hint(e)
            lines = [f"OpenClaw setup failed: {safe_error}"]
            if hint:
                lines.append(hint)
            _ExecError = _setup_exec_error()
            rollback_warnings = (
                list(getattr(e, "rollback_warnings", []) or [])
                if isinstance(e, _ExecError)
                else []
            )
            safe_rollback_warnings = [
                _sanitize_openclaw_setup_detail_text(handler, str(item))
                for item in rollback_warnings
                if str(item).strip()
            ]
            if isinstance(e, _ExecError):
                rollback_summary = str(getattr(e, "rollback_summary", "") or "").strip()
                if not rollback_summary:
                    rollback_summary = "Armorer restored the previous OpenClaw runtime state before retry."
                if safe_rollback_warnings:
                    lines.append(
                        rollback_summary.rstrip(".") + ", "
                        "but rollback reported warnings: " + "; ".join(safe_rollback_warnings[:3])
                    )
                else:
                    lines.append(rollback_summary)
            else:
                lines.append("No further changes were applied after the failure.")
            lines.append("Retry with the same settings or cancel to stop.")
            _save_openclaw_state(handler, conversation_id, "confirm", state)
            return "", {
                "message": "\n".join(lines),
                "ui_action": {
                    "type": "confirm",
                    "message": "Retry OpenClaw setup with the same settings?",
                    "default": True,
                },
            }

    return raw_prompt, None


def _setup_security_preflight(handler, conversation_id: str, prompt: str) -> tuple[str, dict | None]:
    raw_prompt = str(prompt or "").strip()
    if not raw_prompt:
        return raw_prompt, None

    pending = handler._pending_decisions.get(conversation_id, {})
    pending_type = str(pending.get("type") or "").strip()
    reply = _extract_reply_value(raw_prompt)
    cancel_words = {"cancel", "abort", "stop", "nevermind", "never mind"}

    if raw_prompt.startswith("[SECURE_SETUP_PROFILE="):
        return raw_prompt, None

    if pending_type == "setup_security_profile":
        if str(reply).strip().lower() in cancel_words:
            handler._clear_pending_decision(conversation_id)
            return "", {"message": "Cancelled secure setup planning."}

        choices = {
            "1": "strict",
            "2": "balanced",
            "3": "flexible",
            "strict": "strict",
            "balanced": "balanced",
            "flexible": "flexible",
        }
        profile = choices.get(str(reply).strip().lower())
        if not profile:
            handler._set_pending_decision(
                conversation_id,
                {"type": "setup_security_profile", "created_at": time.time(), "original_prompt": str(pending.get("original_prompt") or raw_prompt)},
            )
            return "", {
                "message": (
                    "Select a secure setup profile:\n"
                    "1) strict (recommended)\n"
                    "2) balanced\n"
                    "3) flexible"
                ),
                "ui_action": {
                    "type": "select",
                    "message": "Choose setup security profile",
                    "options": ["strict", "balanced", "flexible"],
                },
            }

        original = str(pending.get("original_prompt") or "")
        handler._clear_pending_decision(conversation_id)
        enriched = (
            f"[SECURE_SETUP_PROFILE={profile}]\n"
            "Apply this profile while setting up the assistant:\n"
            "- strict: localhost-only exposure by default, explicit allowlists, no unconfigured admins, vault-only secrets.\n"
            "- balanced: secure defaults with explicit operator confirmation for exceptions.\n"
            "- flexible: prioritize compatibility but still request confirmation for high-risk changes.\n\n"
            f"{original or raw_prompt}"
        )
        return enriched, None

    if not _looks_like_agent_setup_request(raw_prompt):
        return raw_prompt, None

    handler._set_pending_decision(
        conversation_id,
        {
            "type": "setup_security_profile",
            "created_at": time.time(),
            "original_prompt": raw_prompt,
        },
    )
    return "", {
        "message": (
            "Before I configure a third-party assistant, choose a setup security profile "
            "so decisions match your risk tolerance."
        ),
        "ui_action": {
            "type": "select",
            "message": "Choose setup security profile",
            "options": ["strict", "balanced", "flexible"],
        },
    }
