from types import SimpleNamespace
from unittest.mock import MagicMock, patch

from typer.testing import CliRunner

from armorer.main import (
    app,
    _configure_armorer_telegram,
    _decorate_run_command_for_menu,
    _ensure_armorer_core,
    _ensure_self_docker_image,
    _format_action_choice_title,
    _has_newer_openclaw_release,
    _has_advanced_telegram_admin_settings,
    _latest_remote_release_tag,
    _menu_section_separator,
    _release_tag_sort_key,
    _inspect_named_container,
    _load_recent_telegram_targets_cache,
    _maybe_onboard_proactive_security,
    _proactive_security_subject,
    _recommended_admin_chat_ids,
    _recommended_admin_user_ids,
    _resolve_install_path,
    _resolve_openclaw_gateway_action,
    _select_model_id_for_provider,
    _provider_model_suggestions,
    _wait_for_telegram_targets_from_live_bridge,
)
from armorer.compose_runtime import build_compose_service_command


runner = CliRunner()


def test_format_action_choice_title_hides_internal_category_tags():
    title = _format_action_choice_title(
        "Gateway",
        "Start the OpenClaw gateway used for Telegram and other channels.",
        tag="run",
        width=90,
    )

    assert "[run]" not in title
    assert "[setup]" not in title
    assert "Gateway" in title


def test_menu_section_separator_uses_visual_divider():
    separator = _menu_section_separator("Gateway")

    assert str(separator) == "━━ Gateway ━━"


def test_release_tag_sort_key_parses_semver_tags():
    assert _release_tag_sort_key("v1.2.3") == (1, 2, 3)
    assert _release_tag_sort_key("1.2.0") == (1, 2, 0)
    assert _release_tag_sort_key("nightly") is None


@patch("armorer.main.subprocess.run")
def test_latest_remote_release_tag_ignores_prerelease_tags(mock_run, tmp_path):
    mock_run.return_value = SimpleNamespace(
        returncode=0,
        stdout=(
            "abc refs/tags/v1.2.0\n"
            "def refs/tags/v1.3.0-rc1\n"
            "ghi refs/tags/v1.1.9\n"
        ),
        stderr="",
    )

    assert _latest_remote_release_tag(tmp_path) == "v1.2.0"


@patch("armorer.main._latest_remote_release_tag")
@patch("armorer.main._latest_local_release_tag")
def test_has_newer_openclaw_release_compares_release_tags(mock_local_tag, mock_remote_tag, tmp_path):
    mock_local_tag.return_value = "v1.2.0"
    mock_remote_tag.return_value = "v1.3.0"

    assert _has_newer_openclaw_release(tmp_path) is True

    mock_remote_tag.return_value = "v1.2.0"
    assert _has_newer_openclaw_release(tmp_path) is False


@patch("armorer.main._compose_service_is_running")
def test_decorate_run_command_for_menu_shows_gateway_state(mock_gateway_running, tmp_path):
    app_meta = _make_app_meta(str(tmp_path))
    cmd = app_meta.run_commands[0]
    mock_gateway_running.return_value = False

    start_cmd = _decorate_run_command_for_menu(app_meta, tmp_path, cmd)
    assert start_cmd["label"] == "Start Gateway"
    assert "Status: stopped." in start_cmd["description"]

    mock_gateway_running.return_value = True
    stop_cmd = _decorate_run_command_for_menu(app_meta, tmp_path, cmd)
    assert stop_cmd["label"] == "Stop Gateway"
    assert "Status: running." in stop_cmd["description"]


def test_inspect_named_container_parses_exact_running_container():
    with patch("armorer.main.subprocess.run") as mock_run:
        mock_run.return_value = SimpleNamespace(returncode=0, stdout="armorer-core\tUp 12 seconds\n", stderr="")
        state = _inspect_named_container("armorer-core")

    assert state == {"exists": True, "running": True, "status": "Up 12 seconds"}


@patch("armorer.main.subprocess.run")
def test_ensure_self_docker_image_reuses_existing_image_by_default(mock_run, tmp_path):
    mock_run.return_value = SimpleNamespace(returncode=0, stdout="[]", stderr="")

    assert _ensure_self_docker_image(tmp_path) is True
    assert mock_run.call_count == 1
    assert mock_run.call_args.args[0][:3] == ["docker", "image", "inspect"]


@patch("armorer.main.console.print")
@patch("armorer.main.subprocess.run")
def test_ensure_self_docker_image_reports_build_failure_cleanly(mock_run, mock_print, tmp_path, monkeypatch):
    monkeypatch.setenv("ARMORER_REBUILD_IMAGE", "1")
    mock_run.side_effect = [
        SimpleNamespace(returncode=0, stdout="[]", stderr=""),
        SimpleNamespace(returncode=1, stdout="", stderr="no space left on device"),
    ]

    assert _ensure_self_docker_image(tmp_path) is False
    printed = " ".join(" ".join(map(str, call.args)) for call in mock_print.call_args_list)
    assert "Failed to build Armorer sandbox image" in printed
    assert "no space left on device" in printed


@patch("armorer.main.console.print")
@patch("armorer.main.os.path.exists", return_value=False)
@patch("armorer.main.subprocess.run")
def test_ensure_armorer_core_fails_fast_when_stale_container_cannot_be_removed(
    mock_run,
    mock_exists,
    mock_print,
):
    mock_run.side_effect = [
        SimpleNamespace(returncode=0, stdout="armorer-core\tExited (137) 2 minutes ago\n", stderr=""),
        SimpleNamespace(returncode=1, stdout="", stderr="permission denied"),
    ]

    try:
        _ensure_armorer_core()
        assert False, "expected SystemExit"
    except SystemExit as exc:
        assert exc.code == 1

    assert mock_run.call_args_list[1].args[0] == ["docker", "rm", "-f", "armorer-core"]


