from __future__ import annotations

from dataclasses import asdict
import os
from pathlib import Path
from typing import Any, Dict, List, Optional


from armorer.agent_sdk import tool
from armorer.apps.openclaw import (
    OpenClawInstanceConfig,
    apply_openclaw_instance_config,
    approve_openclaw_pairings,
    bind_openclaw_channels,
    check_openclaw_skill,
    discover_openclaw_skills,
    ensure_openclaw_skill_runtime_dependencies,
    load_openclaw_instance_config,
    runtime_openclaw_bin_exists,
    verify_openclaw_channels,
)
from armorer.apps.openclaw.config_adapter import OPENCLAW_PROVIDER_ENV_KEYS
from armorer.apps.openclaw.discovery import openclaw_discovered_catalog, openclaw_discovered_providers, normalize_openclaw_provider_id
from armorer.compose_runtime import (
    load_compose_services,
    recreate_compose_service,
    wait_for_compose_service_ready,
)
from armorer.apps.openclaw.credential_help import CREDENTIAL_HELP
from armorer.config import ConfigManager
from armorer.openclaw_config import (
    ensure_openclaw_runtime_bootstrap,
    resolve_openclaw_registered_install_path,
)
from armorer.registry import AppRegistry
from armorer.tailscale import ensure_tailscale_serve, tailscale_serve_status_summary, tailscale_status_summary
from dotenv import dotenv_values


_PROVIDER_CHOICES = ["openai", "anthropic", "gemini", "openrouter", "ollama"]
_WEB_CHOICES = ["disabled", "brave", "perplexity", "gemini", "grok", "kimi"]
_CHANNEL_CHOICES = ["telegram", "discord", "slack"]

_PROVIDER_ENV_KEYS = dict(OPENCLAW_PROVIDER_ENV_KEYS)

_CHANNEL_ENV_KEYS = {
    "telegram": ["TELEGRAM_BOT_TOKEN"],
    "discord": ["DISCORD_BOT_TOKEN"],
    "slack": ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"],
}

_WEB_ENV_KEYS = {
    "brave": "BRAVE_API_KEY",
    "perplexity": "PERPLEXITY_API_KEY",
    "gemini": "GEMINI_API_KEY",
    "grok": "XAI_API_KEY",
    "kimi": "MOONSHOT_API_KEY",
}


def _normalize_openclaw_access_mode(value: Any) -> str:
    mode = str(value or "").strip().lower()
    if mode == "remote_tailscale":
        return "remote_tailscale"
    return "local"


def _openclaw_tailscale_state(current: OpenClawInstanceConfig) -> Dict[str, Any]:
    local_status = tailscale_status_summary()
    remote_target = str(current.remote_host or "").strip() if current.access_mode == "remote_tailscale" else ""
    remote_status = tailscale_status_summary(remote_target) if remote_target else local_status
    local_serve_status = tailscale_serve_status_summary()
    remote_serve_status = tailscale_serve_status_summary(remote_target) if remote_target else local_serve_status
    magicdns_name = str(current.remote_magicdns_name or remote_status.get("magicdns_name") or "").strip()
    configured = bool(
        current.access_mode == "remote_tailscale"
        and remote_status.get("available")
        and remote_status.get("logged_in")
        and magicdns_name
        and remote_serve_status.get("enabled")
    )
    last_check_error = str(remote_status.get("last_check_error") or remote_serve_status.get("last_check_error") or "").strip()
    if current.access_mode == "remote_tailscale" and not magicdns_name and not last_check_error:
        last_check_error = "Tailscale is signed in, but no MagicDNS name is available on the selected host."
    return {
        "configured": configured,
        "local_available": bool(local_status.get("available")),
        "local_logged_in": bool(local_status.get("logged_in")),
        "remote_available": bool(remote_status.get("available")),
        "remote_logged_in": bool(remote_status.get("logged_in")),
        "remote_magicdns_name": magicdns_name,
        "tailnet_name": str(remote_status.get("tailnet_name") or local_status.get("tailnet_name") or "").strip(),
        "reachable": bool(remote_status.get("reachable") and magicdns_name and remote_serve_status.get("enabled")),
        "serve_enabled": bool(remote_serve_status.get("enabled")),
        "serve_configured": bool(remote_serve_status.get("configured")),
        "serve_enable_url": str(remote_serve_status.get("enable_url") or "").strip(),
        "last_check_error": last_check_error,
    }


def _is_channel_secret(key_name: str) -> bool:
    return any(key_name in values for values in _CHANNEL_ENV_KEYS.values())


def _channel_secret_value(cfg: ConfigManager, agent: str, key_name: str) -> Optional[str]:
    return cfg.get_scoped_secret(agent, key_name, fallback_to_global=False)


def _secret_value(cfg: ConfigManager, agent: str, key_name: str) -> Optional[str]:
    if _is_channel_secret(key_name):
        return _channel_secret_value(cfg, agent, key_name)
    return cfg.find_vault_key(key_name) or cfg.get_key(key_name)


def _resolve_app(agent: str):
    registry = AppRegistry()
    app = registry.get_app(agent)
    if not app:
        return None, None, {
            "ok": False,
            "message": f"OpenClaw instance '{agent}' is not installed. Install it first.",
        }
    if (app.agent_id or app.name) != "openclaw":
        return None, None, {
            "ok": False,
            "message": f"'{agent}' is not an OpenClaw instance.",
        }
    install_dir = resolve_openclaw_registered_install_path(app.install_path)
    if not install_dir.exists():
        return None, None, {
            "ok": False,
            "message": f"Install path no longer exists: {install_dir}",
        }
    return registry, app, install_dir


def _default_model(provider: str) -> str:
    return {
        "openai": "gpt-4o",
        "anthropic": "claude-sonnet-4-20250514",
        "gemini": "gemini-2.0-flash",
        "google": "gemini-2.0-flash",
        "groq": "llama-3.3-70b-versatile",
        "xai": "grok-3-mini",
        "moonshot": "kimi-k2-0711-preview",
        "kimi": "kimi-k2-0711-preview",
        "mistral": "mistral-large-latest",
        "together": "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo",
        "cerebras": "llama3.1-70b",
        "openrouter": "qwen/qwen-2.5-coder-32b-instruct",
        "ollama": "llama3",
    }.get(provider, "gpt-4o")


def _build_openclaw_runtime_env(install_dir: Path) -> Dict[str, str]:
    run_env = os.environ.copy()
    getuid = getattr(os, "getuid", None)
    getgid = getattr(os, "getgid", None)
    if callable(getuid):
        run_env.setdefault("UID", str(getuid()))
    if callable(getgid):
        run_env.setdefault("GID", str(getgid()))
    env_vars = dotenv_values(install_dir / ".env")
    for key in ("OPENCLAW_CONFIG_DIR", "OPENCLAW_WORKSPACE_DIR"):
        raw = str(env_vars.get(key) or "").strip()
        if not raw:
            continue
        abs_p = Path(raw).expanduser()
        if not abs_p.is_absolute():
            abs_p = (install_dir / abs_p).resolve()
        run_env[key] = str(abs_p)
    for key, val in env_vars.items():
        if key in {"OPENCLAW_CONFIG_DIR", "OPENCLAW_WORKSPACE_DIR"}:
            continue
        sval = str(val or "").strip()
        if not sval:
            continue
        if sval.startswith("/") or sval.startswith("./") or sval.startswith("../"):
            run_env[key] = str((install_dir / sval).resolve())
    return run_env


def _openclaw_provider_catalog(agent: str, install_dir: Path) -> Dict[str, Any]:
    fallback_providers = list(_PROVIDER_CHOICES)
    fallback_models = {provider: [_default_model(provider)] for provider in fallback_providers}
    models, discovery_error = openclaw_discovered_catalog(
        agent,
        resolve_install_dir=lambda candidate: install_dir if candidate == agent else None,
        build_runtime_env=_build_openclaw_runtime_env,
    )
    if not models:
        return {
            "providers": fallback_providers,
            "provider_default_models": {provider: _default_model(provider) for provider in fallback_providers},
            "provider_models": fallback_models,
            "discovery_error": discovery_error or "",
        }

    discovered_providers: List[str] = []
    seen_providers: set[str] = set()
    provider_models: Dict[str, List[str]] = {}
    for provider in openclaw_discovered_providers(models):
        normalized_provider = normalize_openclaw_provider_id(provider)
        if normalized_provider in seen_providers:
            continue
        seen_providers.add(normalized_provider)
        discovered_providers.append(normalized_provider)
    for entry in models:
        key = str(entry.get("key") or "").strip()
        if "/" not in key:
            continue
        raw_provider, raw_model = key.split("/", 1)
        provider = normalize_openclaw_provider_id(raw_provider)
        model = str(raw_model or "").strip()
        if not provider or not model:
            continue
        bucket = provider_models.setdefault(provider, [])
        if model not in bucket:
            bucket.append(model)
    providers = discovered_providers or fallback_providers
    default_models = {
        provider: (provider_models.get(provider, []) or [_default_model(provider)])[0]
        for provider in providers
    }
    for provider in providers:
        provider_models.setdefault(provider, [_default_model(provider)])
    return {
        "providers": providers,
        "provider_default_models": default_models,
        "provider_models": provider_models,
        "discovery_error": discovery_error or "",
    }