@patch("armorer.main.ConfigManager", create=True)
@patch("armorer.main._proactive_settings")
@patch("armorer.main._ensure_self_docker_image")
@patch("armorer.main._inspect_named_container")
@patch("armorer.main.os.path.exists", return_value=False)
@patch("armorer.main.subprocess.run")
def test_ensure_armorer_core_reuses_existing_image_by_default(
    mock_run,
    mock_exists,
    mock_inspect,
    mock_ensure_image,
    mock_proactive_settings,
    mock_config_cls,
    tmp_path,
):
    src_dir = tmp_path / "src" / "armorer"
    src_dir.mkdir(parents=True)
    (src_dir / "core.py").write_text("print('core')\n")
    mock_exists.side_effect = lambda path: False
    mock_inspect.return_value = {"exists": False, "running": False, "status": ""}
    mock_ensure_image.return_value = True
    mock_proactive_settings.return_value = {
        "telegram_alert_chat_id": "123",
        "telegram_admin_chat_ids": ["123"],
        "telegram_admin_user_ids": ["456"],
        "telegram_allow_unconfigured_admins": False,
    }
    cfg = MagicMock()
    values = {
        "TELEGRAM_ALERT_CHAT_ID": "123",
        "ARMORER_TELEGRAM_ADMIN_CHAT_IDS": "123",
        "ARMORER_TELEGRAM_ADMIN_USER_IDS": "456",
        "ARMORER_TELEGRAM_ALLOW_UNCONFIGURED_ADMINS": "false",
        "ARMORER_CONFIRM_TTL_S": "60",
    }
    cfg.get_key.side_effect = lambda key: values.get(key, "")
    cfg.find_vault_key.return_value = "999:token"
    mock_config_cls.return_value = cfg
    mock_run.side_effect = [
        SimpleNamespace(returncode=0, stdout="1.41\n", stderr=""),
        SimpleNamespace(returncode=0, stdout=b"", stderr=b""),
    ]

    with patch("armorer.main.Path.cwd", return_value=tmp_path):
        _ensure_armorer_core()

    mock_ensure_image.assert_called_once()
    assert mock_ensure_image.call_args.kwargs == {"force_rebuild": False}
    docker_commands = [call.args[0] for call in mock_run.call_args_list]
    assert ["docker", "version", "--format", "{{.Server.APIVersion}}"] in docker_commands
    assert any(cmd[:3] == ["docker", "run", "-d"] for cmd in docker_commands)
    assert all(cmd[:3] != ["docker", "build", "-t"] for cmd in docker_commands)
    run_cmd = next(cmd for cmd in docker_commands if cmd[:3] == ["docker", "run", "-d"])
    assert f"{tmp_path / 'src'}:/app/src:ro" in run_cmd
    assert "-e" in run_cmd
    assert "TELEGRAM_BOT_TOKEN=999:token" in run_cmd
    assert "TELEGRAM_ALERT_CHAT_ID=123" in run_cmd
    assert "ARMORER_TELEGRAM_ADMIN_CHAT_IDS=123" in run_cmd


@patch("armorer.main.ConfigManager", create=True)
@patch("armorer.main._ensure_self_docker_image")
@patch("armorer.main._inspect_named_container")
@patch("armorer.main.os.path.exists", return_value=False)
@patch("armorer.main.subprocess.run")
def test_ensure_armorer_core_forces_rebuild_in_dev_mode(
    mock_run,
    mock_exists,
    mock_inspect,
    mock_ensure_image,
    mock_config_cls,
    monkeypatch,
    tmp_path,
):
    monkeypatch.setenv("ARMORER_DEV", "1")
    src_dir = tmp_path / "src" / "armorer"
    src_dir.mkdir(parents=True)
    (src_dir / "core.py").write_text("print('core')\n")
    mock_inspect.return_value = {"exists": False, "running": False, "status": ""}
    mock_ensure_image.return_value = True
    cfg = MagicMock()
    cfg.get_key.side_effect = lambda key: ""
    cfg.find_vault_key.return_value = ""
    mock_config_cls.return_value = cfg
    mock_run.side_effect = [
        SimpleNamespace(returncode=0, stdout="1.41\n", stderr=""),
        SimpleNamespace(returncode=0, stdout=b"", stderr=b""),
    ]

    with patch("armorer.main.Path.cwd", return_value=tmp_path):
        _ensure_armorer_core()

    mock_ensure_image.assert_called_once()
    assert mock_ensure_image.call_args.kwargs == {"force_rebuild": True}


def test_maybe_onboard_proactive_security_enables_and_runs_telegram_setup(monkeypatch):
    state = {}
    prompts = []
    cfg = MagicMock()
    cfg.get_key.side_effect = lambda key: state.get(key, "")
    cfg.set_key.side_effect = lambda key, value: state.__setitem__(key, value)
    cfg.find_vault_key.side_effect = lambda key: state.get(key, "") if key == "TELEGRAM_BOT_TOKEN" else None
    guard = MagicMock()
    guard.get_proactive_security_settings.side_effect = lambda: {
        "proactive_security_enabled": bool(state.get("proactive_security_enabled", False)),
        "proactive_security_prompted": bool(state.get("proactive_security_prompted", False)),
        "telegram_alert_chat_id": str(state.get("telegram_alert_chat_id", "") or "").strip(),
        "telegram_admin_chat_ids": list(state.get("telegram_admin_chat_ids", []) or []),
    }
    def save_proactive(updates):
        state.update(updates)
        return guard.get_proactive_security_settings()
    guard.save_proactive_security_settings.side_effect = save_proactive
    monkeypatch.setattr("armorer.main._user_guard", lambda *_args, **_kwargs: guard)

    monkeypatch.setattr("armorer.main.sys.stdin", SimpleNamespace(isatty=lambda: True))

    def fake_confirm(message, default=True):
        prompts.append(message)
        return True

    monkeypatch.setattr("armorer.main._ui_confirm", fake_confirm)

    def fake_configure(_cfg):
        state["TELEGRAM_BOT_TOKEN"] = "123:abc"
        state["telegram_alert_chat_id"] = "777"

    monkeypatch.setattr("armorer.main._configure_armorer_telegram", fake_configure)

    _maybe_onboard_proactive_security(cfg, "install openclaw")

    assert state["proactive_security_prompted"] is True
    assert state["proactive_security_enabled"] is True
    assert state["TELEGRAM_BOT_TOKEN"] == "123:abc"
    assert any("openclaw" in msg and "Telegram alerts" in msg for msg in prompts)


def test_proactive_security_subject_prefers_explicit_target():
    assert _proactive_security_subject("install something", target_agent="openclaw-prod") == "openclaw-prod"


def test_proactive_security_subject_falls_back_to_trigger():
    assert _proactive_security_subject("run openclaw") == "openclaw"