def _normalize_csv_list(raw: Any) -> List[str]:
    if raw is None:
        return []
    if isinstance(raw, list):
        return [str(v).strip() for v in raw if str(v).strip()]
    if isinstance(raw, str):
        value = raw.strip()
        if not value or value.lower() in {"none", "skip", "no"}:
            return []
        return [part.strip() for part in value.split(",") if part.strip()]
    return []


def _credential_hint(key_name: str) -> Dict[str, str]:
    return dict(CREDENTIAL_HELP.get(key_name) or {})


def _list_skill_metadata(install_dir: Path, cfg: ConfigManager, staged_secrets: Optional[Dict[str, str]] = None) -> List[Dict[str, Any]]:
    staged = staged_secrets or {}

    def env_lookup(name: str) -> Optional[str]:
        return staged.get(name) or cfg.find_vault_key(name) or cfg.get_key(name)

    skills: List[Dict[str, Any]] = []
    for skill in discover_openclaw_skills(install_dir):
        check = check_openclaw_skill(
            skill,
            env_lookup=env_lookup,
            bin_lookup=lambda name: runtime_openclaw_bin_exists(install_dir, name),
        )
        skills.append(
            {
                "key": skill.key,
                "name": skill.name,
                "description": skill.description,
                "primary_env": skill.primary_env,
                "required_env": list(skill.required_env),
                "required_bins": list(skill.required_bins),
                "ready": check.ready,
                "missing_env": list(check.missing_env),
                "missing_bins": list(check.missing_bins),
                "install_help": list(check.install_help),
            }
        )
    return skills


def _merge_config(current: OpenClawInstanceConfig, config_patch: Dict[str, Any]) -> OpenClawInstanceConfig:
    merged = asdict(current)
    for key, value in (config_patch or {}).items():
        if key not in merged:
            continue
        if key in {"enabled_channels", "enabled_skills", "trusted_proxies"}:
            merged[key] = _normalize_csv_list(value)
        elif key == "skill_settings" and isinstance(value, dict):
            merged[key] = value
        elif value is not None:
            merged[key] = value

    merged["access_mode"] = _normalize_openclaw_access_mode(merged.get("access_mode"))
    if merged["access_mode"] != "remote_tailscale":
        merged["remote_host"] = ""
        merged["remote_magicdns_name"] = ""

    provider = str(merged.get("primary_provider") or current.primary_provider)
    if not merged.get("default_model"):
        merged["default_model"] = _default_model(provider)
    return OpenClawInstanceConfig(**merged)


def _prepare_skill_settings(
    install_dir: Path,
    cfg: ConfigManager,
    selected_skills: List[str],
    staged_secrets: Dict[str, str],
) -> tuple[List[str], Dict[str, Dict[str, Any]], List[str]]:
    catalog = {skill.key: skill for skill in discover_openclaw_skills(install_dir)}
    ready_skills: List[str] = []
    skill_settings: Dict[str, Dict[str, Any]] = {}
    warnings: List[str] = []
    selected_skill_objs = [catalog[name] for name in selected_skills if name in catalog]

    def env_lookup(name: str) -> Optional[str]:
        return staged_secrets.get(name) or cfg.find_vault_key(name) or cfg.get_key(name)

    runtime_result = ensure_openclaw_skill_runtime_dependencies(install_dir, selected_skill_objs)
    warnings.extend(runtime_result.get("warnings", []))

    for skill_name in selected_skills:
        skill = catalog.get(skill_name)
        if not skill:
            warnings.append(f"Unknown skill '{skill_name}' was skipped.")
            continue
        result = check_openclaw_skill(
            skill,
            env_lookup=env_lookup,
            bin_lookup=lambda name: runtime_openclaw_bin_exists(install_dir, name),
        )
        if not result.ready:
            parts: List[str] = []
            if result.missing_env:
                parts.append(f"missing credentials: {', '.join(result.missing_env)}")
            if result.missing_bins:
                parts.append(f"missing binaries: {', '.join(result.missing_bins)}")
            if result.install_help:
                parts.append(f"available installers: {', '.join(result.install_help)}")
            warnings.append(f"Skill '{skill_name}' not enabled because " + "; ".join(parts))
            continue
        ready_skills.append(skill_name)
        entry: Dict[str, Any] = {"enabled": True}
        if skill.primary_env and env_lookup(skill.primary_env):
            entry["apiKey"] = env_lookup(skill.primary_env)
        extra_env = {
            env_key: env_lookup(env_key)
            for env_key in skill.required_env
            if env_lookup(env_key)
        }
        if extra_env:
            entry["env"] = extra_env
        skill_settings[skill_name] = entry
    return ready_skills, skill_settings, warnings