def test_maybe_onboard_proactive_security_records_decline(monkeypatch):
    state = {}
    cfg = MagicMock()
    cfg.get_key.side_effect = lambda key: state.get(key, "")
    cfg.set_key.side_effect = lambda key, value: state.__setitem__(key, value)
    cfg.find_vault_key.side_effect = lambda key: state.get(key, "") if key == "TELEGRAM_BOT_TOKEN" else None
    guard = MagicMock()
    guard.get_proactive_security_settings.side_effect = lambda: {
        "proactive_security_enabled": bool(state.get("proactive_security_enabled", False)),
        "proactive_security_prompted": bool(state.get("proactive_security_prompted", False)),
        "telegram_alert_chat_id": str(state.get("telegram_alert_chat_id", "") or "").strip(),
        "telegram_admin_chat_ids": list(state.get("telegram_admin_chat_ids", []) or []),
    }
    def save_proactive(updates):
        state.update(updates)
        return guard.get_proactive_security_settings()
    guard.save_proactive_security_settings.side_effect = save_proactive
    monkeypatch.setattr("armorer.main._user_guard", lambda *_args, **_kwargs: guard)

    monkeypatch.setattr("armorer.main.sys.stdin", SimpleNamespace(isatty=lambda: True))
    monkeypatch.setattr("armorer.main._ui_confirm", lambda *args, **kwargs: False)
    monkeypatch.setattr("armorer.main._configure_armorer_telegram", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not run")))

    _maybe_onboard_proactive_security(cfg, "run openclaw")

    assert state["proactive_security_prompted"] is True
    assert state["proactive_security_enabled"] is False


def test_maybe_onboard_proactive_security_repairs_missing_telegram_when_enabled(monkeypatch):
    state = {
        "proactive_security_prompted": True,
        "proactive_security_enabled": True,
    }
    cfg = MagicMock()
    cfg.get_key.side_effect = lambda key: state.get(key, "")
    cfg.set_key.side_effect = lambda key, value: state.__setitem__(key, value)
    cfg.find_vault_key.side_effect = lambda key: state.get(key, "") if key == "TELEGRAM_BOT_TOKEN" else None
    guard = MagicMock()
    guard.get_proactive_security_settings.side_effect = lambda: {
        "proactive_security_enabled": bool(state.get("proactive_security_enabled", False)),
        "proactive_security_prompted": bool(state.get("proactive_security_prompted", False)),
        "telegram_alert_chat_id": str(state.get("telegram_alert_chat_id", "") or "").strip(),
        "telegram_admin_chat_ids": list(state.get("telegram_admin_chat_ids", []) or []),
    }
    def save_proactive(updates):
        state.update(updates)
        return guard.get_proactive_security_settings()
    guard.save_proactive_security_settings.side_effect = save_proactive
    monkeypatch.setattr("armorer.main._user_guard", lambda *_args, **_kwargs: guard)

    monkeypatch.setattr("armorer.main.sys.stdin", SimpleNamespace(isatty=lambda: True))

    invoked = {"count": 0}

    def fake_configure(_cfg):
        invoked["count"] += 1
        state["TELEGRAM_BOT_TOKEN"] = "123:abc"
        state["telegram_alert_chat_id"] = "777"

    monkeypatch.setattr("armorer.main._configure_armorer_telegram", fake_configure)

    _maybe_onboard_proactive_security(cfg, "run openclaw")

    assert invoked["count"] == 1
    assert state["telegram_alert_chat_id"] == "777"


def test_load_recent_telegram_targets_cache_filters_old_entries(tmp_path, monkeypatch):
    monkeypatch.setattr("armorer.main.Path.home", lambda: tmp_path)
    cache = tmp_path / ".armorer" / "telegram_recent_targets.json"
    cache.parent.mkdir(parents=True, exist_ok=True)
    cache.write_text(
        '[{"chat_id":"111","chat_type":"private","title":"old","user_id":"1","updated_at":10},'
        '{"chat_id":"222","chat_type":"private","title":"new","user_id":"2","updated_at":20}]'
    )

    targets = _load_recent_telegram_targets_cache(min_updated_at=15.0)

    assert targets == [
        {"chat_id": "222", "chat_type": "private", "title": "new", "user_id": "2"}
    ]


def test_provider_model_suggestions_include_current_openai_and_gemini_ids():
    assert "gpt-5.4" in _provider_model_suggestions("openai")
    assert "gpt-5-mini" in _provider_model_suggestions("openai")
    assert "gemini-3.1-pro-preview" in _provider_model_suggestions("gemini")
    assert "gemini-3.1-flash-lite-preview" in _provider_model_suggestions("gemini")
    assert "gemini-3-flash-preview" in _provider_model_suggestions("gemini")


def test_select_model_id_for_provider_uses_curated_suggestion():
    model = _select_model_id_for_provider(
        "openai",
        "",
        select_fn=lambda _message, choices, default=None: default or choices[0],
        text_fn=lambda _message, default=None: default or "",
    )

    assert model == "gpt-5.4"


def test_select_model_id_for_provider_allows_custom_model():
    model = _select_model_id_for_provider(
        "anthropic",
        "",
        select_fn=lambda _message, choices, default=None: "Enter custom model ID",
        text_fn=lambda _message, default=None: "claude-custom-lab",
    )

    assert model == "claude-custom-lab"


def test_select_model_id_for_provider_keeps_ollama_manual():
    model = _select_model_id_for_provider(
        "ollama",
        "qwen2.5-coder:14b",
        select_fn=lambda _message, choices, default=None: (_ for _ in ()).throw(AssertionError("should not select curated choices")),
        text_fn=lambda _message, default=None: "llama3.3:70b",
    )

    assert model == "llama3.3:70b"


def test_wait_for_telegram_targets_from_live_bridge_prefers_fresh_targets(monkeypatch):
    cache_responses = [
        [],
        [{"chat_id": "222", "chat_type": "private", "title": "new", "user_id": "2"}],
    ]
    monkeypatch.setattr(
        "armorer.main._load_recent_telegram_targets_cache",
        lambda *args, **kwargs: cache_responses.pop(0) if cache_responses else [],
    )
    monkeypatch.setattr("armorer.main.time.sleep", lambda *_args, **_kwargs: None)

    current_time = {"value": 100.0}

    def fake_time():
        value = current_time["value"]
        current_time["value"] += 1.0
        return value

    monkeypatch.setattr("armorer.main.time.time", fake_time)

    targets, saw_fresh = _wait_for_telegram_targets_from_live_bridge(started_at=100.0, timeout_seconds=15.0)

    assert saw_fresh is True
    assert targets == [{"chat_id": "222", "chat_type": "private", "title": "new", "user_id": "2"}]


def test_wait_for_telegram_targets_from_live_bridge_falls_back_to_cached_targets(monkeypatch):
    def fake_load_recent_targets_cache(min_updated_at=None):
        if min_updated_at is not None:
            return []
        return [{"chat_id": "333", "chat_type": "private", "title": "cached", "user_id": "3"}]

    monkeypatch.setattr("armorer.main._load_recent_telegram_targets_cache", fake_load_recent_targets_cache)
    monkeypatch.setattr("armorer.main.time.sleep", lambda *_args, **_kwargs: None)

    current_time = {"value": 100.0}

    def fake_time():
        value = current_time["value"]
        current_time["value"] += 10.0
        return value

    monkeypatch.setattr("armorer.main.time.time", fake_time)

    targets, saw_fresh = _wait_for_telegram_targets_from_live_bridge(started_at=100.0, timeout_seconds=15.0)

    assert saw_fresh is False
    assert targets == [{"chat_id": "333", "chat_type": "private", "title": "cached", "user_id": "3"}]


def test_recommended_admin_chat_ids_prefers_selected_alert_chat_and_filters_stale_ids():
    discovered_targets = [
        {"chat_id": "900100200", "chat_type": "private", "title": "private", "user_id": "900100200"},
    ]

    defaults = _recommended_admin_chat_ids(
        alert_chat="900100200",
        current_admin_chat_ids=["123", "900100200"],
        discovered_targets=discovered_targets,
    )

    assert defaults == ["900100200"]


def test_recommended_admin_user_ids_prefers_discovered_user_for_selected_chat():
    discovered_targets = [
        {"chat_id": "900100200", "chat_type": "private", "title": "private", "user_id": "900100200"},
        {"chat_id": "999", "chat_type": "private", "title": "other", "user_id": "999"},
    ]

    defaults = _recommended_admin_user_ids(
        current_admin_user_ids=["456"],
        discovered_targets=discovered_targets,
        selected_admin_chat_ids=["900100200"],
    )

    assert defaults == ["900100200"]


def test_has_advanced_telegram_admin_settings_detects_only_real_advanced_values():
    assert _has_advanced_telegram_admin_settings(
        current_admin_chat_ids=["900100200"],
        current_admin_user_ids=[],
        recommended_admin_chat_ids=["900100200"],
    ) is False

    assert _has_advanced_telegram_admin_settings(
        current_admin_chat_ids=["123", "900100200"],
        current_admin_user_ids=[],
        recommended_admin_chat_ids=["900100200"],
    ) is True

    assert _has_advanced_telegram_admin_settings(
        current_admin_chat_ids=["900100200"],
        current_admin_user_ids=["42"],
        recommended_admin_chat_ids=["900100200"],
    ) is True


def test_configure_armorer_telegram_discovers_recent_chats_without_prompt(monkeypatch):
    cfg = MagicMock()
    cfg.find_vault_key.return_value = ""
    saved_vault = {}
    cfg.save_to_vault.side_effect = lambda key, value, **_kwargs: saved_vault.__setitem__(key, value)

    guard = MagicMock()
    saved_settings = {}
    guard.get_proactive_security_settings.return_value = {
        "telegram_alert_chat_id": "",
        "telegram_admin_chat_ids": [],
        "telegram_admin_user_ids": [],
        "telegram_allow_unconfigured_admins": False,
    }
    guard.save_proactive_security_settings.side_effect = lambda updates: saved_settings.update(updates)
    monkeypatch.setattr("armorer.main._user_guard", lambda *_args, **_kwargs: guard)

    confirms = []

    def fake_confirm(message, default=True):
        confirms.append(message)
        if "advanced Telegram access controls" in message:
            return False
        return True

    monkeypatch.setattr("armorer.main._ui_confirm", fake_confirm)
    monkeypatch.setattr("armorer.main._print_telegram_bot_setup_steps", lambda: None)
    monkeypatch.setattr("armorer.main._inspect_named_container", lambda *_args, **_kwargs: {"running": True})

    cache_responses = [
        [],
        [{"chat_id": "900100200", "chat_type": "private", "title": "private", "user_id": "900100200"}],
    ]
    monkeypatch.setattr(
        "armorer.main._load_recent_telegram_targets_cache",
        lambda *args, **kwargs: cache_responses.pop(0) if cache_responses else [],
    )
    monkeypatch.setattr("armorer.main.time.sleep", lambda *_args, **_kwargs: None)

    selections = []

    def fake_select(message, choices):
        selections.append((message, choices))
        return choices[0]

    monkeypatch.setattr("armorer.main._ui_select", fake_select)
    monkeypatch.setattr(
        "armorer.main._ui_text",
        lambda message, default=None: "123:abc" if "TELEGRAM_BOT_TOKEN" in message else default or "",
    )

    gateway = MagicMock()
    gateway.get_me.return_value = {"username": "testbot"}
    gateway.send_message.return_value = None
    monkeypatch.setattr("armorer.main.TelegramGateway", lambda bot_token: gateway)

    _configure_armorer_telegram(cfg)

    assert all("Discover recent Telegram chats now?" not in message for message in confirms)
    assert selections
    assert saved_settings["telegram_alert_chat_id"] == "900100200"
    assert saved_settings["telegram_admin_chat_ids"] == ["900100200"]
    assert saved_settings["telegram_admin_user_ids"] == ["900100200"]

def test_configure_armorer_telegram_uses_detected_user_id_for_private_chat(monkeypatch):
    cfg = MagicMock()
    cfg.find_vault_key.return_value = ""
    cfg.save_to_vault.side_effect = lambda *_args, **_kwargs: None

    guard = MagicMock()
    saved_settings = {}
    guard.get_proactive_security_settings.return_value = {
        "telegram_alert_chat_id": "",
        "telegram_admin_chat_ids": [],
        "telegram_admin_user_ids": [],
        "telegram_allow_unconfigured_admins": False,
    }
    guard.save_proactive_security_settings.side_effect = lambda updates: saved_settings.update(updates)
    monkeypatch.setattr("armorer.main._user_guard", lambda *_args, **_kwargs: guard)
    monkeypatch.setattr("armorer.main._print_telegram_bot_setup_steps", lambda: None)
    monkeypatch.setattr("armorer.main._inspect_named_container", lambda *_args, **_kwargs: {"running": True})
    monkeypatch.setattr("armorer.main.time.sleep", lambda *_args, **_kwargs: None)
    monkeypatch.setattr(
        "armorer.main._load_recent_telegram_targets_cache",
        lambda *args, **kwargs: [{"chat_id": "900100200", "chat_type": "private", "title": "private", "user_id": "123456789"}],
    )
    monkeypatch.setattr(
        "armorer.main._ui_confirm",
        lambda message, default=True: False if "advanced Telegram access controls" in message else True,
    )
    monkeypatch.setattr("armorer.main._ui_select", lambda message, choices: choices[0])
    monkeypatch.setattr(
        "armorer.main._ui_text",
        lambda message, default=None: "123:abc" if "TELEGRAM_BOT_TOKEN" in message else default or "",
    )

    gateway = MagicMock()
    gateway.get_me.return_value = {"username": "testbot"}
    gateway.send_message.return_value = None
    monkeypatch.setattr("armorer.main.TelegramGateway", lambda bot_token: gateway)

    _configure_armorer_telegram(cfg)

    assert saved_settings["telegram_alert_chat_id"] == "900100200"
    assert saved_settings["telegram_admin_chat_ids"] == ["900100200"]
    assert saved_settings["telegram_admin_user_ids"] == ["123456789"]


def test_configure_armorer_telegram_uses_cached_chats_when_no_fresh_message_arrives(monkeypatch):
    cfg = MagicMock()
    cfg.find_vault_key.return_value = ""
    saved_vault = {}
    cfg.save_to_vault.side_effect = lambda key, value, **_kwargs: saved_vault.__setitem__(key, value)

    guard = MagicMock()
    saved_settings = {}
    guard.get_proactive_security_settings.return_value = {
        "telegram_alert_chat_id": "",
        "telegram_admin_chat_ids": [],
        "telegram_admin_user_ids": [],
        "telegram_allow_unconfigured_admins": False,
    }
    guard.save_proactive_security_settings.side_effect = lambda updates: saved_settings.update(updates)
    monkeypatch.setattr("armorer.main._user_guard", lambda *_args, **_kwargs: guard)

    confirms = []

    def fake_confirm(message, default=True):
        confirms.append(message)
        if "advanced Telegram access controls" in message:
            return False
        return True

    monkeypatch.setattr("armorer.main._ui_confirm", fake_confirm)
    monkeypatch.setattr("armorer.main._print_telegram_bot_setup_steps", lambda: None)
    monkeypatch.setattr("armorer.main._inspect_named_container", lambda *_args, **_kwargs: {"running": True})
    monkeypatch.setattr(
        "armorer.main._wait_for_telegram_targets_from_live_bridge",
        lambda *args, **kwargs: (
            [{"chat_id": "900100200", "chat_type": "private", "title": "cached", "user_id": "900100200"}],
            False,
        ),
    )

    selections = []

    def fake_select(message, choices):
        selections.append((message, choices))
        return choices[0]

    monkeypatch.setattr("armorer.main._ui_select", fake_select)
    monkeypatch.setattr(
        "armorer.main._ui_text",
        lambda message, default=None: "123:abc" if "TELEGRAM_BOT_TOKEN" in message else default or "",
    )

    gateway = MagicMock()
    gateway.get_me.return_value = {"username": "testbot"}
    gateway.send_message.return_value = None
    monkeypatch.setattr("armorer.main.TelegramGateway", lambda bot_token: gateway)

    _configure_armorer_telegram(cfg)

    assert all("Discover recent Telegram chats now?" not in message for message in confirms)
    assert selections
    assert saved_settings["telegram_alert_chat_id"] == "900100200"
    assert saved_settings["telegram_admin_chat_ids"] == ["900100200"]
    assert saved_settings["telegram_admin_user_ids"] == ["900100200"]


def test_configure_armorer_telegram_explains_matching_private_chat_and_user_ids(monkeypatch):
    cfg = MagicMock()
    cfg.find_vault_key.return_value = ""
    cfg.save_to_vault.side_effect = lambda *_args, **_kwargs: None

    guard = MagicMock()
    saved_settings = {}
    guard.get_proactive_security_settings.return_value = {
        "telegram_alert_chat_id": "",
        "telegram_admin_chat_ids": [],
        "telegram_admin_user_ids": [],
        "telegram_allow_unconfigured_admins": False,
    }
    guard.save_proactive_security_settings.side_effect = lambda updates: saved_settings.update(updates)
    monkeypatch.setattr("armorer.main._user_guard", lambda *_args, **_kwargs: guard)
    monkeypatch.setattr("armorer.main._print_telegram_bot_setup_steps", lambda: None)
    monkeypatch.setattr("armorer.main._inspect_named_container", lambda *_args, **_kwargs: {"running": True})
    monkeypatch.setattr(
        "armorer.main._wait_for_telegram_targets_from_live_bridge",
        lambda *args, **kwargs: (
            [{"chat_id": "6775341584", "chat_type": "private", "title": "private chat", "user_id": "6775341584"}],
            True,
        ),
    )
    monkeypatch.setattr(
        "armorer.main._ui_confirm",
        lambda message, default=True: False if "advanced Telegram access controls" in message else True,
    )
    monkeypatch.setattr("armorer.main._ui_select", lambda message, choices: choices[0])
    monkeypatch.setattr(
        "armorer.main._ui_text",
        lambda message, default=None: "123:abc" if "TELEGRAM_BOT_TOKEN" in message else default or "",
    )

    gateway = MagicMock()
    gateway.get_me.return_value = {"username": "testbot"}
    gateway.send_message.return_value = None
    monkeypatch.setattr("armorer.main.TelegramGateway", lambda bot_token: gateway)

    printed = []
    monkeypatch.setattr("armorer.main.console.print", lambda *args, **kwargs: printed.append(" ".join(str(arg) for arg in args)))

    _configure_armorer_telegram(cfg)

    assert any("detected user ID matches the chat ID" in line for line in printed)
    assert saved_settings["telegram_admin_user_ids"] == ["6775341584"]


@patch("armorer.main._chat_command_impl")
def test_slash_chat_alias_invokes_chat(mock_chat_impl):
    result = runner.invoke(app, ["/chat"])

    assert result.exit_code == 0
    mock_chat_impl.assert_called_once()


def _make_app_meta(install_path: str):
    return SimpleNamespace(
        name="openclaw",
        install_path=install_path,
        agent_id="openclaw",
        run_commands=[
            {
                "label": "OpenClaw connection",
                "service": "openclaw-cli",
                "command": "gateway",
                "category": "run",
                "description": "Start the OpenClaw gateway used to communicate through channels like Telegram, Discord, and Slack.",
            }
        ],
        run_preference=None,
        installed_at=0,
    )


def test_resolve_install_path_keeps_host_path_when_mounted_in_docker(tmp_path, monkeypatch):
    project_root = tmp_path / "project"
    install_dir = project_root / "installed" / "openclaw"
    install_dir.mkdir(parents=True)

    monkeypatch.setenv("HOST_PROJECT_ROOT", str(project_root))
    monkeypatch.setattr("armorer.main.os.path.exists", lambda p: p == "/.dockerenv")

    resolved = _resolve_install_path(str(install_dir))

    assert resolved == install_dir


def test_build_compose_service_command_skips_deps_for_setup_commands(tmp_path):
    services_data = {
        "openclaw-gateway": {
            "image": "openclaw:local",
        },
        "openclaw-cli": {
            "image": "openclaw:local",
            "entrypoint": ["node", "dist/index.js"],
            "depends_on": ["openclaw-gateway"],
            "network_mode": "service:openclaw-gateway",
        }
    }

    configure_cmd = build_compose_service_command(
        tmp_path,
        services_data,
        "openclaw-cli",
        ["configure"],
    )
    gateway_cmd = build_compose_service_command(
        tmp_path,
        services_data,
        "openclaw-cli",
        ["gateway"],
    )

    assert configure_cmd["cmd"] == [
        "docker",
        "compose",
        "run",
        "--rm",
        "--no-deps",
        "--entrypoint",
        "node dist/index.js",
        "openclaw-gateway",
        "configure",
    ]
    assert gateway_cmd["cmd"] == [
        "docker",
        "compose",
        "run",
        "--rm",
        "--service-ports",
        "openclaw-cli",
        "gateway",
    ]


@patch("armorer.main._prefill_from_vault_checklist")
@patch("armorer.main._build_runtime_env", return_value={})
@patch("armorer.main._ensure_runtime_local_images")
@patch("armorer.main._ensure_runtime_path_env_defaults")
@patch("armorer.main.verify_openclaw_channels")
@patch("armorer.main.bind_openclaw_channels")
@patch("armorer.main._check_notifications")
@patch("armorer.main.load_dotenv")
@patch("armorer.main.ConfigManager", create=True)
@patch("armorer.main.AppRegistry", create=True)
@patch("armorer.main.subprocess.run")
def test_run_gateway_starts_service_when_not_running(
    mock_run,
    mock_registry_cls,
    mock_config,
    mock_load_dotenv,
    mock_check_notifications,
    mock_bind_channels,
    mock_verify_channels,
    mock_runtime_defaults,
    mock_runtime_images,
    mock_build_env,
    mock_prefill,
    tmp_path,
):
    compose_path = tmp_path / "docker-compose.yml"
    compose_path.write_text(
        """
services:
  openclaw-gateway:
    image: openclaw:local
  openclaw-cli:
    image: openclaw:local
    entrypoint:
      - node
      - dist/index.js
    tty: true
"""
    )
    app_meta = _make_app_meta(str(tmp_path))
    app_meta.channels_enabled = ["telegram"]
    registry = MagicMock()
    registry.get_app.return_value = app_meta
    mock_registry_cls.return_value = registry
    mock_run.side_effect = [
        MagicMock(returncode=0, stdout="", stderr=""),
        MagicMock(returncode=0, stdout="", stderr=""),
    ]
    cfg = MagicMock()
    cfg.find_vault_key.return_value = "123:abc"
    cfg.get_key.return_value = "123:abc"
    mock_config.return_value = cfg

    result = runner.invoke(app, ["run", "openclaw", "gateway"])

    assert result.exit_code == 0
    assert "Bypassing interactive menu" in result.stdout
    assert mock_run.call_count == 2
    args, kwargs = mock_run.call_args
    assert args[0] == ["docker", "compose", "up", "-d", "openclaw-gateway"]
    assert kwargs["cwd"] == tmp_path
    mock_bind_channels.assert_not_called()
    mock_verify_channels.assert_not_called()


@patch("armorer.main._ui_select")
@patch("armorer.main._prefill_from_vault_checklist")
@patch("armorer.main._build_runtime_env", return_value={})
@patch("armorer.main._ensure_runtime_local_images")
@patch("armorer.main._ensure_runtime_path_env_defaults")
@patch("armorer.main._is_repo_behind_upstream")
@patch("armorer.main._ensure_armorer_core")
@patch("armorer.main._check_notifications")
@patch("armorer.main.load_dotenv")
@patch("armorer.main.ConfigManager", create=True)
@patch("armorer.main.AppRegistry", create=True)
@patch("armorer.main.subprocess.run")
def test_run_direct_custom_command_bypasses_menu_and_autoselects_cli(
    mock_run,
    mock_registry_cls,
    mock_config,
    mock_load_dotenv,
    mock_check_notifications,
    mock_ensure_core,
    mock_repo_behind,
    mock_runtime_defaults,
    mock_runtime_images,
    mock_build_env,
    mock_prefill,
    mock_ui_select,
    tmp_path,
):
    compose_path = tmp_path / "docker-compose.yml"
    compose_path.write_text(
        """
services:
  openclaw-gateway:
    image: openclaw:local
    ports:
      - "18789:18789"
  openclaw-cli:
    image: openclaw:local
    network_mode: service:openclaw-gateway
    entrypoint:
      - node
      - dist/index.js
    tty: true
"""
    )
    app_meta = _make_app_meta(str(tmp_path))
    registry = MagicMock()
    registry.get_app.return_value = app_meta
    mock_registry_cls.return_value = registry
    mock_run.side_effect = [
        MagicMock(returncode=0, stdout="gateway-container\n", stderr=""),
        MagicMock(returncode=0, stdout="", stderr=""),
    ]

    result = runner.invoke(app, ["run", "openclaw", "--", "doctor"])

    assert result.exit_code == 0
    assert "Bypassing interactive menu" in result.stdout
    args, kwargs = mock_run.call_args
    assert args[0] == ["docker", "compose", "exec", "-T", "openclaw-gateway", "node", "dist/index.js", "doctor"]
    assert kwargs["cwd"] == tmp_path
    mock_ui_select.assert_not_called()


@patch("armorer.main._ui_confirm", return_value=False)
@patch("armorer.main._ui_select")
@patch("armorer.main._prefill_from_vault_checklist")
@patch("armorer.main._build_runtime_env", return_value={})
@patch("armorer.main._ensure_runtime_local_images")
@patch("armorer.main._ensure_runtime_path_env_defaults")
@patch("armorer.main._is_repo_behind_upstream")
@patch("armorer.main._ensure_armorer_core")
@patch("armorer.main._check_notifications")
@patch("armorer.main.load_dotenv")
@patch("armorer.main.ConfigManager", create=True)
@patch("armorer.main.AppRegistry", create=True)
@patch("armorer.main.subprocess.run")
def test_run_menu_shortcut_uses_loaded_services_data(
    mock_run,
    mock_registry_cls,
    mock_config,
    mock_load_dotenv,
    mock_check_notifications,
    mock_ensure_core,
    mock_repo_behind,
    mock_runtime_defaults,
    mock_runtime_images,
    mock_build_env,
    mock_prefill,
    mock_ui_select,
    mock_ui_confirm,
    tmp_path,
):
    compose_path = tmp_path / "docker-compose.yml"
    compose_path.write_text(
        """
services:
  openclaw-cli:
    image: openclaw:local
    entrypoint:
      - node
      - dist/index.js
    tty: true
"""
    )
    app_meta = SimpleNamespace(
        name="openclaw",
        install_path=str(tmp_path),
        agent_id="openclaw",
        run_commands=[
            {
                "label": "Config",
                "service": "openclaw-cli",
                "command": "configure",
                "category": "setup",
                "description": "Configure OpenClaw inside its own CLI.",
            }
        ],
        run_preference=None,
        installed_at=0,
    )
    registry = MagicMock()
    registry.get_app.return_value = app_meta
    mock_registry_cls.return_value = registry
    mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
    mock_ui_select.return_value = "run::OpenClaw Config"

    result = runner.invoke(app, ["run", "openclaw"])

    assert result.exit_code == 0
    args, kwargs = mock_run.call_args
    assert args[0] == [
        "docker",
        "compose",
        "run",
        "--rm",
        "--no-deps",
        "--service-ports",
        "openclaw-cli",
        "configure",
    ]
    assert kwargs["cwd"] == tmp_path


@patch("armorer.main._ui_select")
@patch("armorer.main._ui_confirm")
@patch("armorer.main._has_newer_openclaw_release")
@patch("armorer.main._recent_install_skip_update", return_value=False)
@patch("armorer.main.sys.stdin.isatty", return_value=True)
@patch("armorer.main.resolve_playbook")
@patch("armorer.main._ensure_runtime_local_images")
@patch("armorer.main._ensure_runtime_path_env_defaults")
@patch("armorer.main._check_notifications")
@patch("armorer.main.load_dotenv")
@patch("armorer.main.ConfigManager", create=True)
@patch("armorer.main.AppRegistry", create=True)
def test_run_refreshes_cached_playbook_commands(
    mock_registry_cls,
    mock_config,
    mock_load_dotenv,
    mock_check_notifications,
    mock_runtime_defaults,
    mock_runtime_images,
    mock_resolve_playbook,
    mock_stdin_tty,
    mock_recent_install_skip,
    mock_has_newer_release,
    mock_ui_confirm,
    mock_ui_select,
    tmp_path,
):
    (tmp_path / "docker-compose.yml").write_text(
        """
services:
  openclaw-cli:
    image: openclaw:local
    entrypoint:
      - node
      - dist/index.js
    tty: true
"""
    )
    app_meta = _make_app_meta(str(tmp_path))
    app_meta.run_commands = [
        {
            "label": "Gateway status",
            "service": "openclaw-cli",
            "command": "status",
            "category": "run",
            "description": "Check gateway status quickly.",
        }
    ]
    (tmp_path / ".git").mkdir()
    registry = MagicMock()
    registry.get_app.return_value = app_meta
    mock_registry_cls.return_value = registry
    mock_has_newer_release.return_value = False
    mock_resolve_playbook.return_value = SimpleNamespace(
        run_commands=[
            SimpleNamespace(
                label="Config",
                service="openclaw-cli",
                command="configure",
                category="setup",
                description="Configure OpenClaw inside its own CLI.",
            ),
            SimpleNamespace(
                label="Gateway",
                service="openclaw-cli",
                command="gateway",
                category="run",
                description="Start the OpenClaw gateway used to communicate through channels like Telegram, Discord, and Slack.",
            ),
            SimpleNamespace(
                label="TUI",
                service="openclaw-cli",
                command="tui",
                category="run",
                description="Open the text-based OpenClaw interface.",
            ),
            SimpleNamespace(
                label="Help",
                service="openclaw-cli",
                command="--help",
                category="help",
                description="Show OpenClaw CLI help and the available commands.",
            ),
        ]
    )
    mock_ui_select.return_value = None

    result = runner.invoke(app, ["run", "openclaw"])

    assert result.exit_code == 0
    mock_ui_confirm.assert_not_called()
    registry.add_app.assert_called_once()
    assert app_meta.run_commands == [
        {
            "label": "Armorer Config",
            "service": "armorer",
            "command": "armorer-configure",
            "category": "setup",
            "description": "Recommended: configure models, channels, skills, and secrets through Armorer.",
            "armorer_action": "app_configure",
        },
        {
            "label": "OpenClaw Config",
            "service": "openclaw-cli",
            "command": "configure",
            "category": "setup",
            "description": "OpenClaw's own in-container configuration flow.",
        },
        {
            "label": "Gateway",
            "service": "openclaw-cli",
            "command": "gateway",
            "category": "run",
            "description": "Start the OpenClaw gateway used to communicate through channels like Telegram, Discord, and Slack.",
        },
        {
            "label": "TUI",
            "service": "openclaw-cli",
            "command": "tui",
            "category": "run",
            "description": "Open the text-based OpenClaw interface.",
        },
        {
            "label": "Help",
            "service": "openclaw-cli",
            "command": "--help",
            "category": "help",
            "description": "Show OpenClaw CLI help and the available commands.",
        },
    ]


@patch("armorer.main._app_configure_impl")
@patch("armorer.main._ui_select")
@patch("armorer.main._ensure_runtime_local_images")
@patch("armorer.main._ensure_runtime_path_env_defaults")
@patch("armorer.main._check_notifications")
@patch("armorer.main.load_dotenv")
@patch("armorer.main.ConfigManager", create=True)
@patch("armorer.main.AppRegistry", create=True)
def test_run_menu_can_dispatch_armorer_managed_config(
    mock_registry_cls,
    mock_config,
    mock_load_dotenv,
    mock_check_notifications,
    mock_runtime_defaults,
    mock_runtime_images,
    mock_ui_select,
    mock_app_configure_impl,
    tmp_path,
):
    (tmp_path / "docker-compose.yml").write_text(
        """
services:
  openclaw-cli:
    image: openclaw:local
    entrypoint:
      - node
      - dist/index.js
    tty: true
"""
    )
    app_meta = _make_app_meta(str(tmp_path))
    app_meta.run_commands = []
    registry = MagicMock()
    registry.get_app.return_value = app_meta
    mock_registry_cls.return_value = registry
    mock_ui_select.return_value = "run::Armorer Config"

    result = runner.invoke(app, ["run", "openclaw"])

    assert result.exit_code == 0
    assert mock_ui_select.call_args.kwargs["default"] == "run::Armorer Config"
    mock_app_configure_impl.assert_called_once_with("openclaw")


@patch("armorer.main._prefill_from_vault_checklist")
@patch("armorer.main._build_runtime_env", return_value={})
@patch("armorer.main._ensure_runtime_local_images")
@patch("armorer.main._ensure_runtime_path_env_defaults")
@patch("armorer.main._check_notifications")
@patch("armorer.main.load_dotenv")
@patch("armorer.main.ConfigManager", create=True)
@patch("armorer.main.AppRegistry", create=True)
@patch("armorer.main.verify_openclaw_channels")
@patch("armorer.main.bind_openclaw_channels")
@patch("armorer.main.subprocess.run")
def test_gateway_shortcut_stops_gateway_when_running(
    mock_run,
    mock_bind_channels,
    mock_verify_channels,
    mock_registry_cls,
    mock_config,
    mock_load_dotenv,
    mock_check_notifications,
    mock_runtime_defaults,
    mock_runtime_images,
    mock_build_env,
    mock_prefill,
    tmp_path,
):
    (tmp_path / "docker-compose.yml").write_text(
        """
services:
  openclaw-gateway:
    image: openclaw:local
  openclaw-cli:
    image: openclaw:local
    network_mode: service:openclaw-gateway
    entrypoint:
      - node
      - dist/index.js
    tty: true
"""
    )
    app_meta = _make_app_meta(str(tmp_path))
    registry = MagicMock()
    registry.get_app.return_value = app_meta
    mock_registry_cls.return_value = registry
    mock_run.side_effect = [
        MagicMock(returncode=0, stdout="container-id\n", stderr=""),
        MagicMock(returncode=0, stdout="", stderr=""),
    ]

    result = runner.invoke(app, ["run", "openclaw", "gateway"])

    assert result.exit_code == 0
    args, kwargs = mock_run.call_args
    assert args[0] == ["docker", "compose", "stop", "openclaw-gateway"]
    assert kwargs["cwd"] == tmp_path
    mock_bind_channels.assert_not_called()
    mock_verify_channels.assert_not_called()


@patch("armorer.main._ui_select")
@patch("armorer.main._prefill_from_vault_checklist")
@patch("armorer.main._build_runtime_env", return_value={})
@patch("armorer.main._ensure_runtime_local_images")
@patch("armorer.main._ensure_runtime_path_env_defaults")
@patch("armorer.main._is_repo_behind_upstream")
@patch("armorer.main._ensure_armorer_core")
@patch("armorer.main._check_notifications")
@patch("armorer.main.load_dotenv")
@patch("armorer.main.ConfigManager", create=True)
@patch("armorer.main.AppRegistry", create=True)
@patch("armorer.main.subprocess.run")
def test_run_direct_status_falls_back_to_run_when_delegate_not_running(
    mock_run,
    mock_registry_cls,
    mock_config,
    mock_load_dotenv,
    mock_check_notifications,
    mock_ensure_core,
    mock_repo_behind,
    mock_runtime_defaults,
    mock_runtime_images,
    mock_build_env,
    mock_prefill,
    mock_ui_select,
    tmp_path,
):
    compose_path = tmp_path / "docker-compose.yml"
    compose_path.write_text(
        """
services:
  openclaw-gateway:
    image: openclaw:local
  openclaw-cli:
    image: openclaw:local
    network_mode: service:openclaw-gateway
    entrypoint:
      - node
      - dist/index.js
    tty: true
"""
    )
    app_meta = _make_app_meta(str(tmp_path))
    registry = MagicMock()
    registry.get_app.return_value = app_meta
    mock_registry_cls.return_value = registry
    mock_run.side_effect = [
        MagicMock(returncode=0, stdout="", stderr=""),
        MagicMock(returncode=0, stdout="", stderr=""),
    ]
    (tmp_path / ".git").mkdir()

    result = runner.invoke(app, ["run", "openclaw", "--", "status"])

    assert result.exit_code == 0
    args, kwargs = mock_run.call_args
    assert args[0] == ["docker", "compose", "run", "--rm", "--service-ports", "openclaw-cli", "status"]
    assert kwargs["cwd"] == tmp_path
    mock_ensure_core.assert_not_called()
    mock_repo_behind.assert_not_called()
    mock_runtime_images.assert_not_called()
    mock_prefill.assert_not_called()
    mock_ui_select.assert_not_called()


@patch("armorer.main.subprocess.run")
def test_resolve_openclaw_gateway_action_stops_running_gateway(
    mock_run,
    tmp_path,
):
    (tmp_path / "docker-compose.yml").write_text(
        """
services:
  openclaw-gateway:
    image: openclaw:local
  openclaw-cli:
    image: openclaw:local
    entrypoint:
      - node
      - dist/index.js
    tty: true
"""
    )
    app_meta = _make_app_meta(str(tmp_path))
    registry = MagicMock()
    mock_run.side_effect = [
        MagicMock(returncode=0, stdout="container-id\n", stderr=""),
        MagicMock(returncode=0, stdout="", stderr=""),
    ]
    ctx = MagicMock()

    result = _resolve_openclaw_gateway_action(
        ctx, registry, app_meta, "openclaw", tmp_path, "OpenClaw connection", ["gateway"]
    )

    assert result == {"handled": True}
    args, kwargs = mock_run.call_args
    assert args[0] == ["docker", "compose", "stop", "openclaw-gateway"]
    assert kwargs["cwd"] == tmp_path