def _collect_effective_secrets(
    agent: str,
    install_dir: Path,
    cfg: ConfigManager,
    proposed: OpenClawInstanceConfig,
    staged_secrets: Dict[str, str],
) -> Dict[str, str]:
    effective = dict(staged_secrets)

    def remember(key_name: Optional[str]) -> None:
        if not key_name:
            return
        current = str(effective.get(key_name) or "").strip()
        if current:
            return
        value = staged_secrets.get(key_name) or _secret_value(cfg, agent, key_name)
        if value:
            effective[key_name] = value

    remember(_PROVIDER_ENV_KEYS.get(proposed.primary_provider))
    if proposed.primary_provider == "ollama":
        remember("OLLAMA_BASE_URL")

    for channel in proposed.enabled_channels:
        for key_name in _CHANNEL_ENV_KEYS.get(channel, []):
            remember(key_name)

    remember(_WEB_ENV_KEYS.get(proposed.web_search_provider))

    skill_catalog = {skill.key: skill for skill in discover_openclaw_skills(install_dir)}
    for skill_name in proposed.enabled_skills:
        skill = skill_catalog.get(skill_name)
        if not skill:
            continue
        for key_name in dict.fromkeys([skill.primary_env, *skill.required_env]):
            remember(key_name)

    return effective


@tool(category="setup_state", verification_level="live_state", safe_for_retrieval=True)
def get_openclaw_setup_context(agent: str) -> Dict[str, Any]:
    """Return current OpenClaw config state, available options, credential hints, and skill readiness."""
    _, app_meta, resolved = _resolve_app(agent)
    if not app_meta:
        return resolved
    install_dir = resolved
    cfg = ConfigManager(env_path=str(install_dir / ".env"))
    current = load_openclaw_instance_config(
        install_dir,
        app_meta.app_config_state if isinstance(app_meta.app_config_state, dict) else {},
    )
    tailscale = _openclaw_tailscale_state(current)
    provider_catalog = _openclaw_provider_catalog(agent, install_dir)

    required_credentials: Dict[str, Dict[str, Any]] = {}
    for provider, env_key in _PROVIDER_ENV_KEYS.items():
        required_credentials[env_key] = {
            "kind": "provider",
            "provider": provider,
            "present": bool(_secret_value(cfg, agent, env_key)),
            "help": _credential_hint(env_key),
        }
    for keys in _CHANNEL_ENV_KEYS.values():
        for env_key in keys:
            required_credentials[env_key] = {
                "kind": "channel",
                "present": bool(_secret_value(cfg, agent, env_key)),
                "help": _credential_hint(env_key),
            }
    for provider, env_key in _WEB_ENV_KEYS.items():
        required_credentials[env_key] = {
            "kind": "web",
            "provider": provider,
            "present": bool(_secret_value(cfg, agent, env_key)),
            "help": _credential_hint(env_key),
        }

    provider_env = _PROVIDER_ENV_KEYS.get(current.primary_provider, "")
    current_provider_key_present = bool(provider_env and _secret_value(cfg, agent, provider_env))
    channel_state = {
        channel: {
            "enabled": channel in current.enabled_channels,
            "required_keys": list(_CHANNEL_ENV_KEYS.get(channel, [])),
            "present_keys": [
                key_name
                for key_name in _CHANNEL_ENV_KEYS.get(channel, [])
                if _secret_value(cfg, agent, key_name)
            ],
            "missing_keys": [
                key_name
                for key_name in _CHANNEL_ENV_KEYS.get(channel, [])
                if not _secret_value(cfg, agent, key_name)
            ],
        }
        for channel in _CHANNEL_CHOICES
    }
    web_env = _WEB_ENV_KEYS.get(current.web_search_provider, "")
    web_key_present = bool(web_env and _secret_value(cfg, agent, web_env))
    next_questions: List[str] = []
    if current.primary_provider:
        next_questions.append(
            f"Confirm whether to keep provider '{current.primary_provider}' or switch to one of: {', '.join(provider_catalog.get('providers') or _PROVIDER_CHOICES)}."
        )
    else:
        next_questions.append(f"Ask which provider to use: {', '.join(provider_catalog.get('providers') or _PROVIDER_CHOICES)}.")
    if current.default_model:
        next_questions.append(
            f"Confirm whether to keep model '{current.default_model}' or replace it with an exact model id."
        )
    else:
        next_questions.append("Ask for the exact model id after the provider is chosen.")
    if provider_env:
        if current_provider_key_present:
            next_questions.append(f"Tell the user {provider_env} is already set and ask whether to keep it.")
        else:
            next_questions.append(f"Ask for {provider_env} and explain where to get it.")
    if current.enabled_channels:
        for channel in current.enabled_channels:
            missing = channel_state.get(channel, {}).get("missing_keys", [])
            if missing:
                next_questions.append(
                    f"Tell the user channel '{channel}' is enabled but missing credentials: {', '.join(missing)}."
                )
            else:
                next_questions.append(
                    f"Tell the user channel '{channel}' is already configured and its required credentials are present."
                )
    if current.web_search_provider and current.web_search_provider != "disabled":
        if web_key_present:
            next_questions.append(
                f"Tell the user web search provider '{current.web_search_provider}' is already configured and its credential is present."
            )
        elif web_env:
            next_questions.append(
                f"Tell the user web search provider '{current.web_search_provider}' is selected but {web_env} is missing."
            )

    return {
        "ok": True,
        "agent": agent,
        "current_config": asdict(current),
        "state_summary": {
            "provider": current.primary_provider,
            "model": current.default_model,
            "access_mode": current.access_mode,
            "remote_host": current.remote_host,
            "remote_magicdns_name": tailscale.get("remote_magicdns_name") or current.remote_magicdns_name,
            "provider_credential_key": provider_env,
            "provider_credential_present": current_provider_key_present,
            "provider_recommended_default_model": _default_model(current.primary_provider),
            "enabled_channels": list(current.enabled_channels),
            "channel_state": channel_state,
            "web_search_provider": current.web_search_provider,
            "web_search_credential_key": web_env,
            "web_search_credential_present": web_key_present,
            "enabled_skills": list(current.enabled_skills),
            "tailscale": tailscale,
        },
        "options": {
            "providers": list(provider_catalog.get("providers") or _PROVIDER_CHOICES),
            "provider_credential_keys": {
                provider: env_key
                for provider, env_key in _PROVIDER_ENV_KEYS.items()
                if env_key
            },
            "provider_default_models": dict(
                provider_catalog.get("provider_default_models")
                or {provider: _default_model(provider) for provider in _PROVIDER_CHOICES}
            ),
            "provider_models": dict(
                provider_catalog.get("provider_models")
                or {provider: [_default_model(provider)] for provider in _PROVIDER_CHOICES}
            ),
            "provider_discovery_error": str(provider_catalog.get("discovery_error") or ""),
            "web_search_providers": list(_WEB_CHOICES),
            "channels": list(_CHANNEL_CHOICES),
            "access_modes": ["local", "remote_tailscale"],
            "skills": _list_skill_metadata(install_dir, cfg),
        },
        "tailscale": tailscale,
        "credentials": required_credentials,
        "setup_kb": {
            "model_setup_requires": ["primary_provider", "default_model", provider_env or "provider_api_key"],
            "channel_requirements": {
                channel: list(keys)
                for channel, keys in _CHANNEL_ENV_KEYS.items()
            },
            "ask_order": {
                "models": ["provider", "exact_model_id", "matching_api_key"],
                "channels": ["confirm_channel", "missing_tokens_only", "bind_and_verify"],
                "web_search": ["provider", "matching_api_key"],
            },
            "next_questions": next_questions,
        },
        "message": (
            f"OpenClaw setup context for '{agent}'. "
            f"Current provider: {current.primary_provider}. Current model: {current.default_model}."
        ),
    }


@tool(category="setup_state", verification_level="selection_validation", safe_for_retrieval=True)
def validate_openclaw_selection(
    agent: str,
    config_patch: Optional[Dict[str, Any]] = None,
    secrets: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
    """Validate a proposed OpenClaw config patch before applying it."""
    _, app_meta, resolved = _resolve_app(agent)
    if not app_meta:
        return resolved
    install_dir = resolved
    cfg = ConfigManager(env_path=str(install_dir / ".env"))
    current = load_openclaw_instance_config(
        install_dir,
        app_meta.app_config_state if isinstance(app_meta.app_config_state, dict) else {},
    )
    provider_catalog = _openclaw_provider_catalog(agent, install_dir)
    proposed = _merge_config(current, config_patch or {})
    staged_secrets = dict(secrets or {})
    tailscale = _openclaw_tailscale_state(proposed)

    errors: List[str] = []
    warnings: List[str] = []
    missing_credentials: List[Dict[str, Any]] = []

    supported_providers = set(provider_catalog.get("providers") or [])
    supported_providers.update(_PROVIDER_ENV_KEYS.keys())
    if proposed.primary_provider not in supported_providers:
        errors.append(f"Unsupported provider '{proposed.primary_provider}'.")

    provider_env = _PROVIDER_ENV_KEYS.get(proposed.primary_provider)
    if provider_env and not (staged_secrets.get(provider_env) or _secret_value(cfg, agent, provider_env)):
        missing_credentials.append({"key": provider_env, "reason": f"Required for provider '{proposed.primary_provider}'.", "help": _credential_hint(provider_env)})

    invalid_channels = [channel for channel in proposed.enabled_channels if channel not in _CHANNEL_CHOICES]
    if invalid_channels:
        errors.append(f"Unsupported channels: {', '.join(invalid_channels)}.")
    for channel in proposed.enabled_channels:
        for env_key in _CHANNEL_ENV_KEYS.get(channel, []):
            if not (staged_secrets.get(env_key) or _secret_value(cfg, agent, env_key)):
                missing_credentials.append({"key": env_key, "reason": f"Required for channel '{channel}'.", "help": _credential_hint(env_key)})

    if proposed.web_search_provider not in _WEB_CHOICES:
        errors.append(f"Unsupported web search provider '{proposed.web_search_provider}'.")
    web_env = _WEB_ENV_KEYS.get(proposed.web_search_provider)
    if web_env and not (staged_secrets.get(web_env) or _secret_value(cfg, agent, web_env)):
        missing_credentials.append({"key": web_env, "reason": f"Required for web search provider '{proposed.web_search_provider}'.", "help": _credential_hint(web_env)})

    available_skills = {skill.key for skill in discover_openclaw_skills(install_dir)}
    invalid_skills = [skill for skill in proposed.enabled_skills if skill not in available_skills]
    if invalid_skills:
        errors.append(f"Unknown skills selected: {', '.join(invalid_skills)}.")

    ready_skills, skill_settings, skill_warnings = _prepare_skill_settings(
        install_dir,
        cfg,
        list(proposed.enabled_skills),
        staged_secrets,
    )
    warnings.extend(skill_warnings)
    if ready_skills != list(proposed.enabled_skills):
        blocked = [skill for skill in proposed.enabled_skills if skill not in ready_skills]
        if blocked:
            warnings.append(f"Some selected skills are not ready and would be skipped: {', '.join(blocked)}.")

    if proposed.access_mode == "remote_tailscale":
        if not tailscale.get("remote_available"):
            if proposed.remote_host:
                errors.append(f"Tailscale CLI is not installed or reachable on '{proposed.remote_host}'.")
            else:
                errors.append("Tailscale CLI is not installed on this host.")
        elif not tailscale.get("remote_logged_in"):
            if proposed.remote_host:
                errors.append(f"Sign in to Tailscale on '{proposed.remote_host}' before enabling remote OpenClaw access.")
            else:
                errors.append("Sign in to Tailscale on this host before enabling remote OpenClaw access.")
        elif not tailscale.get("serve_enabled"):
            errors.append("Enable Tailscale Serve in your tailnet before enabling remote OpenClaw access.")
        elif not tailscale.get("remote_magicdns_name"):
            errors.append("Tailscale did not report a MagicDNS name for the selected host.")
        proposed.remote_magicdns_name = str(tailscale.get("remote_magicdns_name") or "").strip()

    return {
        "ok": not errors,
        "current_config": asdict(current),
        "proposed_config": asdict(proposed),
        "errors": errors,
        "warnings": warnings,
        "tailscale": tailscale,
        "missing_credentials": missing_credentials,
        "prepared_skills": ready_skills,
        "skill_settings": skill_settings,
        "message": "Validation complete." if not errors else "Validation failed.",
    }


@tool(category="setup_state", verification_level="setup_mutation")
def apply_openclaw_config(
    agent: str,
    config_patch: Optional[Dict[str, Any]] = None,
    secrets: Optional[Dict[str, str]] = None,
    bind_channels: bool = True,
    pairing_codes: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
    """Apply an OpenClaw config patch and persist any provided secrets."""
    registry, app_meta, resolved = _resolve_app(agent)
    if not registry:
        return resolved
    install_dir = resolved
    cfg = ConfigManager(env_path=str(install_dir / ".env"))
    current = load_openclaw_instance_config(
        install_dir,
        app_meta.app_config_state if isinstance(app_meta.app_config_state, dict) else {},
    )
    proposed = _merge_config(current, config_patch or {})
    staged_secrets = dict(secrets or {})

    if not config_patch and not staged_secrets and pairing_codes:
        pairing_result = approve_openclaw_pairings(install_dir, pairing_codes or {})
        return {
            "ok": True,
            "message": "OpenClaw pairing approval processed.",
            "warnings": list(pairing_result.get("warnings") or []),
            "channels_bound": False,
            "channel_binding": {"bound_channels": [], "warnings": []},
            "channel_verification": {"statuses": {}, "warnings": []},
            "pairing_approval": pairing_result,
            "result": {
                "app_config_state": asdict(current),
                "managed_files": list(app_meta.managed_files or []),
                "channels_enabled": list(current.enabled_channels),
                "skills_enabled": list(current.enabled_skills),
            },
        }

    validation = validate_openclaw_selection(agent, config_patch=config_patch, secrets=staged_secrets)

    for key_name, value in staged_secrets.items():
        if not value:
            continue
        description = f"OpenClaw credential for {agent}"
        if key_name == "OLLAMA_BASE_URL":
            cfg.set_key(key_name, value)
        elif _is_channel_secret(key_name):
            cfg.save_scoped_secret(agent, key_name, value, description=description)
        else:
            cfg.save_to_vault(key_name, value, description=description)

    if not validation.get("ok"):
        return validation
    if validation.get("missing_credentials"):
        return {
            "ok": False,
            "message": "Missing credentials required before applying OpenClaw configuration.",
            "errors": list(validation.get("errors") or []),
            "warnings": list(validation.get("warnings") or []),
            "missing_credentials": list(validation.get("missing_credentials") or []),
            "prepared_skills": list(validation.get("prepared_skills") or []),
        }

    validated_proposed = validation.get("proposed_config")
    if isinstance(validated_proposed, dict):
        proposed = OpenClawInstanceConfig(**validated_proposed)

    prepared_skills = list(validation.get("prepared_skills") or [])
    proposed.enabled_skills = prepared_skills
    proposed.skill_settings = dict(validation.get("skill_settings") or {})
    effective_secrets = _collect_effective_secrets(agent, install_dir, cfg, proposed, staged_secrets)

    managed_skill_keys = sorted(
        set(current.enabled_skills)
        | set(current.skill_settings.keys())
        | set(proposed.enabled_skills)
        | set(proposed.skill_settings.keys())
    )
    result = apply_openclaw_instance_config(
        install_dir,
        proposed,
        secrets_map=effective_secrets,
        managed_skill_keys=managed_skill_keys,
    )
    runtime_access_issues = list(result.get("runtime_access_issues") or [])
    if runtime_access_issues:
        return {
            "ok": False,
            "message": "OpenClaw runtime files were written, but the gateway runtime still cannot read them.",
            "errors": runtime_access_issues,
            "warnings": list(validation.get("warnings") or []),
            "missing_credentials": [],
            "prepared_skills": prepared_skills,
        }

    runtime_restart = {"restarted": False, "warnings": []}
    if result.get("runtime_env_changed") or result.get("bootstrap_config_changed") or result.get("compose_changed"):
        try:
            ensure_openclaw_runtime_bootstrap(install_dir)
        except Exception as exc:
            return {
                "ok": False,
                "message": "OpenClaw runtime changed, but Armorer could not prepare the gateway runtime for restart.",
                "errors": [str(exc)],
                "warnings": list(validation.get("warnings") or []),
                "missing_credentials": [],
                "prepared_skills": prepared_skills,
            }
        services = load_compose_services(install_dir)
        if "openclaw-gateway" in services:
            restart_result = recreate_compose_service(install_dir, "openclaw-gateway")
            if restart_result.returncode == 0:
                runtime_restart["restarted"] = True
                ready_result = wait_for_compose_service_ready(install_dir, "openclaw-gateway", timeout_seconds=120)
                if not ready_result.get("ok"):
                    detail_parts: List[str] = []
                    status = str(ready_result.get("status") or "").strip()
                    health = str(ready_result.get("health") or "").strip()
                    if status:
                        detail_parts.append(f"status={status}")
                    if health and health != "none":
                        detail_parts.append(f"health={health}")
                    if ready_result.get("restart_count") is not None:
                        detail_parts.append(f"restarts={ready_result.get('restart_count')}")
                    errors = [
                        "OpenClaw gateway did not become ready after configuration was applied."
                    ]
                    if detail_parts:
                        errors.append("Gateway state: " + ", ".join(detail_parts))
                    compose_ps = str(ready_result.get("compose_ps") or "").strip()
                    recent_logs = str(ready_result.get("recent_logs") or "").strip()
                    if compose_ps:
                        errors.append("compose ps:\n" + compose_ps)
                    if recent_logs:
                        errors.append("recent logs:\n" + recent_logs[:2000])
                    return {
                        "ok": False,
                        "message": "OpenClaw gateway failed to become ready after setup.",
                        "errors": errors,
                        "warnings": list(validation.get("warnings") or []),
                        "missing_credentials": [],
                        "prepared_skills": prepared_skills,
                    }
            else:
                err = (restart_result.stderr or restart_result.stdout or "").strip()
                return {
                    "ok": False,
                    "message": "OpenClaw runtime changed, but Armorer could not restart the gateway automatically.",
                    "errors": [err.splitlines()[-1] if err else "unknown error"],
                    "warnings": list(validation.get("warnings") or []),
                    "missing_credentials": [],
                    "prepared_skills": prepared_skills,
                }
    tailscale_publish = {"configured": False, "message": ""}
    if proposed.access_mode == "remote_tailscale":
        tailscale_publish = ensure_tailscale_serve("localhost:3000", host=str(proposed.remote_host or "").strip() or None)
        if not tailscale_publish.get("ok"):
            return {
                "ok": False,
                "message": "OpenClaw settings were saved, but Armorer could not publish the dashboard over Tailscale HTTPS.",
                "errors": [str(tailscale_publish.get("message") or "Tailscale Serve configuration failed.")],
                "warnings": list(validation.get("warnings") or []),
                "missing_credentials": [],
                "prepared_skills": prepared_skills,
            }
    if bind_channels:
        channel_bind = bind_openclaw_channels(install_dir, list(proposed.enabled_channels), effective_secrets)
        channels_to_verify = sorted(
            set(channel_bind.get("bound_channels", []) or [])
            | set(channel_bind.get("config_managed_channels", []) or [])
        )
        channel_verify = verify_openclaw_channels(install_dir, channels_to_verify) if channels_to_verify else {"statuses": {}, "warnings": []}
    else:
        channel_bind = {"bound_channels": [], "config_managed_channels": [], "warnings": []}
        channel_verify = {"statuses": {}, "warnings": []}
    pairing_result = approve_openclaw_pairings(install_dir, pairing_codes or {}) if pairing_codes else {"statuses": {}, "warnings": []}
    app_meta.app_config_state = result.get("app_config_state", {})
    app_meta.managed_files = result.get("managed_files", [])
    app_meta.channels_enabled = result.get("channels_enabled", [])
    app_meta.skills_enabled = result.get("skills_enabled", [])
    app_meta.injected_skills = result.get("skills_enabled", [])
    registry.add_app(app_meta)

    warnings = list(validation.get("warnings") or [])
    warnings.extend(runtime_restart.get("warnings", []))
    warnings.extend(channel_bind.get("warnings", []))
    warnings.extend(channel_verify.get("warnings", []))
    warnings.extend(pairing_result.get("warnings", []))
    return {
        "ok": True,
        "message": (
            f"Applied OpenClaw configuration for '{agent}'. "
            f"Provider: {proposed.primary_provider}. Model: {proposed.default_model}. "
            f"Channels: {', '.join(proposed.enabled_channels) or 'none'}. "
            f"Web search: {proposed.web_search_provider}. "
            f"Skills enabled: {', '.join(prepared_skills) or 'none'}."
        ),
        "warnings": warnings,
        "runtime_restart": runtime_restart,
        "channels_bound": bind_channels,
        "channel_binding": channel_bind,
        "channel_verification": channel_verify,
        "pairing_approval": pairing_result,
        "tailscale_publish": tailscale_publish,
        "result": result,
    }
