"""Tests for CLI config subcommands, run-command error handling, and doctor."""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, patch

import agno.models.vertexai.claude as vertexai_claude_module
import httpx
import pytest
import structlog
import typer
import yaml
from anthropic import PermissionDeniedError
from google.auth.exceptions import DefaultCredentialsError
from typer.testing import CliRunner

import mindroom.constants as constants_module
import mindroom.google_adc as google_adc_module
from mindroom.agents import ensure_default_agent_workspaces
from mindroom.cli import config as config_cli
from mindroom.cli import migrate as migrate_cli
from mindroom.cli.agent_docs import ensure_config_agent_docs
from mindroom.cli.config import _format_config_search_locations, activate_cli_runtime
from mindroom.cli.main import _load_active_config_or_exit, _threads_export, app
from mindroom.constants import OWNER_MATRIX_USER_ID_ENV, OWNER_MATRIX_USER_ID_PLACEHOLDER
from mindroom.error_handling import AvatarGenerationError, AvatarSyncError
from mindroom.handled_turns import HandledTurnLedger
from mindroom.matrix.state import MatrixAccount, MatrixState
from mindroom.model_defaults import (
    CONFIG_INIT_MODEL_PRESETS,
    LLAMA_CPP_GEMMA,
    LLAMA_CPP_QWEN,
    LOCAL_QWEN_CONTEXT_WINDOW,
    LOCAL_QWEN_PRESET_NAME,
    OLLAMA_GEMMA,
    OLLAMA_QWEN,
    OPENAI_GPT_LUNA,
    OPENAI_GPT_TERRA,
    llama_cpp_server_command,
)
from mindroom.startup_errors import PermanentStartupError
from mindroom.thread_export import ThreadExportStats
from mindroom.thread_export.models import ThreadExportRoom, failure_for_room, failure_for_target
from tests.conftest import load_config_yaml, normalize_console_output

if TYPE_CHECKING:
    from mindroom.config.main import Config

runner = CliRunner()


@pytest.fixture(autouse=True)
def _clear_runtime_path_env(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.delenv("MINDROOM_CONFIG_PATH", raising=False)
    monkeypatch.delenv("MINDROOM_STORAGE_PATH", raising=False)


def _runtime_path_env(config_path: Path, *, storage_path: Path | None = None) -> dict[str, str]:
    env = {"MINDROOM_CONFIG_PATH": str(config_path.resolve())}
    if storage_path is not None:
        env["MINDROOM_STORAGE_PATH"] = str(storage_path.resolve())
    return env


def _invoke_with_runtime(
    args: list[str],
    config_path: Path,
    *,
    storage_path: Path | None = None,
    env: dict[str, str] | None = None,
    **kwargs: object,
) -> object:
    command_env = _runtime_path_env(config_path, storage_path=storage_path)
    if env:
        command_env.update(env)
    return cast("object", runner.invoke(app, args, env=command_env, **kwargs))


def _write_minimal_runtime_config(path: Path) -> None:
    path.write_text(
        "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
        "agents:\n  general:\n    display_name: General Agent\n    model: default\n"
        "router:\n  model: default\n"
        "matrix_space:\n  enabled: false\n"
        "authorization:\n  global_users: []\n",
        encoding="utf-8",
    )


def test_cli_console_renders_without_ansi_escapes(capsys: pytest.CaptureFixture[str]) -> None:
    """The root conftest must keep the import-time CLI console plain in any shell."""
    config_cli.console.print("[bold red]MindRoom Doctor[/bold red]")

    captured = capsys.readouterr().out
    assert "\x1b" not in captured
    assert "MindRoom Doctor" in captured


def test_cli_import_keeps_help_path_runtime_modules_lazy() -> None:
    """Importing the CLI should not preload runtime config/history dependencies."""
    blocked_modules = [
        "mindroom.config.main",
        "mindroom.history",
        "mindroom.history.runtime",
        "mindroom.agent_storage",
        "agno",
        "sqlalchemy",
        "agno.db.sqlite.async_sqlite",
    ]
    script = (
        "import json\n"
        "import sys\n"
        "import mindroom.cli.main\n"
        f"blocked_modules = {blocked_modules!r}\n"
        "print(json.dumps([name for name in blocked_modules if name in sys.modules]))\n"
    )
    repo_root = Path(__file__).resolve().parents[1]
    python_path = str(repo_root / "src")
    env = {
        **os.environ,
        "PYTHONPATH": f"{python_path}{os.pathsep}{os.environ.get('PYTHONPATH', '')}",
    }

    result = subprocess.run(
        [sys.executable, "-c", script],
        cwd=repo_root,
        env=env,
        capture_output=True,
        text=True,
        check=True,
    )

    assert json.loads(result.stdout) == []


def test_format_config_search_locations_numbers_paths_and_statuses(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Config search-location rendering should include numbers and existence status."""
    monkeypatch.chdir(tmp_path)
    existing = tmp_path / "config.yaml"
    existing.write_text("agents: {}\n", encoding="utf-8")
    missing = tmp_path / "missing.yaml"

    lines = _format_config_search_locations({"MINDROOM_CONFIG_PATH": str(missing)})

    assert lines[0] == f"  1. {missing.resolve()} ([dim]not found[/dim])"
    assert lines[1] == f"  2. {existing.resolve()} ([green]exists[/green])"


def test_load_config_quiet_restores_unconfigured_structlog(tmp_path: Path) -> None:
    """Quiet CLI loads must exercise and restore the unconfigured structlog path."""
    config_path = tmp_path / "config.yaml"
    _write_minimal_runtime_config(config_path)
    runtime_paths = constants_module.resolve_primary_runtime_paths(config_path=config_path, process_env={})

    structlog.reset_defaults()
    assert structlog.is_configured() is False
    with patch.object(structlog, "configure", wraps=structlog.configure) as configure:
        loaded_config = config_cli.load_config_quiet(runtime_paths)

    configure.assert_called_once()
    assert loaded_config.agents
    assert structlog.is_configured() is False


def test_activate_cli_runtime_explicit_path_keeps_exported_storage_override(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Explicit CLI config paths should still honor exported MINDROOM_STORAGE_PATH."""
    config_path = tmp_path / "config.yaml"
    storage_path = tmp_path / "custom-storage"
    config_path.write_text(
        "models:\n  default:\n    provider: openai\n    id: gpt-5.4\nagents: {}\nrouter:\n  model: default\n",
        encoding="utf-8",
    )
    monkeypatch.setenv("MINDROOM_STORAGE_PATH", str(storage_path))

    runtime_paths = activate_cli_runtime(config_path)

    assert runtime_paths.storage_root == storage_path.resolve()


def test_ensure_config_agent_docs_copies_when_symlinks_unavailable(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Without symlink support, CLAUDE.md becomes a plain copy of AGENTS.md."""

    def _unsupported_symlink(*_args: object, **_kwargs: object) -> None:
        message = "symlinks unsupported"
        raise OSError(message)

    monkeypatch.setattr(Path, "symlink_to", _unsupported_symlink)
    created = ensure_config_agent_docs(
        tmp_path,
        config_path=tmp_path / "config.yaml",
        storage_root=tmp_path / "mindroom_data",
    )

    claude_doc = tmp_path / "CLAUDE.md"
    assert not claude_doc.is_symlink()
    assert claude_doc.read_text(encoding="utf-8") == (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
    assert created == [tmp_path / "AGENTS.md", claude_doc]


def test_ensure_config_agent_docs_copy_mirrors_preserved_agents_doc(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """The no-symlink fallback copies preserved user AGENTS.md content, not the template."""

    def _unsupported_symlink(*_args: object, **_kwargs: object) -> None:
        message = "symlinks unsupported"
        raise OSError(message)

    monkeypatch.setattr(Path, "symlink_to", _unsupported_symlink)
    agents_doc = tmp_path / "AGENTS.md"
    agents_doc.write_text("custom agents notes\n", encoding="utf-8")

    created = ensure_config_agent_docs(
        tmp_path,
        config_path=tmp_path / "config.yaml",
        storage_root=tmp_path / "mindroom_data",
    )

    claude_doc = tmp_path / "CLAUDE.md"
    assert agents_doc.read_text(encoding="utf-8") == "custom agents notes\n"
    assert claude_doc.read_text(encoding="utf-8") == "custom agents notes\n"
    assert created == [claude_doc]


def test_ensure_config_agent_docs_force_replaces_agents_symlink_without_clobbering_target(
    tmp_path: Path,
) -> None:
    """Force must replace a symlinked AGENTS.md instead of writing through it."""
    external_target = tmp_path / "external.md"
    external_target.write_text("external notes\n", encoding="utf-8")
    config_dir = tmp_path / "config"
    config_dir.mkdir()
    agents_doc = config_dir / "AGENTS.md"
    agents_doc.symlink_to(external_target)

    ensure_config_agent_docs(
        config_dir,
        config_path=config_dir / "config.yaml",
        storage_root=config_dir / "mindroom_data",
        force=True,
    )

    assert external_target.read_text(encoding="utf-8") == "external notes\n"
    assert not agents_doc.is_symlink()
    assert "# MindRoom Configuration" in agents_doc.read_text(encoding="utf-8")


# ---------------------------------------------------------------------------
# mindroom config init
# ---------------------------------------------------------------------------


class TestConfigInit:
    """Tests for `mindroom config init`."""

    def test_init_creates_config(self, tmp_path: Path) -> None:
        """Config init creates a valid config.yaml at the target path."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target)], input="openai\n")
        assert result.exit_code == 0
        assert target.exists()
        content = target.read_text()
        assert "agents:" in content
        assert "models:" in content
        assert "authorization:" in content
        assert "matrix_space:" in content
        assert "matrix_space:\n  enabled: true\n  name: MindRoom" in content
        assert OWNER_MATRIX_USER_ID_PLACEHOLDER in content
        config = yaml.safe_load(content)
        assert config["matrix_room_access"]["room_admins"] == [OWNER_MATRIX_USER_ID_PLACEHOLDER]

    def test_init_defaults_to_openai_for_mindroom_chat(self, tmp_path: Path) -> None:
        """mindroom.chat should default to OpenAI without prompting for a provider."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target)], input="\n")
        assert result.exit_code == 0

        config = yaml.safe_load(target.read_text())
        assert config["models"]["default"]["provider"] == "openai"
        assert config["models"]["default"]["id"] == CONFIG_INIT_MODEL_PRESETS["openai"].id

    def test_init_adds_only_mindroom_style_mind(self, tmp_path: Path) -> None:
        """Starter config should include only the Mind agent in its personal room."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "openai"])
        assert result.exit_code == 0
        config = yaml.safe_load(target.read_text())
        assert list(config["agents"]) == ["mind"]
        mind = config["agents"]["mind"]

        assert mind["display_name"] == "Mind"
        assert mind["include_default_tools"] is False
        assert mind["learning"] is False
        assert mind["memory_backend"] == "file"
        assert mind["rooms"] == ["personal"]
        assert mind["context_files"] == [
            "SOUL.md",
            "AGENTS.md",
            "USER.md",
            "IDENTITY.md",
            "TOOLS.md",
            "HEARTBEAT.md",
        ]
        assert "knowledge_bases" not in mind
        assert mind["tools"] == [
            "shell",
            "coding",
            "memory",
            {"name": "config_manager", "defer": True},
            "duckduckgo",
            "website",
            "browser",
            "scheduler",
            "update_awareness",
            "todo",
            "subagents",
            "matrix_message",
            "thread_tags",
            "thread_summary",
        ]
        config_manager_entry = next(
            entry for entry in load_config_yaml(target).agents["mind"].tools if entry.name == "config_manager"
        )
        assert config_manager_entry.defer is True
        assert "thread_resolution" not in mind["tools"]
        assert mind["skills"] == ["mindroom-docs"]
        assert (
            "When helping with MindRoom setup, follow AGENTS.md and check the live state — don't guess."
            in mind["instructions"]
        )
        assert (
            "Meet the user at their technical level. If they ask you to configure something, do it; skip YAML or shell "
            "details unless they ask." in mind["instructions"]
        )
        assert "knowledge_bases" not in config
        assert config["memory"]["backend"] == "file"
        assert config["memory"]["embedder"]["provider"] == "sentence_transformers"
        assert config["memory"]["embedder"]["config"]["model"] == "sentence-transformers/all-MiniLM-L6-v2"
        assert config["memory"]["file"]["max_entrypoint_lines"] == 200
        assert config["memory"]["search"] == {
            "mode": "semantic",
            "include": ["memory/**/*.md"],
            "include_entrypoint": False,
        }
        assert config["memory"]["auto_flush"]["enabled"] is True
        assert config["defaults"]["tools"] == ["scheduler", "update_awareness"]
        assert "openclaw_compat" not in target.read_text()

        env_content = (tmp_path / ".env").read_text()
        assert f"MINDROOM_STORAGE_PATH={(tmp_path / 'mindroom_data').resolve()}" in env_content

    def test_init_creates_mind_workspace_files(self, tmp_path: Path) -> None:
        """Starter config should scaffold the required canonical workspace files."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "openai"])
        assert result.exit_code == 0

        workspace = tmp_path / "mindroom_data" / "agents" / "mind" / "workspace"
        assert workspace.exists()
        assert (workspace / "memory").exists()
        assert (workspace / "SOUL.md").exists()
        assert (workspace / "AGENTS.md").exists()
        assert (workspace / "USER.md").exists()
        assert (workspace / "IDENTITY.md").exists()
        assert (workspace / "TOOLS.md").exists()
        assert (workspace / "HEARTBEAT.md").exists()
        assert (workspace / "MEMORY.md").exists()
        assert not (workspace / "BOOT.md").exists()
        tools_notes = (workspace / "TOOLS.md").read_text(encoding="utf-8")
        assert f"- Active config file: {json.dumps(str(target.resolve()))}" in tools_notes

    def test_init_creates_agent_rescue_docs(self, tmp_path: Path) -> None:
        """Config init seeds AGENTS.md plus a CLAUDE.md symlink for repair agents."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "openai"])
        assert result.exit_code == 0

        agents_doc = tmp_path / "AGENTS.md"
        claude_doc = tmp_path / "CLAUDE.md"
        assert agents_doc.is_file()
        assert claude_doc.is_symlink()
        assert claude_doc.readlink() == Path("AGENTS.md")

        content = agents_doc.read_text(encoding="utf-8")
        assert "https://docs.mindroom.chat/" in content
        assert (
            "https://raw.githubusercontent.com/mindroom-ai/mindroom/refs/heads/main/"
            "skills/mindroom-docs/references/llms.txt" in content
        )
        assert (
            "https://raw.githubusercontent.com/mindroom-ai/mindroom/refs/heads/main/"
            "skills/mindroom-docs/references/llms-full.txt" in content
        )
        assert "mindroom config validate" in content
        assert "mindroom service status" in content
        assert "mindroom service restart" in content
        assert "`config.yaml`" in content
        assert str((tmp_path / "mindroom_data").resolve()) in content
        assert claude_doc.read_text(encoding="utf-8") == content
        assert "Agent docs created" in normalize_console_output(result.output)

    def test_init_preserves_existing_agent_docs_without_force(self, tmp_path: Path) -> None:
        """Config init must not clobber user-authored AGENTS.md or CLAUDE.md."""
        target = tmp_path / "config.yaml"
        agents_doc = tmp_path / "AGENTS.md"
        claude_doc = tmp_path / "CLAUDE.md"
        agents_doc.write_text("custom agents notes\n", encoding="utf-8")
        claude_doc.write_text("custom claude notes\n", encoding="utf-8")

        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "openai"])
        assert result.exit_code == 0
        assert agents_doc.read_text(encoding="utf-8") == "custom agents notes\n"
        assert claude_doc.read_text(encoding="utf-8") == "custom claude notes\n"
        assert not claude_doc.is_symlink()

    def test_init_force_replaces_agent_docs(self, tmp_path: Path) -> None:
        """Config init --force regenerates the agent docs and restores the symlink."""
        target = tmp_path / "config.yaml"
        agents_doc = tmp_path / "AGENTS.md"
        claude_doc = tmp_path / "CLAUDE.md"
        agents_doc.write_text("stale\n", encoding="utf-8")
        claude_doc.write_text("stale\n", encoding="utf-8")

        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "openai", "--force"])
        assert result.exit_code == 0
        assert "# MindRoom Configuration" in agents_doc.read_text(encoding="utf-8")
        assert claude_doc.is_symlink()
        assert claude_doc.readlink() == Path("AGENTS.md")

    def test_init_rerun_keeps_agent_docs_symlink(self, tmp_path: Path) -> None:
        """A second config init leaves the seeded docs and symlink in place."""
        target = tmp_path / "config.yaml"
        first = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "openai"])
        assert first.exit_code == 0

        second = runner.invoke(app, ["config", "init", "--path", str(target), "--no-input"])
        assert second.exit_code == 0
        claude_doc = tmp_path / "CLAUDE.md"
        assert claude_doc.is_symlink()
        assert "Agent docs created" not in normalize_console_output(second.output)

    def test_init_respects_storage_path_override(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Starter config should scaffold the Mind workspace under MINDROOM_STORAGE_PATH when set."""
        target = tmp_path / "config.yaml"
        storage_root = tmp_path / "custom-storage"
        monkeypatch.setenv("MINDROOM_STORAGE_PATH", str(storage_root))

        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "openai"])
        assert result.exit_code == 0

        config = yaml.safe_load(target.read_text())
        workspace = storage_root / "agents" / "mind" / "workspace"
        assert workspace.exists()
        assert (workspace / "memory").exists()
        assert (workspace / "SOUL.md").exists()
        assert (workspace / "MEMORY.md").exists()
        agents_template = (workspace / "AGENTS.md").read_text(encoding="utf-8")
        assert "## 🧭 MindRoom Setup" in agents_template
        assert "discover and use `config_manager`" in agents_template
        assert "active config path recorded in `TOOLS.md`" in agents_template
        assert "The active Matrix homeserver is listed in the runtime context" in agents_template
        assert "MindRoom Chat at `https://chat.mindroom.chat`" in agents_template
        assert "supports custom homeservers" in agents_template
        assert "The MindRoom dashboard is a separate app" in agents_template
        assert "If `config_manager` returns a target-agent `connect_url`" in agents_template
        assert "Newly configured tools may not be available to the current agent or current run" in agents_template
        assert "`*_connection_status` or `*_list_tools`" in agents_template
        assert "otherwise, use the dashboard as the manual fallback" in agents_template
        assert "loopback URL (`localhost`, `127.0.0.1`, or `::1`)" in agents_template
        assert "`https://mindroom.chat`" not in agents_template
        assert "knowledge_bases" not in config

        env_content = (tmp_path / ".env").read_text()
        assert f"MINDROOM_STORAGE_PATH={storage_root.resolve()}" in env_content

    def test_init_runtime_storage_override_keeps_mind_workspace_in_sync(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """The starter Mind workspace should follow the active runtime storage root, not the init-time one."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "openai"])
        assert result.exit_code == 0

        runtime_storage = tmp_path / "alternate-storage"
        monkeypatch.setenv("MINDROOM_STORAGE_PATH", str(runtime_storage))

        config = load_config_yaml(target)
        assert "mind_memory" not in config.knowledge_bases

        ensure_default_agent_workspaces(config, runtime_storage)
        runtime_workspace = runtime_storage / "agents" / "mind" / "workspace"
        assert (runtime_workspace / "SOUL.md").exists()
        assert (runtime_workspace / "AGENTS.md").exists()
        assert (runtime_workspace / "USER.md").exists()
        assert (runtime_workspace / "IDENTITY.md").exists()
        assert (runtime_workspace / "TOOLS.md").exists()
        assert (runtime_workspace / "HEARTBEAT.md").exists()
        assert (runtime_workspace / "MEMORY.md").exists()
        template_memory = (
            Path(__file__).resolve().parents[1] / "src" / "mindroom" / "cli" / "templates" / "mind_data" / "MEMORY.md"
        )
        assert (runtime_workspace / "MEMORY.md").read_text(encoding="utf-8") == template_memory.read_text(
            encoding="utf-8",
        )

    def test_init_without_path_uses_detected_default_location(
        self,
        tmp_path: Path,
    ) -> None:
        """Config init without --path should write to the detected config location."""
        default_cfg = tmp_path / ".mindroom" / "config.yaml"
        result = runner.invoke(
            app,
            ["config", "init"],
            env={"MINDROOM_CONFIG_PATH": str(default_cfg)},
            input="openai\n",
        )

        assert result.exit_code == 0
        assert default_cfg.exists()
        assert (default_cfg.parent / ".env").exists()

    def test_init_preserved_env_without_storage_path_uses_literal_storage_root(
        self,
        tmp_path: Path,
    ) -> None:
        """Preserving an env file without MINDROOM_STORAGE_PATH must not emit a broken placeholder path."""
        target = tmp_path / "config.yaml"
        env_path = tmp_path / ".env"
        env_path.write_text("ANTHROPIC_API_KEY=sk-existing\n", encoding="utf-8")

        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--matrix-server", "self-hosted", "--provider", "openai"],
            input="n\n",
        )

        assert result.exit_code == 0
        config = yaml.safe_load(target.read_text())
        assert "knowledge_bases" not in config
        assert "${MINDROOM_STORAGE_PATH}" not in target.read_text()
        assert (tmp_path / "mindroom_data" / "agents" / "mind" / "workspace").exists()
        assert env_path.read_text() == "ANTHROPIC_API_KEY=sk-existing\n"

    def test_init_mindroom_chat_writes_hosted_matrix_defaults(self, tmp_path: Path) -> None:
        """mindroom.chat should prefill hosted Matrix defaults and token placeholder."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--matrix-server", "mindroom.chat"])
        assert result.exit_code == 0
        config_content = target.read_text()
        assert "mindroom_user:" not in config_content

        env_content = (tmp_path / ".env").read_text()
        assert "MATRIX_HOMESERVER=https://mindroom.chat" in env_content
        assert "MATRIX_SERVER_NAME=mindroom.chat" in env_content
        assert "MINDROOM_PROVISIONING_URL=https://mindroom.chat" in env_content
        assert "MATRIX_REGISTRATION_TOKEN=" in env_content
        assert "\n\n\n# AI provider API keys" not in env_content

        output = normalize_console_output(result.output)
        assert "mindroom connect --pair-code" in output

    def test_init_mindroom_chat_vertexai_claude_writes_hosted_vertex_defaults(self, tmp_path: Path) -> None:
        """Hosted Vertex config should use Vertex Claude defaults and hosted Matrix settings."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(
            app,
            [
                "config",
                "init",
                "--path",
                str(target),
                "--matrix-server",
                "mindroom.chat",
                "--provider",
                "vertexai_claude",
            ],
        )
        assert result.exit_code == 0

        config = yaml.safe_load(target.read_text())
        assert "mindroom_user" not in config
        assert config["models"]["default"]["provider"] == "vertexai_claude"
        assert config["models"]["default"]["id"] == "claude-sonnet-5"

        env_content = (tmp_path / ".env").read_text()
        assert "MATRIX_HOMESERVER=https://mindroom.chat" in env_content
        assert "ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id" in env_content
        assert "CLOUD_ML_REGION=us-central1" in env_content
        assert "gcloud auth application-default login" in env_content
        assert "\nOPENAI_API_KEY=" not in env_content
        assert "\nOPENROUTER_API_KEY=" not in env_content

        output = normalize_console_output(result.output)
        assert "mindroom connect --pair-code" in output
        assert "Vertex AI project/region" in output
        assert "Google" in output
        assert "auth" in output

    def test_init_mindroom_chat_vertexai_claude_updates_existing_env_with_hosted_defaults(
        self,
        tmp_path: Path,
    ) -> None:
        """mindroom.chat should append Matrix defaults when preserving an existing `.env`."""
        target = tmp_path / "config.yaml"
        env_path = tmp_path / ".env"
        env_path.write_text(
            "ANTHROPIC_VERTEX_PROJECT_ID=existing-project\nCLOUD_ML_REGION=europe-west4\n",
            encoding="utf-8",
        )

        result = runner.invoke(
            app,
            [
                "config",
                "init",
                "--path",
                str(target),
                "--matrix-server",
                "mindroom.chat",
                "--provider",
                "vertexai_claude",
            ],
            input="n\n",
        )

        assert result.exit_code == 0
        env_content = env_path.read_text(encoding="utf-8")
        assert "ANTHROPIC_VERTEX_PROJECT_ID=existing-project" in env_content
        assert "CLOUD_ML_REGION=europe-west4" in env_content
        assert "MATRIX_HOMESERVER=https://mindroom.chat" in env_content
        assert "MATRIX_SERVER_NAME=mindroom.chat" in env_content
        assert "MINDROOM_PROVISIONING_URL=https://mindroom.chat" in env_content
        assert "MINDROOM_NAMESPACE=" in env_content
        assert "MATRIX_REGISTRATION_TOKEN=" in env_content
        assert "Env file updated" in normalize_console_output(result.output)

    def test_init_mindroom_chat_updates_connect_created_env_with_hosted_defaults(
        self,
        tmp_path: Path,
    ) -> None:
        """Running `connect` before `config init` should not strand hosted Matrix defaults."""
        target = tmp_path / "config.yaml"
        env_path = tmp_path / ".env"
        env_path.write_text(
            "MINDROOM_PROVISIONING_URL=https://mindroom.chat\n"
            "MINDROOM_LOCAL_CLIENT_ID=client-123\n"
            "MINDROOM_LOCAL_CLIENT_SECRET=secret-123\n"
            "MINDROOM_NAMESPACE=a1b2c3d4\n",
            encoding="utf-8",
        )

        result = runner.invoke(
            app,
            [
                "config",
                "init",
                "--path",
                str(target),
                "--matrix-server",
                "mindroom.chat",
                "--provider",
                "vertexai_claude",
            ],
            input="n\n",
        )

        assert result.exit_code == 0
        env_content = env_path.read_text(encoding="utf-8")
        assert "MINDROOM_LOCAL_CLIENT_ID=client-123" in env_content
        assert "MINDROOM_LOCAL_CLIENT_SECRET=secret-123" in env_content
        assert env_content.count("MINDROOM_PROVISIONING_URL=") == 1
        assert "MATRIX_HOMESERVER=https://mindroom.chat" in env_content
        assert "MATRIX_SERVER_NAME=mindroom.chat" in env_content
        assert "MATRIX_REGISTRATION_TOKEN=" in env_content

    def test_init_mindroom_chat_uses_connect_created_owner_env(
        self,
        tmp_path: Path,
    ) -> None:
        """Running `connect` before `config init` should still fill owner access settings."""
        target = tmp_path / "config.yaml"
        env_path = tmp_path / ".env"
        env_path.write_text(
            "MINDROOM_PROVISIONING_URL=https://mindroom.chat\n"
            "MINDROOM_LOCAL_CLIENT_ID=client-123\n"
            "MINDROOM_LOCAL_CLIENT_SECRET=secret-123\n"
            "MINDROOM_NAMESPACE=a1b2c3d4\n"
            f"{OWNER_MATRIX_USER_ID_ENV}=@alice:mindroom.chat\n",
            encoding="utf-8",
        )

        result = runner.invoke(
            app,
            [
                "config",
                "init",
                "--path",
                str(target),
                "--matrix-server",
                "mindroom.chat",
                "--provider",
                "vertexai_claude",
            ],
            input="n\n",
        )

        assert result.exit_code == 0
        config_content = target.read_text(encoding="utf-8")
        config = yaml.safe_load(config_content)
        assert OWNER_MATRIX_USER_ID_PLACEHOLDER not in config_content
        assert config["matrix_room_access"]["room_admins"] == ["@alice:mindroom.chat"]
        assert config["authorization"]["global_users"] == ["@alice:mindroom.chat"]
        assert config["authorization"]["agent_reply_permissions"]["*"] == ["@alice:mindroom.chat"]

    def test_init_mindroom_chat_codex_writes_hosted_codex_defaults(self, tmp_path: Path) -> None:
        """Hosted Codex config should use Codex defaults and hosted Matrix settings."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--matrix-server", "mindroom.chat", "--provider", "codex"],
        )
        assert result.exit_code == 0

        config = yaml.safe_load(target.read_text())
        assert "mindroom_user" not in config
        assert config["models"]["default"]["provider"] == "codex"
        assert config["models"]["default"]["id"] == CONFIG_INIT_MODEL_PRESETS["codex"].id
        assert config["models"]["default"]["context_window"] == CONFIG_INIT_MODEL_PRESETS["codex"].context_window
        assert config["models"]["default"]["extra_kwargs"]["reasoning_effort"] == "medium"
        assert "prompt_cache_key" not in config["models"]["default"]["extra_kwargs"]
        assert "Prompt caching is enabled automatically per active agent session." in target.read_text()

        env_content = (tmp_path / ".env").read_text()
        assert "MATRIX_HOMESERVER=https://mindroom.chat" in env_content
        assert "Run `codex login` before starting MindRoom." in env_content
        assert "# CODEX_HOME=~/.codex" in env_content
        assert "\nANTHROPIC_API_KEY=" not in env_content
        assert "\nOPENAI_API_KEY=" not in env_content
        assert "\nOPENROUTER_API_KEY=" not in env_content

        output = normalize_console_output(result.output)
        assert "mindroom connect --pair-code" in output
        assert "codex login" in output

    def test_init_mindroom_chat_kimi_writes_hosted_kimi_defaults(self, tmp_path: Path) -> None:
        """Hosted Kimi config should use Kimi defaults and hosted Matrix settings."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--matrix-server", "mindroom.chat", "--provider", "kimi"],
        )
        assert result.exit_code == 0

        config = yaml.safe_load(target.read_text())
        assert "mindroom_user" not in config
        assert config["models"]["default"]["provider"] == "kimi"
        assert config["models"]["default"]["id"] == CONFIG_INIT_MODEL_PRESETS["kimi"].id
        assert config["models"]["default"]["context_window"] == CONFIG_INIT_MODEL_PRESETS["kimi"].context_window

        env_content = (tmp_path / ".env").read_text()
        assert "MATRIX_HOMESERVER=https://mindroom.chat" in env_content
        assert "Run `kimi` and `/login` before starting MindRoom." in env_content
        assert "# KIMI_CODE_HOME=~/.kimi-code" in env_content
        assert "\nANTHROPIC_API_KEY=" not in env_content
        assert "\nOPENAI_API_KEY=" not in env_content
        assert "\nOPENROUTER_API_KEY=" not in env_content

        output = normalize_console_output(result.output)
        assert "mindroom connect --pair-code" in output
        assert "/login" in output

    def test_init_mindroom_chat_ollama_writes_hosted_ollama_defaults(
        self,
        tmp_path: Path,
    ) -> None:
        """Hosted Ollama config should use local Ollama models and hosted Matrix settings."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--matrix-server", "mindroom.chat", "--provider", "ollama"],
        )
        assert result.exit_code == 0

        config = yaml.safe_load(target.read_text())
        assert "mindroom_user" not in config
        assert config["models"]["default"]["provider"] == "ollama"
        assert config["models"]["default"]["id"] == OLLAMA_GEMMA
        assert config["models"]["default"]["host"] == "http://localhost:11434"
        assert config["models"]["default"]["context_window"] == 128_000
        assert config["models"][LOCAL_QWEN_PRESET_NAME]["provider"] == "ollama"
        assert config["models"][LOCAL_QWEN_PRESET_NAME]["id"] == OLLAMA_QWEN
        assert config["models"][LOCAL_QWEN_PRESET_NAME]["host"] == "http://localhost:11434"
        assert config["models"][LOCAL_QWEN_PRESET_NAME]["context_window"] == LOCAL_QWEN_CONTEXT_WINDOW

        env_content = (tmp_path / ".env").read_text()
        assert "MATRIX_HOMESERVER=https://mindroom.chat" in env_content
        assert "OLLAMA_HOST=http://localhost:11434" in env_content
        assert f"ollama pull {OLLAMA_GEMMA}" in env_content
        assert f"ollama pull {OLLAMA_QWEN}" in env_content
        assert "\nOPENAI_API_KEY=" not in env_content
        assert "\nANTHROPIC_API_KEY=" not in env_content

        output = normalize_console_output(result.output)
        assert "mindroom connect --pair-code" in output
        assert f"ollama pull {OLLAMA_GEMMA}" in output
        assert f"ollama pull {OLLAMA_QWEN}" in output
        assert "Ollama" in output

    @pytest.mark.parametrize("provider_name", ["llama.cpp", "llama-cpp", "llama_cpp"])
    def test_init_mindroom_chat_llama_cpp_writes_hosted_openai_compatible_defaults(
        self,
        tmp_path: Path,
        provider_name: str,
    ) -> None:
        """llama.cpp provider should use local OpenAI-compatible server defaults and hosted Matrix."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--matrix-server", "mindroom.chat", "--provider", provider_name],
        )
        assert result.exit_code == 0

        config = yaml.safe_load(target.read_text())
        assert "mindroom_user" not in config
        assert config["models"]["default"]["provider"] == "llama_cpp"
        assert config["models"]["default"]["id"] == LLAMA_CPP_GEMMA
        assert config["models"]["default"]["context_window"] == 128_000
        assert config["models"]["default"]["extra_kwargs"] == {
            "api_key": "sk-no-key-required",
            "base_url": "http://localhost:8080/v1",
        }
        assert config["models"][LOCAL_QWEN_PRESET_NAME]["provider"] == "llama_cpp"
        assert config["models"][LOCAL_QWEN_PRESET_NAME]["id"] == LLAMA_CPP_QWEN
        assert config["models"][LOCAL_QWEN_PRESET_NAME]["context_window"] == LOCAL_QWEN_CONTEXT_WINDOW
        assert config["models"][LOCAL_QWEN_PRESET_NAME]["extra_kwargs"] == {
            "api_key": "sk-no-key-required",
            "base_url": "http://localhost:8080/v1",
        }

        env_content = (tmp_path / ".env").read_text()
        assert "MATRIX_HOMESERVER=https://mindroom.chat" in env_content
        assert "OPENAI_BASE_URL=http://localhost:8080/v1" in env_content
        assert "OPENAI_API_KEY=sk-no-key-required" in env_content
        assert llama_cpp_server_command(LLAMA_CPP_GEMMA) in env_content
        assert llama_cpp_server_command(LLAMA_CPP_QWEN) in env_content
        assert "\nANTHROPIC_API_KEY=" not in env_content

        output = normalize_console_output(result.output)
        assert "mindroom connect --pair-code" in output
        assert llama_cpp_server_command(LLAMA_CPP_GEMMA) in output
        assert llama_cpp_server_command(LLAMA_CPP_QWEN) in output
        assert "llama.cpp" in output

    def test_init_help_separates_matrix_server_from_provider_presets(self) -> None:
        """Config init help should present Matrix server and model provider as separate choices."""
        result = runner.invoke(app, ["config", "init", "--help"])
        assert result.exit_code == 0

        output = normalize_console_output(result.output)
        assert "azure" in output
        assert "--matrix-server" in output
        assert "self-hosted" in output
        assert "mindroom.chat" in output
        assert "Default model provider" in output
        assert "llama.cpp" in output
        assert "llama_cpp" not in output
        assert "openai_terra" not in output
        assert "openai_luna" not in output
        assert "Use with --matrix-server" not in output
        assert "--profile" not in output
        assert "--minimal" not in output
        assert "--template" not in output
        assert "--print" in output

    def test_init_print_outputs_config_without_writing_files(self, tmp_path: Path) -> None:
        """Config init --print should preview YAML without creating config side effects."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(
            app,
            [
                "config",
                "init",
                "--path",
                str(target),
                "--matrix-server",
                "mindroom.chat",
                "--provider",
                "ollama",
                "--print",
            ],
        )
        assert result.exit_code == 0

        config = yaml.safe_load(result.output)
        assert config["models"]["default"]["provider"] == "ollama"
        assert config["models"]["default"]["id"] == OLLAMA_GEMMA
        assert config["models"][LOCAL_QWEN_PRESET_NAME]["id"] == OLLAMA_QWEN
        assert not target.exists()
        assert not (tmp_path / ".env").exists()
        assert not (tmp_path / "mindroom_data").exists()

        output = normalize_console_output(result.output)
        assert "Config created" not in output
        assert "Next steps" not in output

    def test_init_print_defaults_to_openai_without_prompting(self, tmp_path: Path) -> None:
        """Config init --print should produce only YAML when no provider is specified."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--print"])
        assert result.exit_code == 0

        config = yaml.safe_load(result.output)
        assert config["models"]["default"]["provider"] == "openai"
        assert "Choose provider preset" not in normalize_console_output(result.output)
        assert not target.exists()

    def test_init_print_ignores_existing_config_without_prompting(self, tmp_path: Path) -> None:
        """Config init --print should not prompt for or overwrite an existing config."""
        target = tmp_path / "config.yaml"
        target.write_text("existing: true\n", encoding="utf-8")

        result = runner.invoke(
            app,
            [
                "config",
                "init",
                "--path",
                str(target),
                "--matrix-server",
                "mindroom.chat",
                "--provider",
                "codex",
                "--print",
            ],
        )
        assert result.exit_code == 0

        config = yaml.safe_load(result.output)
        assert config["models"]["default"]["provider"] == "codex"
        assert target.read_text(encoding="utf-8") == "existing: true\n"
        assert "Overwrite existing config file?" not in normalize_console_output(result.output)

    def test_init_print_preserves_existing_env_and_storage_without_prompting(self, tmp_path: Path) -> None:
        """Config init --print should not touch existing .env or storage artifacts."""
        target = tmp_path / "config.yaml"
        env_path = tmp_path / ".env"
        storage_path = tmp_path / "mindroom_data"
        storage_marker = storage_path / "marker.txt"
        env_path.write_text("EXISTING=value\n", encoding="utf-8")
        storage_path.mkdir()
        storage_marker.write_text("keep me\n", encoding="utf-8")

        result = runner.invoke(
            app,
            [
                "config",
                "init",
                "--path",
                str(target),
                "--matrix-server",
                "mindroom.chat",
                "--provider",
                "ollama",
                "--print",
            ],
            input="n\n",
        )
        assert result.exit_code == 0

        config = yaml.safe_load(result.output)
        assert config["models"]["default"]["provider"] == "ollama"
        assert not target.exists()
        assert env_path.read_text(encoding="utf-8") == "EXISTING=value\n"
        assert storage_marker.read_text(encoding="utf-8") == "keep me\n"

        output = normalize_console_output(result.output)
        assert "Overwrite existing .env file?" not in output
        assert "Config created" not in output
        assert "Next steps" not in output

    def test_init_self_hosted_omits_pairing_step(self, tmp_path: Path) -> None:
        """Self-hosted Matrix next steps should NOT mention pairing."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--matrix-server", "self-hosted", "--provider", "openai"],
        )
        assert result.exit_code == 0
        output = normalize_console_output(result.output)
        assert "mindroom connect" not in output

    def test_init_creates_env_with_dashboard_key(self, tmp_path: Path) -> None:
        """Config init writes a random MINDROOM_API_KEY to .env."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target)], input="openai\n")
        assert result.exit_code == 0

        env_path = tmp_path / ".env"
        assert env_path.exists()

        content = env_path.read_text()
        backend_match = re.search(r"^MINDROOM_API_KEY=(.+)$", content, flags=re.MULTILINE)
        assert backend_match is not None
        assert backend_match.group(1)
        # VITE_API_KEY should NOT be in the template (auth is handled at proxy layer)
        assert "VITE_API_KEY" not in content

    def test_init_force_overwrites_existing_env(self, tmp_path: Path) -> None:
        """Config init --force should overwrite an existing .env file."""
        target = tmp_path / "config.yaml"
        env_path = tmp_path / ".env"
        env_path.write_text("ANTHROPIC_API_KEY=sk-existing\n")
        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--force", "--provider", "openai"],
        )
        assert result.exit_code == 0
        new_content = env_path.read_text()
        assert "ANTHROPIC_API_KEY=sk-existing" not in new_content
        assert "OPENAI_API_KEY=" in new_content

    def test_init_prompts_to_overwrite_env_separately(self, tmp_path: Path) -> None:
        """Config init should ask separately about overwriting .env when it exists."""
        target = tmp_path / "config.yaml"
        env_path = tmp_path / ".env"
        env_path.write_text("ANTHROPIC_API_KEY=sk-existing\n")
        # Answer 'n' to .env overwrite prompt
        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--matrix-server", "self-hosted", "--provider", "openai"],
            input="n\n",
        )
        assert result.exit_code == 0
        assert env_path.read_text() == "ANTHROPIC_API_KEY=sk-existing\n"

    def test_init_keeps_existing_env_without_storage_root_and_resolves_to_default_root(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Keeping an existing `.env` without MINDROOM_STORAGE_PATH should still produce a working starter config."""
        target = tmp_path / "config.yaml"
        env_path = tmp_path / ".env"
        env_path.write_text("OPENAI_API_KEY=sk-existing\n", encoding="utf-8")
        monkeypatch.delenv("MINDROOM_STORAGE_PATH", raising=False)

        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--matrix-server", "self-hosted", "--provider", "openai"],
            input="n\n",
        )

        assert result.exit_code == 0
        workspace = tmp_path / "mindroom_data" / "agents" / "mind" / "workspace"
        assert (workspace / "SOUL.md").exists()
        config = yaml.safe_load(target.read_text(encoding="utf-8"))
        assert "knowledge_bases" not in config
        assert (workspace / "memory").exists()

    def test_init_keeps_existing_env_storage_root_for_workspace(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Keeping an existing `.env` with MINDROOM_STORAGE_PATH should keep starter files under that root."""
        target = tmp_path / "config.yaml"
        env_path = tmp_path / ".env"
        custom_root = tmp_path / "custom-root"
        env_path.write_text(
            f"MINDROOM_STORAGE_PATH={custom_root}\nOPENAI_API_KEY=sk-existing\n",
            encoding="utf-8",
        )
        monkeypatch.delenv("MINDROOM_STORAGE_PATH", raising=False)

        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--matrix-server", "self-hosted", "--provider", "openai"],
            input="n\n",
        )

        assert result.exit_code == 0
        workspace = custom_root / "agents" / "mind" / "workspace"
        assert (workspace / "SOUL.md").exists()
        config = yaml.safe_load(target.read_text(encoding="utf-8"))
        assert "knowledge_bases" not in config
        assert (workspace / "memory").exists()
        assert env_path.read_text(encoding="utf-8").startswith(f"MINDROOM_STORAGE_PATH={custom_root}\n")

    def test_init_overwrites_env_when_confirmed(self, tmp_path: Path) -> None:
        """Config init should overwrite .env when user confirms."""
        target = tmp_path / "config.yaml"
        env_path = tmp_path / ".env"
        env_path.write_text("ANTHROPIC_API_KEY=sk-existing\n")
        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--provider", "openai"],
            input="y\n",
        )
        assert result.exit_code == 0
        new_content = env_path.read_text()
        assert "ANTHROPIC_API_KEY=sk-existing" not in new_content
        assert "Env file overwritten" in normalize_console_output(result.output)

    def test_init_refuses_overwrite_without_force(self, tmp_path: Path) -> None:
        """Config init prompts before overwriting and aborts on 'n'."""
        target = tmp_path / "config.yaml"
        target.write_text("existing")
        result = runner.invoke(app, ["config", "init", "--path", str(target)], input="n\n")
        assert result.exit_code == 0
        assert target.read_text() == "existing"

    def test_init_force_overwrites(self, tmp_path: Path) -> None:
        """Config init --force overwrites without prompting."""
        target = tmp_path / "config.yaml"
        target.write_text("existing")
        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--force", "--provider", "openai"],
        )
        assert result.exit_code == 0
        content = target.read_text()
        assert content != "existing"
        assert "agents:" in content

    def test_init_no_input_keeps_existing_config_and_recreates_missing_env(self, tmp_path: Path) -> None:
        """Config init --no-input keeps an existing config but still creates a missing .env."""
        target = tmp_path / "config.yaml"
        target.write_text("existing")
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--no-input"])
        assert result.exit_code == 0
        assert target.read_text() == "existing"
        assert "Keeping existing config.yaml" in normalize_console_output(result.output)
        env_content = (tmp_path / ".env").read_text()
        assert "MATRIX_HOMESERVER" in env_content

    def test_init_no_input_rerun_leaves_config_and_env_untouched(self, tmp_path: Path) -> None:
        """Config init --no-input is idempotent once config.yaml and a hosted .env exist."""
        target = tmp_path / "config.yaml"
        first = runner.invoke(app, ["config", "init", "--path", str(target), "--no-input"])
        assert first.exit_code == 0
        config_before = target.read_text()
        env_before = (tmp_path / ".env").read_text()
        second = runner.invoke(app, ["config", "init", "--path", str(target), "--no-input"])
        assert second.exit_code == 0
        assert target.read_text() == config_before
        assert (tmp_path / ".env").read_text() == env_before

    def test_init_no_input_keeps_existing_env_and_appends_hosted_defaults(self, tmp_path: Path) -> None:
        """Config init --no-input preserves .env values and only appends missing hosted defaults."""
        target = tmp_path / "config.yaml"
        env_path = tmp_path / ".env"
        env_path.write_text("ANTHROPIC_API_KEY=sk-existing\n")
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--no-input"])
        assert result.exit_code == 0
        assert target.exists()
        env_content = env_path.read_text()
        assert "ANTHROPIC_API_KEY=sk-existing" in env_content
        assert "MATRIX_HOMESERVER" in env_content

    def test_init_no_input_self_hosted_defaults_to_openai_without_prompting(self, tmp_path: Path) -> None:
        """Self-hosted config init --no-input skips the provider prompt and uses OpenAI."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(
            app,
            ["config", "init", "--path", str(target), "--matrix-server", "self-hosted", "--no-input"],
        )
        assert result.exit_code == 0
        config = yaml.safe_load(target.read_text())
        assert config["models"]["default"]["provider"] == "openai"

    def test_init_openai_preset_uses_openai_models(self, tmp_path: Path) -> None:
        """Config init --provider openai prepopulates OpenAI defaults."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "openai"])
        assert result.exit_code == 0
        config = yaml.safe_load(target.read_text())
        assert config["models"]["default"]["provider"] == "openai"
        assert config["models"]["default"]["id"] == CONFIG_INIT_MODEL_PRESETS["openai"].id
        assert config["models"]["default"]["context_window"] == CONFIG_INIT_MODEL_PRESETS["openai"].context_window
        assert "openai_terra" not in config["models"]
        assert "openai_luna" not in config["models"]

        config_text = target.read_text(encoding="utf-8")
        assert "# openai_terra:" in config_text
        assert f"#   id: {OPENAI_GPT_TERRA}" in config_text
        assert "# openai_luna:" in config_text
        assert f"#   id: {OPENAI_GPT_LUNA}" in config_text
        assert config["matrix_room_access"] == {
            "mode": "single_user_private",
            "room_admins": [OWNER_MATRIX_USER_ID_PLACEHOLDER],
        }

    def test_init_anthropic_preset_uses_anthropic_models(self, tmp_path: Path) -> None:
        """Config init --provider anthropic prepopulates Anthropic defaults."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "anthropic"])
        assert result.exit_code == 0
        config = yaml.safe_load(target.read_text())
        assert config["models"]["default"]["provider"] == "anthropic"
        assert config["models"]["default"]["id"] == "claude-sonnet-5"
        assert config["models"]["default"]["context_window"] == 1_000_000

        env_content = (tmp_path / ".env").read_text()
        assert "ANTHROPIC_API_KEY=your-anthropic-key-here" in env_content
        assert "# OPENAI_API_KEY=your-openai-key-here" in env_content

    def test_init_openrouter_preset_uses_openrouter_models(self, tmp_path: Path) -> None:
        """Config init --provider openrouter uses OpenRouter with Claude model."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "openrouter"])
        assert result.exit_code == 0
        config = yaml.safe_load(target.read_text())
        assert config["models"]["default"]["provider"] == "openrouter"
        assert config["models"]["default"]["id"] == "anthropic/claude-sonnet-5"
        assert config["models"]["default"]["context_window"] == 1_000_000

    def test_init_azure_preset_uses_azure_openai_models(self, tmp_path: Path) -> None:
        """Config init --provider azure uses Azure OpenAI deployment defaults."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "azure"])
        assert result.exit_code == 0

        config = yaml.safe_load(target.read_text())
        assert config["models"]["default"]["provider"] == "azure"
        assert config["models"]["default"]["id"] == "your-azure-openai-deployment"
        assert "context_window" not in config["models"]["default"]

        env_content = (tmp_path / ".env").read_text()
        assert "AZURE_OPENAI_API_KEY=your-azure-openai-key-here" in env_content
        assert "AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com" in env_content
        assert "# AZURE_OPENAI_API_VERSION=2024-10-21" in env_content
        assert "\nAZURE_OPENAI_API_VERSION=" not in env_content
        assert "\nOPENAI_API_KEY=" not in env_content
        assert "\nANTHROPIC_API_KEY=" not in env_content

        output = normalize_console_output(result.output)
        assert "Azure OpenAI" in output
        assert "deployment" in output

    def test_init_bedrock_claude_preset_uses_opus_model(self, tmp_path: Path) -> None:
        """Config init --provider bedrock_claude uses Amazon Bedrock Claude Opus defaults."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "bedrock_claude"])
        assert result.exit_code == 0

        config = yaml.safe_load(target.read_text())
        assert config["models"]["default"]["provider"] == "bedrock_claude"
        assert config["models"]["default"]["id"] == "anthropic.claude-opus-5"
        assert config["models"]["default"]["context_window"] == 1_000_000

        config_text = target.read_text(encoding="utf-8")
        assert "# fable:" in config_text
        assert "#   id: anthropic.claude-fable-5" in config_text
        assert "# sonnet:" in config_text
        assert "#   id: anthropic.claude-sonnet-5" in config_text
        assert "# haiku:" in config_text
        assert "#   id: anthropic.claude-haiku-4-5" in config_text

        env_content = (tmp_path / ".env").read_text()
        assert "AWS_REGION=us-east-1" in env_content
        assert "# AWS_ACCESS_KEY_ID=your-access-key-id" in env_content
        assert "# AWS_SECRET_ACCESS_KEY=your-secret-access-key" in env_content
        assert "# AWS_PROFILE=your-profile" in env_content
        assert "\nANTHROPIC_API_KEY=" not in env_content
        assert "\nOPENAI_API_KEY=" not in env_content

    @pytest.mark.parametrize("provider", ["bedrock", "aws_bedrock", "aws-bedrock", "bedrock-claude"])
    def test_init_rejects_bedrock_claude_provider_aliases(self, tmp_path: Path, provider: str) -> None:
        """Config init should expose only the canonical Bedrock Claude preset name."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", provider])
        assert result.exit_code == 1
        assert "Invalid --provider value" in normalize_console_output(result.output)

    def test_provider_env_template_uses_canonical_provider_env_key(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Provider .env templates should derive required keys from the canonical provider mapping."""
        monkeypatch.setattr(
            config_cli.constants,
            "env_key_for_provider",
            lambda provider: "OPENAI_API_KEY" if provider == "openrouter" else None,
        )

        env_content = config_cli._provider_env_template("openrouter")

        assert "\nOPENAI_API_KEY=your-openai-key-here" in f"\n{env_content}"
        assert "\n# OPENROUTER_API_KEY=your-openrouter-key-here" in f"\n{env_content}"

    def test_init_codex_preset_uses_codex_models(self, tmp_path: Path) -> None:
        """Config init --provider codex uses Codex ChatGPT-login defaults."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "codex"])
        assert result.exit_code == 0

        config = yaml.safe_load(target.read_text())
        assert config["models"]["default"]["provider"] == "codex"
        assert config["models"]["default"]["id"] == CONFIG_INIT_MODEL_PRESETS["codex"].id
        assert config["models"]["default"]["context_window"] == CONFIG_INIT_MODEL_PRESETS["codex"].context_window
        assert config["models"]["default"]["extra_kwargs"]["reasoning_effort"] == "medium"
        assert "prompt_cache_key" not in config["models"]["default"]["extra_kwargs"]

        env_content = (tmp_path / ".env").read_text()
        assert "Run `codex login` before starting MindRoom." in env_content
        assert "# CODEX_HOME=~/.codex" in env_content
        assert "OPENAI_API_KEY=your-openai-key-here" not in env_content

    def test_init_kimi_preset_uses_kimi_models(self, tmp_path: Path) -> None:
        """Config init --provider kimi uses Kimi Code CLI login defaults."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "kimi"])
        assert result.exit_code == 0

        config = yaml.safe_load(target.read_text())
        assert config["models"]["default"]["provider"] == "kimi"
        assert config["models"]["default"]["id"] == CONFIG_INIT_MODEL_PRESETS["kimi"].id
        assert config["models"]["default"]["context_window"] == CONFIG_INIT_MODEL_PRESETS["kimi"].context_window

        env_content = (tmp_path / ".env").read_text()
        assert "Run `kimi` and `/login` before starting MindRoom." in env_content
        assert "# KIMI_CODE_HOME=~/.kimi-code" in env_content
        assert "OPENAI_API_KEY=your-openai-key-here" not in env_content

    @pytest.mark.parametrize("provider", ["kimi-code", "kimi_code"])
    def test_init_rejects_kimi_provider_aliases(self, tmp_path: Path, provider: str) -> None:
        """Config init should accept kimi as the provider preset without extra aliases."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", provider])
        assert result.exit_code == 1
        assert "Invalid --provider value" in normalize_console_output(result.output)

    @pytest.mark.parametrize("provider", ["openai-codex", "openai_codex", "c"])
    def test_init_rejects_openai_codex_provider_aliases(self, tmp_path: Path, provider: str) -> None:
        """Config init should accept codex as the provider preset without extra aliases."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", provider])
        assert result.exit_code == 1
        assert "Invalid --provider value" in normalize_console_output(result.output)

    def test_init_anthropic_preset_uses_anthropic_models_and_local_embedder(self, tmp_path: Path) -> None:
        """Config init --provider anthropic should only require the Anthropic API key."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "anthropic"])
        assert result.exit_code == 0

        config = yaml.safe_load(target.read_text())
        assert config["models"]["default"]["provider"] == "anthropic"
        assert config["models"]["default"]["id"] == "claude-sonnet-5"
        assert config["models"]["default"]["context_window"] == 1_000_000
        assert config["memory"]["embedder"]["provider"] == "sentence_transformers"
        assert config["memory"]["embedder"]["config"]["model"] == "sentence-transformers/all-MiniLM-L6-v2"

        env_content = (tmp_path / ".env").read_text()
        assert "ANTHROPIC_API_KEY=your-anthropic-key-here" in env_content
        assert "\n# OPENAI_API_KEY=your-openai-key-here" in env_content
        assert "\n# OPENROUTER_API_KEY=your-openrouter-key-here" in env_content

    def test_init_vertexai_claude_preset_uses_vertex_models(self, tmp_path: Path) -> None:
        """Config init --provider vertexai_claude uses Vertex AI Claude defaults."""
        target = tmp_path / "config.yaml"
        result = runner.invoke(app, ["config", "init", "--path", str(target), "--provider", "vertexai_claude"])
        assert result.exit_code == 0
        config = yaml.safe_load(target.read_text())
        assert config["models"]["default"]["provider"] == "vertexai_claude"
        assert config["models"]["default"]["id"] == "claude-sonnet-5"
        assert config["models"]["default"]["context_window"] == 1_000_000

        env_content = (tmp_path / ".env").read_text()
        assert "ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id" in env_content
        assert "CLOUD_ML_REGION=us-central1" in env_content


# ---------------------------------------------------------------------------
# mindroom config migrate
# ---------------------------------------------------------------------------


def _old_config_init_mind_memory_config(knowledge_path: str) -> str:
    return f"""\
# MindRoom Configuration
# Generated by: mindroom config init
# Keep this hand-written comment.

models:
  default:
    provider: openai
    id: gpt-5.6

agents:
  assistant:
    display_name: Assistant
    role: A helpful general-purpose assistant
    model: default
    rooms:
      - lobby
    accept_invites: true
    tools: []
    instructions:
      - Be helpful and conversational
  mind:
    display_name: Mind
    role: Personal assistant with persistent file-based identity and memory
    model: default
    include_default_tools: false
    learning: false
    memory_backend: file
    rooms:
      - personal
    accept_invites: true
    context_files:
      - SOUL.md
      - AGENTS.md
      - USER.md
      - IDENTITY.md
      - TOOLS.md
      - HEARTBEAT.md
    knowledge_bases:
      - mind_memory
    tools:
      - shell
      - coding
      - duckduckgo
      - website
      - browser
      - scheduler
      - subagents
      - matrix_message
      - thread_tags
    skills:
      - mindroom-docs
    instructions:
      - You wake up fresh each session with no memory of previous conversations. Your context files are already loaded into your system prompt.
      - Important long-term context is persisted by the configured MindRoom memory backend. If something must be preserved exactly, write or update the relevant file directly.
      - MEMORY.md is curated long-term memory; daily files are short-lived notes and logs.
      - Ask before external or destructive actions.
      - Before answering prior-history questions, search memory files first when a knowledge base is configured.

router:
  model: default
  accept_invites: true

matrix_room_access:
  mode: single_user_private

knowledge_bases:
  mind_memory:
    path: {knowledge_path}
    watch: true

# File-based memory requires no external LLM, and starter configs use a local embedder for knowledge indexing.
memory:
  backend: file
  embedder:
    provider: sentence_transformers
    config:
      model: sentence-transformers/all-MiniLM-L6-v2
  file:
    max_entrypoint_lines: 200
  auto_flush:
    enabled: true

defaults:
  tools:
    - scheduler
  markdown: true
"""


def _migrated_config_init_mind_memory_config() -> str:
    return """\
# MindRoom Configuration
# Generated by: mindroom config init
# Keep this hand-written comment.

models:
  default:
    provider: openai
    id: gpt-5.6

agents:
  assistant:
    display_name: Assistant
    role: A helpful general-purpose assistant
    model: default
    rooms:
      - lobby
    accept_invites: true
    tools: []
    instructions:
      - Be helpful and conversational
  mind:
    display_name: Mind
    role: Personal assistant with persistent file-based identity and memory
    model: default
    include_default_tools: false
    learning: false
    memory_backend: file
    rooms:
      - personal
    accept_invites: true
    context_files:
      - SOUL.md
      - AGENTS.md
      - USER.md
      - IDENTITY.md
      - TOOLS.md
      - HEARTBEAT.md
    tools:
      - shell
      - coding
      - memory
      - duckduckgo
      - website
      - browser
      - scheduler
      - subagents
      - matrix_message
      - thread_tags
    skills:
      - mindroom-docs
    instructions:
      - You wake up fresh each session with no memory of previous conversations. Your context files are already loaded into your system prompt.
      - Important long-term context is persisted by the configured MindRoom memory backend. If something must be preserved exactly, write or update the relevant file directly.
      - MEMORY.md is curated long-term memory; daily files are short-lived notes and logs.
      - Ask before external or destructive actions.
      - Before answering prior-history questions, use search_memories first.

router:
  model: default
  accept_invites: true

matrix_room_access:
  mode: single_user_private

# File-based memory requires no external LLM.
memory:
  backend: file
  embedder:
    provider: sentence_transformers
    config:
      model: sentence-transformers/all-MiniLM-L6-v2
  file:
    max_entrypoint_lines: 200
  search:
    mode: semantic
    include:
      - memory/**/*.md
    include_entrypoint: false
  auto_flush:
    enabled: true

defaults:
  tools:
    - scheduler
  markdown: true
"""


class TestConfigMigrate:
    """Tests for `mindroom config migrate`."""

    @pytest.mark.parametrize(
        "knowledge_path",
        [
            "${MINDROOM_STORAGE_PATH}/agents/mind/workspace/memory",
            "./mindroom_data/agents/mind/workspace/memory",
        ],
    )
    def test_migrate_updates_old_config_init_mind_memory_without_reformatting(
        self,
        tmp_path: Path,
        knowledge_path: str,
    ) -> None:
        """Old starter mind_memory config should be text-patched to memory search."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_old_config_init_mind_memory_config(knowledge_path), encoding="utf-8")

        result = runner.invoke(app, ["config", "migrate", "--path", str(cfg)])

        assert result.exit_code == 0
        assert "Applied migration" in normalize_console_output(result.output)
        assert cfg.read_text(encoding="utf-8") == _migrated_config_init_mind_memory_config()

        config = yaml.safe_load(cfg.read_text(encoding="utf-8"))
        mind = config["agents"]["mind"]
        assert "knowledge_bases" not in mind
        assert mind["tools"] == [
            "shell",
            "coding",
            "memory",
            "duckduckgo",
            "website",
            "browser",
            "scheduler",
            "subagents",
            "matrix_message",
            "thread_tags",
        ]
        assert "knowledge_bases" not in config
        assert config["memory"]["search"] == {
            "mode": "semantic",
            "include": ["memory/**/*.md"],
            "include_entrypoint": False,
        }

    def test_migrate_leaves_custom_mind_memory_config_unchanged(self, tmp_path: Path) -> None:
        """Customized old memory knowledge path should not be silently rewritten."""
        cfg = tmp_path / "config.yaml"
        original = _old_config_init_mind_memory_config("./custom-memory")
        cfg.write_text(original, encoding="utf-8")

        result = runner.invoke(app, ["config", "migrate", "--path", str(cfg)])

        assert result.exit_code == 0
        assert "No migrations applied" in normalize_console_output(result.output)
        assert cfg.read_text(encoding="utf-8") == original

    def test_migrate_missing_config_exits_with_error(self, tmp_path: Path) -> None:
        """Config migrate should fail cleanly when no config exists."""
        missing = tmp_path / "config.yaml"

        result = runner.invoke(app, ["config", "migrate", "--path", str(missing)])

        assert result.exit_code == 1
        assert "No config file found" in normalize_console_output(result.output)

    def test_migrate_write_failure_reports_write_error(self, tmp_path: Path) -> None:
        """Write failures should not be reported as config validation failures."""
        cfg = tmp_path / "config.yaml"
        original = _old_config_init_mind_memory_config("${MINDROOM_STORAGE_PATH}/agents/mind/workspace/memory")
        cfg.write_text(original, encoding="utf-8")

        with patch.object(migrate_cli, "_write_text_atomic", side_effect=OSError("disk full")):
            result = runner.invoke(app, ["config", "migrate", "--path", str(cfg)])

        output = normalize_console_output(result.output)
        assert result.exit_code == 1
        assert "Could not write migrated configuration" in output
        assert "Invalid configuration" not in output
        assert cfg.read_text(encoding="utf-8") == original


# ---------------------------------------------------------------------------
# mindroom config show
# ---------------------------------------------------------------------------


class TestConfigShow:
    """Tests for `mindroom config show`."""

    def test_show_existing_config(self, tmp_path: Path) -> None:
        """Config show --raw prints file contents."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("agents:\n  test:\n    display_name: Test\n")
        result = runner.invoke(app, ["config", "show", "--path", str(cfg), "--raw"])
        assert result.exit_code == 0
        assert "agents:" in result.output

    def test_show_missing_config(self, tmp_path: Path) -> None:
        """Config show exits 1 when config is missing."""
        missing = tmp_path / "nonexistent.yaml"
        result = runner.invoke(app, ["config", "show", "--path", str(missing)])
        assert result.exit_code == 1
        assert "No config file found" in result.output

    def test_show_missing_config_lists_explicit_path_first(self, tmp_path: Path) -> None:
        """Config show should render the explicit missing path as the first search location."""
        missing = tmp_path / "nonexistent.yaml"
        result = runner.invoke(app, ["config", "show", "--path", str(missing)], terminal_width=200)

        assert result.exit_code == 1
        output = normalize_console_output(result.output)
        assert str(missing.resolve()) in output
        assert output.index(str(missing.resolve())) < output.index(str(Path("config.yaml").resolve()))

    def test_show_invalid_utf8_config(self, tmp_path: Path) -> None:
        """Config show should report unreadable config text cleanly."""
        cfg = tmp_path / "config.yaml"
        cfg.write_bytes(b"\xff\xfe\x00\x00")

        result = runner.invoke(app, ["config", "show", "--path", str(cfg)])

        assert result.exit_code == 1
        assert "Invalid configuration" in result.output
        assert "Could not read configuration text" in result.output

    def test_show_os_error_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Config show should report OS-level read failures cleanly."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("agents: {}\n")

        def raise_permission_error(_self: Path, *_args: object, **_kwargs: object) -> str:
            msg = "permission denied"
            raise PermissionError(msg)

        monkeypatch.setattr(Path, "read_text", raise_permission_error)

        result = runner.invoke(app, ["config", "show", "--path", str(cfg)])

        assert result.exit_code == 1
        assert "Invalid configuration" in result.output
        assert "Could not load configuration" in result.output
        assert "permission denied" in result.output


# ---------------------------------------------------------------------------
# mindroom config edit
# ---------------------------------------------------------------------------


class TestConfigEdit:
    """Tests for `mindroom config edit`."""

    def test_edit_missing_config(self, tmp_path: Path) -> None:
        """Config edit exits 1 when config is missing."""
        missing = tmp_path / "nonexistent.yaml"
        result = runner.invoke(app, ["config", "edit", "--path", str(missing)])
        assert result.exit_code == 1
        assert "No config file found" in result.output

    def test_edit_opens_editor(self, tmp_path: Path) -> None:
        """Config edit invokes subprocess.run with the editor."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("agents: {}")
        with patch("mindroom.cli.config.subprocess.run") as mock_run:
            mock_run.return_value = None
            result = runner.invoke(app, ["config", "edit", "--path", str(cfg)])
            assert result.exit_code == 0
            mock_run.assert_called_once()


# ---------------------------------------------------------------------------
# mindroom config validate
# ---------------------------------------------------------------------------


class TestConfigValidate:
    """Tests for `mindroom config validate`."""

    def test_validate_valid_config(self, tmp_path: Path) -> None:
        """Config validate reports success for a valid config."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n",
        )
        result = runner.invoke(app, ["config", "validate", "--path", str(cfg)])
        assert result.exit_code == 0
        assert "valid" in result.output.lower()

    def test_validate_missing_file(self, tmp_path: Path) -> None:
        """Config validate exits 1 when file is missing."""
        missing = tmp_path / "nonexistent.yaml"
        result = runner.invoke(app, ["config", "validate", "--path", str(missing)])
        assert result.exit_code == 1
        assert "not found" in result.output.lower()

    def test_validate_invalid_config(self, tmp_path: Path) -> None:
        """Config validate shows friendly errors for invalid config."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("agents: not_a_dict\n")
        result = runner.invoke(app, ["config", "validate", "--path", str(cfg)])
        assert result.exit_code == 1
        assert "Issues found" in result.output

    def test_validate_invalid_plugin_manifest_name(self, tmp_path: Path) -> None:
        """Config validate should report plugin manifest validation failures cleanly."""
        plugin_root = tmp_path / "plugins" / "bad-name"
        plugin_root.mkdir(parents=True)
        (plugin_root / "mindroom.plugin.json").write_text(
            json.dumps({"name": "BadName", "tools_module": None, "skills": []}),
            encoding="utf-8",
        )
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n"
            "plugins:\n  - ./plugins/bad-name\n",
        )

        result = runner.invoke(app, ["config", "validate", "--path", str(cfg)])

        assert result.exit_code == 1
        assert "Invalid configuration" in result.output
        assert "Invalid plugin name" in result.output

    def test_validate_malformed_plugin_manifest(self, tmp_path: Path) -> None:
        """Config validate should report malformed plugin manifests cleanly."""
        plugin_root = tmp_path / "plugins" / "bad-manifest"
        plugin_root.mkdir(parents=True)
        (plugin_root / "mindroom.plugin.json").write_text(
            json.dumps({"name": "good_plugin", "tools_module": 123, "skills": []}),
            encoding="utf-8",
        )
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n"
            "plugins:\n  - ./plugins/bad-manifest\n",
        )

        result = runner.invoke(app, ["config", "validate", "--path", str(cfg)])

        assert result.exit_code == 1
        assert "Invalid configuration" in result.output
        assert "Plugin tools_module must be a string" in result.output

    def test_validate_rejects_missing_plugin_path(self, tmp_path: Path) -> None:
        """Config validate should stay strict about missing plugin paths."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n"
            "plugins:\n  - ./plugins/this-plugin-does-not-exist\n",
            encoding="utf-8",
        )

        result = runner.invoke(app, ["config", "validate", "--path", str(cfg)])

        assert result.exit_code == 1
        assert "Invalid configuration" in result.output
        assert "Configured plugin path does not exist" in result.output

    def test_validate_invalid_utf8_config(self, tmp_path: Path) -> None:
        """Config validate should report unreadable config text cleanly."""
        cfg = tmp_path / "config.yaml"
        cfg.write_bytes(b"\xff\xfe\x00\x00")

        result = runner.invoke(app, ["config", "validate", "--path", str(cfg)])

        assert result.exit_code == 1
        assert "Invalid configuration" in result.output
        assert "Could not read configuration text" in result.output

    def test_validate_accepts_file_based_api_key_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Config validate does not warn when provider secrets are supplied via *_FILE."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: openai\n    id: gpt-5.4\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n",
        )
        secret_file = tmp_path / "openai_key"
        secret_file.write_text("sk-test", encoding="utf-8")
        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
        monkeypatch.setenv("OPENAI_API_KEY_FILE", str(secret_file))

        result = runner.invoke(app, ["config", "validate", "--path", str(cfg)])

        assert result.exit_code == 0
        assert "Missing environment variables" not in result.output

    def test_validate_warns_for_missing_vertexai_claude_env(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Config validate should warn about missing Vertex AI project settings."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: vertexai_claude\n    id: claude-sonnet-5\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n",
        )
        monkeypatch.delenv("ANTHROPIC_VERTEX_PROJECT_ID", raising=False)
        monkeypatch.delenv("ANTHROPIC_VERTEX_PROJECT_ID_FILE", raising=False)
        monkeypatch.delenv("CLOUD_ML_REGION", raising=False)
        monkeypatch.delenv("CLOUD_ML_REGION_FILE", raising=False)

        result = runner.invoke(app, ["config", "validate", "--path", str(cfg)])

        assert result.exit_code == 0
        assert "Missing environment variables" in result.output
        assert "ANTHROPIC_VERTEX_PROJECT_ID" in result.output
        assert "CLOUD_ML_REGION" in result.output

    def test_validate_warns_for_missing_bedrock_claude_region(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Config validate should warn about missing Bedrock region settings."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: bedrock_claude\n    id: anthropic.claude-opus-5\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n",
        )
        monkeypatch.delenv("AWS_REGION", raising=False)
        monkeypatch.delenv("AWS_REGION_FILE", raising=False)
        monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False)
        monkeypatch.delenv("AWS_DEFAULT_REGION_FILE", raising=False)
        monkeypatch.delenv("AWS_PROFILE", raising=False)
        monkeypatch.delenv("AWS_PROFILE_FILE", raising=False)

        result = runner.invoke(app, ["config", "validate", "--path", str(cfg)])

        assert result.exit_code == 0
        assert "Missing environment variables" in result.output
        assert "bedrock_claude" in result.output
        assert "AWS_REGION" in result.output

    def test_validate_accepts_bedrock_claude_region_in_extra_kwargs(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Config validate should accept Bedrock region configured on the model."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n"
            "  default:\n"
            "    provider: bedrock_claude\n"
            "    id: anthropic.claude-opus-5\n"
            "    extra_kwargs:\n"
            "      aws_region: us-west-2\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n",
        )
        monkeypatch.delenv("AWS_REGION", raising=False)
        monkeypatch.delenv("AWS_REGION_FILE", raising=False)
        monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False)
        monkeypatch.delenv("AWS_DEFAULT_REGION_FILE", raising=False)
        monkeypatch.delenv("AWS_PROFILE", raising=False)
        monkeypatch.delenv("AWS_PROFILE_FILE", raising=False)

        result = runner.invoke(app, ["config", "validate", "--path", str(cfg)])

        assert result.exit_code == 0
        assert "Missing environment variables" not in result.output

    def test_validate_accepts_bedrock_claude_profile_in_extra_kwargs(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Config validate should accept a Bedrock AWS profile without explicit region."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n"
            "  default:\n"
            "    provider: bedrock_claude\n"
            "    id: anthropic.claude-opus-5\n"
            "    extra_kwargs:\n"
            "      aws_profile: dev-profile\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n",
        )
        monkeypatch.delenv("AWS_REGION", raising=False)
        monkeypatch.delenv("AWS_REGION_FILE", raising=False)
        monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False)
        monkeypatch.delenv("AWS_DEFAULT_REGION_FILE", raising=False)
        monkeypatch.delenv("AWS_PROFILE", raising=False)
        monkeypatch.delenv("AWS_PROFILE_FILE", raising=False)

        result = runner.invoke(app, ["config", "validate", "--path", str(cfg)])

        assert result.exit_code == 0
        assert "Missing environment variables" not in result.output

    def test_validate_accepts_bedrock_claude_profile_from_env(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Config validate should accept Bedrock AWS_PROFILE without explicit region."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: bedrock_claude\n    id: anthropic.claude-opus-5\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n",
        )
        monkeypatch.delenv("AWS_REGION", raising=False)
        monkeypatch.delenv("AWS_REGION_FILE", raising=False)
        monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False)
        monkeypatch.delenv("AWS_DEFAULT_REGION_FILE", raising=False)
        monkeypatch.setenv("AWS_PROFILE", "dev-profile")

        result = runner.invoke(app, ["config", "validate", "--path", str(cfg)])

        assert result.exit_code == 0
        assert "Missing environment variables" not in result.output

    def test_validate_uses_active_config_sibling_env_from_exported_config_path(self, tmp_path: Path) -> None:
        """Config validate should honor the sibling .env of the exported active config path."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: openai\n    id: gpt-5.4\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n",
        )
        (tmp_path / ".env").write_text("OPENAI_API_KEY=sk-test\n", encoding="utf-8")

        result = runner.invoke(app, ["config", "validate"], env={"MINDROOM_CONFIG_PATH": str(cfg)})

        assert result.exit_code == 0
        assert "Missing environment variables" not in result.output


# ---------------------------------------------------------------------------
# mindroom config path
# ---------------------------------------------------------------------------


class TestConfigPath:
    """Tests for `mindroom config path`."""

    def test_path_shows_location(self) -> None:
        """Config path prints the resolved config location."""
        result = runner.invoke(app, ["config", "path"])
        assert result.exit_code == 0
        assert "Resolved config path" in result.output

    def test_path_with_explicit_path_lists_that_path_first(self, tmp_path: Path) -> None:
        """Config path should render the explicit path as the first search location."""
        missing = tmp_path / "missing.yaml"
        result = runner.invoke(app, ["config", "path", "--path", str(missing)], terminal_width=200)

        assert result.exit_code == 0
        output = normalize_console_output(result.output)
        assert str(missing.resolve()) in output
        assert output.index(str(missing.resolve())) < output.index(str(Path("config.yaml").resolve()))


# ---------------------------------------------------------------------------
# run command error handling
# ---------------------------------------------------------------------------


class TestRunErrorHandling:
    """Tests for friendly error messages in `mindroom run`."""

    def test_run_missing_config(self, tmp_path: Path) -> None:
        """Run suggests config setup commands when config is missing."""
        cfg = tmp_path / "no_such_config.yaml"
        mock_main = AsyncMock()

        with patch("mindroom.orchestrator.main", mock_main):
            result = _invoke_with_runtime(["run"], cfg)

        assert result.exit_code == 1
        assert "No config found" in result.output
        assert "mindroom config init" in result.output
        provider_guidance = (
            "mindroom config init --provider {openrouter,ollama,openai,azure,bedrock_claude,codex,kimi,claude"
        )
        assert provider_guidance in result.output
        mock_main.assert_not_awaited()
        assert not cfg.exists()

    def test_load_active_config_or_exit_uses_runtime_process_env_for_missing_config(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Top-level missing-config output should respect the explicit runtime snapshot."""
        missing = tmp_path / "runtime-missing.yaml"
        ambient = tmp_path / "ambient-missing.yaml"
        runtime_paths = constants_module.resolve_primary_runtime_paths(config_path=missing, process_env={})
        monkeypatch.setenv("MINDROOM_CONFIG_PATH", str(ambient))

        with pytest.raises(typer.Exit):
            _load_active_config_or_exit(runtime_paths)

        output = normalize_console_output(capsys.readouterr().out)
        assert str(missing.resolve()) in output
        assert str(ambient.resolve()) not in output

    def test_run_invalid_config(self, tmp_path: Path) -> None:
        """Run shows friendly error when config is invalid."""
        bad_cfg = tmp_path / "config.yaml"
        bad_cfg.write_text("agents: not_a_dict\n")
        result = _invoke_with_runtime(["run"], bad_cfg)
        assert result.exit_code == 1
        assert "Invalid configuration" in result.output

    def test_run_invalid_plugin_manifest_name(self, tmp_path: Path) -> None:
        """Run should let startup continue when a plugin manifest is malformed."""
        plugin_root = tmp_path / "plugins" / "bad-name"
        plugin_root.mkdir(parents=True)
        (plugin_root / "mindroom.plugin.json").write_text(
            json.dumps({"name": "BadName", "tools_module": None, "skills": []}),
            encoding="utf-8",
        )
        bad_cfg = tmp_path / "config.yaml"
        bad_cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n"
            "plugins:\n  - ./plugins/bad-name\n",
        )
        mock_main = AsyncMock()

        with patch("mindroom.orchestrator.main", mock_main):
            result = _invoke_with_runtime(["run"], bad_cfg)

        assert result.exit_code == 0
        mock_main.assert_awaited_once()
        assert "Invalid configuration" not in result.output

    def test_run_tolerates_missing_plugin_path(self, tmp_path: Path) -> None:
        """Run should let the orchestrator start when an optional plugin path is missing."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n"
            "plugins:\n  - ./plugins/this-plugin-does-not-exist\n",
            encoding="utf-8",
        )
        mock_main = AsyncMock()

        with patch("mindroom.orchestrator.main", mock_main):
            result = _invoke_with_runtime(["run"], cfg)

        assert result.exit_code == 0
        mock_main.assert_awaited_once()
        assert "Invalid configuration" not in result.output

    def test_run_invalid_utf8_config(self, tmp_path: Path) -> None:
        """Run should report unreadable config text without a traceback."""
        bad_cfg = tmp_path / "config.yaml"
        bad_cfg.write_bytes(b"\xff\xfe\x00\x00")

        result = _invoke_with_runtime(["run"], bad_cfg)

        assert result.exit_code == 1
        assert "Invalid configuration" in result.output
        assert "Could not read configuration text" in result.output

    def test_run_permanent_startup_error_prints_message_without_traceback(self, tmp_path: Path) -> None:
        """Permanent startup failures should not dump an implementation traceback."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: vertexai_claude\n    id: claude-sonnet-5\n"
            "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
            "router:\n  model: default\n",
            encoding="utf-8",
        )
        mock_main = AsyncMock(side_effect=PermanentStartupError("GOOGLE_APPLICATION_CREDENTIALS is invalid"))

        with patch("mindroom.orchestrator.main", mock_main):
            result = _invoke_with_runtime(["run"], cfg)

        assert result.exit_code == 1
        assert "GOOGLE_APPLICATION_CREDENTIALS is invalid" in result.output
        assert "Traceback" not in result.output

    def test_avatars_generate_reports_avatar_generation_failure(
        self,
        tmp_path: Path,
    ) -> None:
        """Avatar generation should exit cleanly when generation fails."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "matrix_space:\n  enabled: false\n"
            "authorization:\n  global_users: []\n",
        )

        with patch(
            "mindroom.avatar_generation.run_avatar_generation",
            AsyncMock(side_effect=AvatarGenerationError("Avatar generation failed. See errors above.")),
        ):
            result = _invoke_with_runtime(["avatars", "generate"], cfg)

        assert result.exit_code == 1
        assert "Avatar generation failed" in normalize_console_output(result.output)


# ---------------------------------------------------------------------------
# version & help
# ---------------------------------------------------------------------------


class TestVersionAndHelp:
    """Tests for version and help commands."""

    def test_version_command(self) -> None:
        """Version command prints the version string."""
        result = runner.invoke(app, ["version"])
        assert result.exit_code == 0
        assert "Mindroom version" in result.output

    def test_help_mentions_config(self) -> None:
        """Top-level help includes key subcommands."""
        result = runner.invoke(app, ["--help"])
        assert result.exit_code == 0
        assert "config" in result.output
        assert "avatars" in result.output
        assert "threads" in result.output

    def test_threads_export_help_shows_export_flags(self) -> None:
        """Thread export help should expose the one-shot and watch controls."""
        result = runner.invoke(app, ["threads", "export", "--help"])
        assert result.exit_code == 0
        output = normalize_console_output(result.output)
        assert "--watch" in output
        assert "--interval" in output
        assert "--room" in output
        assert "--output" in output

    def test_threads_export_runs_once_and_forwards_options(self, tmp_path: Path) -> None:
        """Thread export command should invoke the exporter with CLI options."""
        config_path = tmp_path / "config.yaml"
        storage_path = tmp_path / "storage"
        output_path = tmp_path / "exports"
        _write_minimal_runtime_config(config_path)

        with patch(
            "mindroom.thread_export.export_threads_once",
            new=AsyncMock(return_value=ThreadExportStats(output_dir=output_path)),
        ) as export_threads_once:
            result = _invoke_with_runtime(
                [
                    "threads",
                    "export",
                    "--output",
                    str(output_path),
                    "--room",
                    "lob",
                    "--interval",
                    "7",
                    "--max-thread-roots",
                    "11",
                ],
                config_path,
                storage_path=storage_path,
            )

        assert result.exit_code == 0
        export_threads_once.assert_awaited_once()
        export_kwargs = export_threads_once.await_args.kwargs
        assert export_kwargs["output_dir"] == output_path
        assert export_kwargs["room_filter"] == "lob"
        assert export_kwargs["max_thread_roots"] == 11
        assert export_kwargs["prefer_cache"] is False
        assert export_kwargs["include_invited_rooms"] is True
        assert export_kwargs["runtime_paths"].storage_root == storage_path.resolve()

    def test_threads_export_formats_target_and_thread_failures(self, tmp_path: Path) -> None:
        """Thread export output should render target failures without fake room placeholders."""
        config_path = tmp_path / "config.yaml"
        output_path = tmp_path / "exports"
        _write_minimal_runtime_config(config_path)
        room = ThreadExportRoom(
            key="lobby",
            room_id="!lobby:localhost",
            alias="#lobby:localhost",
            name="Lobby",
        )
        stats = ThreadExportStats(
            output_dir=output_path,
            failed_items=(
                failure_for_target("overlapping output directory"),
                failure_for_room(
                    room,
                    "history fetch failed",
                    thread_id="$thread:localhost",
                ),
            ),
        )

        with patch(
            "mindroom.thread_export.export_threads_once",
            new=AsyncMock(return_value=stats),
        ):
            result = _invoke_with_runtime(
                ["threads", "export", "--output", str(output_path)],
                config_path,
            )

        output = normalize_console_output(result.output)
        assert result.exit_code == 1
        assert "Failed target: overlapping output directory" in output
        assert "Failed: lobby $thread:localhost: history fetch failed" in output
        assert "None" not in output

    def test_threads_export_forwards_no_invited_rooms_flag(self, tmp_path: Path) -> None:
        """The --no-invited-rooms flag should reach the exporter."""
        config_path = tmp_path / "config.yaml"
        storage_path = tmp_path / "storage"
        _write_minimal_runtime_config(config_path)

        with patch(
            "mindroom.thread_export.export_threads_once",
            new=AsyncMock(return_value=ThreadExportStats(output_dir=tmp_path / "exports")),
        ) as export_threads_once:
            result = _invoke_with_runtime(
                ["threads", "export", "--no-invited-rooms"],
                config_path,
                storage_path=storage_path,
            )

        assert result.exit_code == 0
        assert export_threads_once.await_args.kwargs["include_invited_rooms"] is False

    def test_threads_export_forwards_prefer_cache_flag(self, tmp_path: Path) -> None:
        """The --prefer-cache flag should reach the exporter."""
        config_path = tmp_path / "config.yaml"
        storage_path = tmp_path / "storage"
        _write_minimal_runtime_config(config_path)

        with patch(
            "mindroom.thread_export.export_threads_once",
            new=AsyncMock(return_value=ThreadExportStats(output_dir=tmp_path / "exports")),
        ) as export_threads_once:
            result = _invoke_with_runtime(
                ["threads", "export", "--prefer-cache"],
                config_path,
                storage_path=storage_path,
            )

        assert result.exit_code == 0
        assert export_threads_once.await_args.kwargs["prefer_cache"] is True

    @pytest.mark.asyncio
    async def test_threads_export_watch_retries_runtime_errors(self, tmp_path: Path) -> None:
        """Watch mode should keep running after a transient exporter error."""
        config_path = tmp_path / "config.yaml"
        storage_path = tmp_path / "storage"
        output_path = tmp_path / "exports"
        _write_minimal_runtime_config(config_path)
        sleep_count = 0

        async def sleep_once_then_stop(seconds: int) -> None:
            nonlocal sleep_count
            assert seconds == 7
            sleep_count += 1
            if sleep_count == 2:
                raise typer.Exit(0)

        export_results = [
            RuntimeError("temporary"),
            ThreadExportStats(output_dir=output_path),
        ]
        with (
            patch(
                "mindroom.thread_export.export_threads_once",
                new=AsyncMock(side_effect=export_results),
            ) as export_once,
            patch("mindroom.cli.main.asyncio.sleep", new=sleep_once_then_stop),
            pytest.raises(typer.Exit) as exit_info,
        ):
            await _threads_export(
                config_path=config_path,
                storage_path=storage_path,
                output=output_path,
                room=None,
                watch=True,
                interval=7,
                max_thread_roots=11,
                prefer_cache=False,
                include_invited_rooms=True,
            )

        assert exit_info.value.exit_code == 0
        assert export_once.await_count == 2
        assert sleep_count == 2


# ---------------------------------------------------------------------------
# run command: API server flags
# ---------------------------------------------------------------------------


class TestRunApiFlags:
    """Tests for --api/--no-api, --api-port, --api-host flags."""

    @staticmethod
    def _write_minimal_config(path: Path) -> None:
        path.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "matrix_space:\n  enabled: false\n",
            encoding="utf-8",
        )

    def test_run_help_shows_api_flags(self) -> None:
        """Run --help lists the new API server flags."""
        result = runner.invoke(app, ["run", "--help"])
        assert result.exit_code == 0
        output = normalize_console_output(result.output)
        assert "--config" in output
        assert "-c" in output
        assert "--api" in output
        assert "--no-api" in output
        assert "--api-port" in output
        assert "--api-host" in output

    def test_run_passes_api_defaults(self, tmp_path: Path) -> None:
        """Run passes api=True, port=8765, host=0.0.0.0 by default."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "matrix_space:\n  enabled: false\n",
        )
        mock_main = AsyncMock()
        with patch("mindroom.orchestrator.main", mock_main):
            result = _invoke_with_runtime(["run"], cfg)
        assert result.exit_code == 0
        mock_main.assert_awaited_once()
        kwargs = mock_main.call_args
        assert kwargs.kwargs["api"] is True
        assert kwargs.kwargs["api_port"] == 8765
        assert kwargs.kwargs["api_host"] == "0.0.0.0"  # noqa: S104

    def test_run_no_api_flag(self, tmp_path: Path) -> None:
        """Run --no-api passes api=False to bot main."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "matrix_space:\n  enabled: false\n",
        )
        mock_main = AsyncMock()
        with patch("mindroom.orchestrator.main", mock_main):
            result = _invoke_with_runtime(["run", "--no-api"], cfg)
        assert result.exit_code == 0
        assert mock_main.call_args.kwargs["api"] is False

    def test_run_custom_port_and_host(self, tmp_path: Path) -> None:
        """Run --api-port and --api-host are forwarded to bot main."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "matrix_space:\n  enabled: false\n",
        )
        mock_main = AsyncMock()
        with patch("mindroom.orchestrator.main", mock_main):
            result = _invoke_with_runtime(["run", "--api-port", "9000", "--api-host", "127.0.0.1"], cfg)
        assert result.exit_code == 0
        assert mock_main.call_args.kwargs["api_port"] == 9000
        assert mock_main.call_args.kwargs["api_host"] == "127.0.0.1"

    def test_run_config_path_updates_runtime_paths(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Run should let an explicit config path own the implicit storage root."""
        ambient_config = tmp_path / "ambient" / "config.yaml"
        ambient_config.parent.mkdir()
        ambient_config.write_text("agents: {}\nmodels: {}\n", encoding="utf-8")
        config_dir = tmp_path / "selected"
        cfg = config_dir / "config.yaml"
        config_dir.mkdir()
        self._write_minimal_config(cfg)
        monkeypatch.setenv("MINDROOM_CONFIG_PATH", str(ambient_config))
        monkeypatch.delenv("MINDROOM_STORAGE_PATH", raising=False)

        async def _fake_main(**kwargs: object) -> None:
            runtime_paths = kwargs["runtime_paths"]
            assert isinstance(runtime_paths, constants_module.RuntimePaths)
            assert runtime_paths.config_path == cfg.resolve()
            assert runtime_paths.storage_root == config_dir.resolve() / "mindroom_data"
            assert runtime_paths.process_env["MINDROOM_CONFIG_PATH"] == str(cfg.resolve())
            assert runtime_paths.process_env["MINDROOM_STORAGE_PATH"] == str(
                config_dir.resolve() / "mindroom_data",
            )

        with patch("mindroom.orchestrator.main", AsyncMock(side_effect=_fake_main)) as mock_main:
            result = runner.invoke(app, ["run", "--config", str(cfg)])

        assert result.exit_code == 0
        mock_main.assert_awaited_once()

    def test_run_config_short_flag_updates_runtime_paths(
        self,
        tmp_path: Path,
    ) -> None:
        """Run should expose `-c` as the short form for explicit config selection."""
        config_dir = tmp_path / "selected"
        cfg = config_dir / "config.yaml"
        config_dir.mkdir()
        self._write_minimal_config(cfg)

        async def _fake_main(**kwargs: object) -> None:
            runtime_paths = kwargs["runtime_paths"]
            assert isinstance(runtime_paths, constants_module.RuntimePaths)
            assert runtime_paths.config_path == cfg.resolve()
            assert runtime_paths.storage_root == config_dir.resolve() / "mindroom_data"

        with patch("mindroom.orchestrator.main", AsyncMock(side_effect=_fake_main)) as mock_main:
            result = runner.invoke(app, ["run", "-c", str(cfg)])

        assert result.exit_code == 0
        mock_main.assert_awaited_once()

    def test_run_storage_path_updates_runtime_paths(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Run should thread `--storage-path` through the explicit runtime context."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "matrix_space:\n  enabled: false\n",
        )
        runtime_storage = tmp_path / "runtime-storage"
        monkeypatch.delenv("MINDROOM_STORAGE_PATH", raising=False)

        async def _fake_main(**kwargs: object) -> None:
            runtime_paths = kwargs["runtime_paths"]
            assert isinstance(runtime_paths, constants_module.RuntimePaths)
            assert runtime_paths.storage_root == runtime_storage.resolve()
            assert runtime_paths.storage_root == runtime_storage.resolve()
            assert constants_module.tracking_dir(runtime_paths) == runtime_storage.resolve() / "tracking"
            assert constants_module.matrix_state_file(runtime_paths) == runtime_storage.resolve() / "matrix_state.yaml"
            assert (
                HandledTurnLedger("agent", base_path=runtime_storage.resolve() / "tracking").base_path
                == runtime_storage.resolve() / "tracking"
            )
            MatrixState().save(runtime_paths=runtime_paths)
            assert (runtime_storage.resolve() / "matrix_state.yaml").exists()

        with patch("mindroom.orchestrator.main", AsyncMock(side_effect=_fake_main)) as mock_main:
            result = _invoke_with_runtime(["run", "--storage-path", str(runtime_storage)], cfg)

        assert result.exit_code == 0
        mock_main.assert_awaited_once()

    def test_run_short_config_path_keeps_storage_path_override(self, tmp_path: Path) -> None:
        """Run should allow `-c` with an explicit storage override."""
        config_dir = tmp_path / "profile"
        config_dir.mkdir()
        cfg = config_dir / "config.yaml"
        self._write_minimal_config(cfg)
        storage_root = tmp_path / "explicit-storage"

        async def _fake_main(**kwargs: object) -> None:
            runtime_paths = kwargs["runtime_paths"]
            assert isinstance(runtime_paths, constants_module.RuntimePaths)
            assert runtime_paths.config_path == cfg.resolve()
            assert runtime_paths.storage_root == storage_root.resolve()

        with patch("mindroom.orchestrator.main", AsyncMock(side_effect=_fake_main)) as mock_main:
            result = runner.invoke(app, ["run", "-c", str(cfg), "--storage-path", str(storage_root)])

        assert result.exit_code == 0
        mock_main.assert_awaited_once()


class TestAvatarsCommands:
    """Tests for `mindroom avatars`."""

    def test_avatars_help_lists_subcommands(self) -> None:
        """The avatars command should expose generate and sync subcommands."""
        result = runner.invoke(app, ["avatars", "--help"])
        assert result.exit_code == 0
        output = normalize_console_output(result.output)
        assert "generate" in output
        assert "sync" in output

    def test_avatars_generate_runs_generation(self, tmp_path: Path) -> None:
        """Avatar generation command should invoke the generation workflow."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "matrix_space:\n  enabled: false\n",
        )
        run_avatar_generation = AsyncMock()
        with patch("mindroom.avatar_generation.run_avatar_generation", run_avatar_generation):
            result = _invoke_with_runtime(["avatars", "generate"], cfg)
        assert result.exit_code == 0
        run_avatar_generation.assert_awaited_once()
        assert run_avatar_generation.await_args.args[0].config_path == cfg.resolve()

    def test_avatars_generate_force_passes_force_true(self, tmp_path: Path) -> None:
        """Avatar generation command should expose an explicit force flag."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "matrix_space:\n  enabled: false\n",
        )
        run_avatar_generation = AsyncMock()

        with patch("mindroom.avatar_generation.run_avatar_generation", run_avatar_generation):
            result = _invoke_with_runtime(["avatars", "generate", "--force"], cfg)

        assert result.exit_code == 0
        run_avatar_generation.assert_awaited_once()
        assert run_avatar_generation.await_args.kwargs["force"] is True

    def test_avatars_sync_runs_matrix_sync(self, tmp_path: Path) -> None:
        """Avatar sync command should invoke the Matrix sync workflow."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "matrix_space:\n  enabled: false\n",
        )
        set_room_avatars_in_matrix = AsyncMock()

        with patch("mindroom.avatar_generation.set_room_avatars_in_matrix", set_room_avatars_in_matrix):
            result = _invoke_with_runtime(["avatars", "sync"], cfg)

        assert result.exit_code == 0
        set_room_avatars_in_matrix.assert_awaited_once()
        assert set_room_avatars_in_matrix.await_args.args[0].config_path == cfg.resolve()

    def test_avatars_sync_force_passes_force_true(self, tmp_path: Path) -> None:
        """Avatar sync command should expose an explicit force flag."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "matrix_space:\n  enabled: false\n",
        )
        set_room_avatars_in_matrix = AsyncMock()

        with patch("mindroom.avatar_generation.set_room_avatars_in_matrix", set_room_avatars_in_matrix):
            result = _invoke_with_runtime(["avatars", "sync", "--force"], cfg)

        assert result.exit_code == 0
        set_room_avatars_in_matrix.assert_awaited_once()
        assert set_room_avatars_in_matrix.await_args.kwargs["force"] is True

    def test_avatars_sync_reports_sync_failure(self, tmp_path: Path) -> None:
        """Unexpected avatar sync failures should propagate for debugging."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "matrix_space:\n  enabled: false\n",
        )
        with patch(
            "mindroom.avatar_generation.set_room_avatars_in_matrix",
            AsyncMock(side_effect=RuntimeError("boom")),
        ):
            result = _invoke_with_runtime(["avatars", "sync"], cfg)

        assert result.exit_code == 1
        assert isinstance(result.exception, RuntimeError)
        assert str(result.exception) == "boom"

    def test_avatars_sync_requires_initialized_router_account(
        self,
        tmp_path: Path,
    ) -> None:
        """Avatar sync should fail when the router account has not been initialized yet."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "matrix_space:\n  enabled: false\n",
        )
        with patch(
            "mindroom.avatar_generation.set_room_avatars_in_matrix",
            AsyncMock(side_effect=AvatarSyncError("No router account found in Matrix state.")),
        ):
            result = _invoke_with_runtime(["avatars", "sync"], cfg)

        assert result.exit_code == 1
        assert "No router account found in Matrix state." in normalize_console_output(result.output)


# ---------------------------------------------------------------------------
# mindroom doctor
# ---------------------------------------------------------------------------

_VALID_CONFIG = (
    "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
    "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
    "router:\n  model: default\n"
)
_VALID_VERTEXAI_CLAUDE_CONFIG = (
    "models:\n  default:\n    provider: vertexai_claude\n    id: claude-sonnet-5\n"
    "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
    "router:\n  model: default\n"
)
_VALID_MULTI_VERTEXAI_CLAUDE_CONFIG = (
    "models:\n"
    "  default:\n    provider: vertexai_claude\n    id: claude-sonnet-5\n"
    "  fast:\n    provider: vertexai_claude\n    id: claude-haiku-4-5\n"
    "agents:\n  assistant:\n    display_name: Assistant\n    model: default\n"
    "router:\n  model: default\n"
)


def _patch_homeserver_ok(monkeypatch: pytest.MonkeyPatch) -> None:
    """Patch httpx.get to simulate a reachable homeserver."""
    resp = httpx.Response(200, json={"versions": ["v1.1"]})
    monkeypatch.setattr(
        "mindroom.cli.doctor.constants.runtime_matrix_homeserver",
        lambda *_args, **_kwargs: "http://localhost:8008",
    )
    monkeypatch.setattr("mindroom.cli.doctor.httpx.get", lambda *_a, **_kw: resp)


def _patch_homeserver_fail(monkeypatch: pytest.MonkeyPatch) -> None:
    """Patch httpx.get to simulate an unreachable homeserver."""
    monkeypatch.setattr(
        "mindroom.cli.doctor.constants.runtime_matrix_homeserver",
        lambda *_args, **_kwargs: "http://localhost:8008",
    )

    def _raise(*_a: object, **_kw: object) -> None:
        msg = "Connection refused"
        raise httpx.ConnectError(msg)

    monkeypatch.setattr("mindroom.cli.doctor.httpx.get", _raise)


class TestDoctor:
    """Tests for `mindroom doctor`."""

    @pytest.fixture(autouse=True)
    def _healthy_embedder_probe(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Keep the real-embedding probe off the network; specific tests override it."""
        monkeypatch.setattr("mindroom.cli.doctor.probe_embedder", lambda *_args: None)

    def test_all_checks_pass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Doctor reports all green when everything is fine."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_CONFIG)
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")  # for default memory embedder
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "✓" in result.output
        assert "✗" not in result.output
        assert "7 passed" in result.output
        assert "0 failed" in result.output
        assert "1 warning" in result.output  # memory LLM not configured
        assert "Providers:" in result.output
        assert "anthropic (1 model)" in result.output
        assert "API key valid" in result.output

    def test_warns_when_encryption_store_missing(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor warns when a persisted device has no encryption store on disk."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_CONFIG)
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        runtime_paths = constants_module.resolve_runtime_paths(config_path=cfg, storage_path=storage)
        MatrixState(
            accounts={
                "agent_general": MatrixAccount(
                    username="mindroom_general",
                    password="pw",  # noqa: S106
                    domain="localhost",
                    device_id="LOSTDEVICE",
                ),
            },
        ).save(runtime_paths=runtime_paths)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)

        assert result.exit_code == 0
        assert "Encryption store missing for agent_general" in result.output
        assert "1 warning" not in result.output  # memory warning plus this one
        assert "2 warnings" in result.output

    def test_doctor_reports_disabled_memory_backend(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor should distinguish disabled memory from file-backed memory."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(f"{_VALID_CONFIG}memory: none\n")
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)

        assert result.exit_code == 0
        assert "Memory backend: disabled" in result.output
        assert "Memory backend: file" not in result.output

    def test_doctor_uses_active_config_sibling_env_from_exported_config_path(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor should honor active-config sibling env keys without pre-activating runtime globals."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_CONFIG)
        (tmp_path / ".env").write_text(
            "ANTHROPIC_API_KEY=sk-test\nOPENAI_API_KEY=sk-test\n",
            encoding="utf-8",
        )
        _patch_homeserver_ok(monkeypatch)

        result = runner.invoke(app, ["doctor"], env={"MINDROOM_CONFIG_PATH": str(cfg)})

        assert result.exit_code == 0
        assert "ANTHROPIC_API_KEY not set" not in result.output
        assert "OPENAI_API_KEY not set" not in result.output

    def test_uses_status_for_steps(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Doctor wraps checks in status contexts for interactive progress feedback."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_CONFIG)
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        status_messages: list[str] = []

        class _DummyStatus:
            def __enter__(self) -> None:
                return None

            def __exit__(self, *_args: object) -> bool:
                return False

        def _status(message: str, **_kwargs: object) -> _DummyStatus:
            status_messages.append(message)
            return _DummyStatus()

        monkeypatch.setattr("mindroom.cli.doctor.console.status", _status)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert len(status_messages) == 7
        assert any("Matrix homeserver" in msg for msg in status_messages)
        assert any("memory config" in msg for msg in status_messages)
        assert any("encryption stores" in msg for msg in status_messages)

    def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Doctor reports failure when config file is missing."""
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], tmp_path / "missing.yaml", storage_path=tmp_path / "storage")
        assert result.exit_code == 1
        assert "Config file not found" in result.output

    def test_invalid_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Doctor reports failure when config is invalid YAML/schema."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("agents: not_a_dict\n")
        storage = tmp_path / "storage"
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 1
        assert "Config invalid" in result.output

    def test_invalid_utf8_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Doctor reports unreadable config text as a config failure."""
        cfg = tmp_path / "config.yaml"
        cfg.write_bytes(b"\xff\xfe\x00\x00")
        storage = tmp_path / "storage"
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)

        assert result.exit_code == 1
        assert "Config invalid" in result.output
        assert "Could not read configuration text" in result.output

    def test_missing_api_key_is_warning(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Doctor warns (not fails) on missing API keys."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_CONFIG)
        storage = tmp_path / "storage"
        monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "ANTHROPIC_API_KEY not set" in result.output
        assert "2 warnings" in result.output

    def test_homeserver_unreachable(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Doctor reports failure when Matrix homeserver is unreachable."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_CONFIG)
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        _patch_homeserver_fail(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 1
        assert "Matrix homeserver unreachable" in result.output

    def test_storage_not_writable(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Doctor reports failure when storage directory is not writable."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_CONFIG)
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=Path("/proc/fake_mindroom_storage"))
        assert result.exit_code == 1
        assert "Storage not writable" in result.output

    def test_skips_config_checks_when_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Doctor skips config-validation and provider checks when config is missing."""
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], tmp_path / "missing.yaml", storage_path=tmp_path / "storage")
        assert "Config valid" not in result.output
        assert "Providers:" not in result.output
        assert "API key" not in result.output

    def test_invalid_api_key_is_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Doctor reports failure when an API key is rejected by the provider."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_CONFIG)
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-invalid")
        monkeypatch.setattr(
            "mindroom.cli.doctor.constants.runtime_matrix_homeserver",
            lambda *_args, **_kwargs: "http://localhost:8008",
        )

        def _mock_get(url: str, **_kw: object) -> httpx.Response:
            if "/_matrix/" in str(url):
                return httpx.Response(200, json={"versions": ["v1.1"]})
            return httpx.Response(401, json={"error": "invalid"})

        monkeypatch.setattr("mindroom.cli.doctor.httpx.get", _mock_get)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 1
        assert "API key invalid" in result.output

    def test_provider_summary_multiple_providers(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Doctor shows provider summary with correct model counts."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n"
            "  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "  fast:\n    provider: anthropic\n    id: claude-haiku-4-5\n"
            "  gpt:\n    provider: openai\n    id: gpt-4o\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n",
        )
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "anthropic (2 models)" in result.output
        assert "openai (1 model)" in result.output

    def test_vertexai_claude_connection_check_passes(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor validates Vertex AI Claude with a tiny messages smoke test."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_VERTEXAI_CLAUDE_CONFIG)
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_VERTEX_PROJECT_ID", "mindroom-test")
        monkeypatch.setenv("CLOUD_ML_REGION", "us-central1")
        monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        called: dict[str, object] = {}

        class _FakeMessages:
            def create(self, **kwargs: object) -> SimpleNamespace:
                called["kwargs"] = kwargs
                return SimpleNamespace(content=[{"type": "text", "text": "OK"}])

        class _FakeVertexModel:
            def __init__(self, **kwargs: object) -> None:
                called["model_id"] = kwargs["id"]
                called["client_kwargs"] = kwargs
                self.id = str(kwargs["id"])

            def get_request_params(self) -> dict[str, object]:
                return {"timeout": called["client_kwargs"]["timeout"]}

            def get_client(self) -> SimpleNamespace:
                return SimpleNamespace(messages=_FakeMessages())

        monkeypatch.setattr(vertexai_claude_module, "Claude", _FakeVertexModel)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "vertexai_claude connection valid for claude-sonnet-5" in result.output
        assert called["model_id"] == "claude-sonnet-5"
        assert called["client_kwargs"] == {
            "id": "claude-sonnet-5",
            "project_id": "mindroom-test",
            "region": "us-central1",
            "timeout": 10,
        }
        assert called["kwargs"] == {
            "model": "claude-sonnet-5",
            "max_tokens": 1,
            "messages": [{"role": "user", "content": "Reply with OK."}],
            "timeout": 10,
        }

    def test_vertexai_claude_connection_uses_runtime_adc_credentials(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor should pass resolved runtime ADC credentials to the Vertex client."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_VERTEXAI_CLAUDE_CONFIG)
        (tmp_path / ".env").write_text("GOOGLE_APPLICATION_CREDENTIALS=adc.json\n", encoding="utf-8")
        storage = tmp_path / "storage"
        fake_credentials = object()
        monkeypatch.setenv("ANTHROPIC_VERTEX_PROJECT_ID", "mindroom-test")
        monkeypatch.setenv("CLOUD_ML_REGION", "us-central1")
        monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        called: dict[str, object] = {}

        def _load_adc(path: str) -> object:
            called["adc_path"] = path
            return fake_credentials

        class _FakeMessages:
            def create(self, **kwargs: object) -> SimpleNamespace:
                called["kwargs"] = kwargs
                return SimpleNamespace(content=[{"type": "text", "text": "OK"}])

        class _FakeVertexModel:
            def __init__(self, **kwargs: object) -> None:
                called["client_kwargs"] = kwargs
                self.id = str(kwargs["id"])

            def get_request_params(self) -> dict[str, object]:
                return {"timeout": called["client_kwargs"]["timeout"]}

            def get_client(self) -> SimpleNamespace:
                return SimpleNamespace(messages=_FakeMessages())

        monkeypatch.setattr(google_adc_module, "load_google_application_credentials", _load_adc)
        monkeypatch.setattr(vertexai_claude_module, "Claude", _FakeVertexModel)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)

        assert result.exit_code == 0
        assert called["adc_path"] == str((tmp_path / "adc.json").resolve())
        assert called["client_kwargs"] == {
            "id": "claude-sonnet-5",
            "project_id": "mindroom-test",
            "region": "us-central1",
            "timeout": 10,
            "client_params": {"credentials": fake_credentials},
        }

    def test_vertexai_claude_connection_checks_each_configured_model(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor validates every configured Vertex AI Claude model by its actual ID."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_MULTI_VERTEXAI_CLAUDE_CONFIG)
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_VERTEX_PROJECT_ID", "mindroom-test")
        monkeypatch.setenv("CLOUD_ML_REGION", "us-central1")
        monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        created_models: list[str] = []
        requested_models: list[str] = []

        class _FakeMessages:
            def create(self, **kwargs: object) -> SimpleNamespace:
                requested_models.append(str(kwargs["model"]))
                return SimpleNamespace(content=[{"type": "text", "text": "OK"}])

        class _FakeVertexModel:
            def __init__(self, **kwargs: object) -> None:
                created_models.append(str(kwargs["id"]))

            def get_request_params(self) -> dict[str, object]:
                return {"timeout": 10}

            def get_client(self) -> SimpleNamespace:
                return SimpleNamespace(messages=_FakeMessages())

        monkeypatch.setattr(vertexai_claude_module, "Claude", _FakeVertexModel)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "vertexai_claude connection valid for claude-sonnet-5" in result.output
        assert "vertexai_claude connection valid for claude-haiku-4-5" in result.output
        assert created_models == ["claude-sonnet-5", "claude-haiku-4-5"]
        assert requested_models == ["claude-sonnet-5", "claude-haiku-4-5"]

    def test_vertexai_claude_missing_env_is_warning(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor warns when Vertex AI Claude is configured without required env vars."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_VERTEXAI_CLAUDE_CONFIG)
        storage = tmp_path / "storage"
        monkeypatch.delenv("ANTHROPIC_VERTEX_PROJECT_ID", raising=False)
        monkeypatch.delenv("ANTHROPIC_VERTEX_PROJECT_ID_FILE", raising=False)
        monkeypatch.delenv("CLOUD_ML_REGION", raising=False)
        monkeypatch.delenv("CLOUD_ML_REGION_FILE", raising=False)
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "vertexai_claude: could not validate connection" in result.output
        assert "ANTHROPIC_VERTEX_PROJECT_ID" in result.output
        assert "CLOUD_ML_REGION" in result.output

    def test_vertexai_claude_missing_adc_is_warning(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor warns when Vertex AI Claude cannot load Google credentials."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_VERTEXAI_CLAUDE_CONFIG)
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_VERTEX_PROJECT_ID", "mindroom-test")
        monkeypatch.setenv("CLOUD_ML_REGION", "us-central1")
        monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        class _MissingCredentialsMessages:
            def create(self, **_kwargs: object) -> None:
                message = "ADC unavailable"
                raise DefaultCredentialsError(message)

        class _MissingCredentialsModel:
            def __init__(self, **kwargs: object) -> None:
                self.id = str(kwargs["id"])

            def get_request_params(self) -> dict[str, object]:
                return {"timeout": 10}

            def get_client(self) -> SimpleNamespace:
                return SimpleNamespace(messages=_MissingCredentialsMessages())

        monkeypatch.setattr(vertexai_claude_module, "Claude", _MissingCredentialsModel)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "vertexai_claude: could not validate connection" in result.output
        assert "ADC" in result.output
        assert "unavailable" in result.output

    def test_vertexai_claude_missing_adc_file_is_failure(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor fails when an explicit Vertex AI ADC file path is invalid."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_VERTEXAI_CLAUDE_CONFIG)
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_VERTEX_PROJECT_ID", "mindroom-test")
        monkeypatch.setenv("CLOUD_ML_REGION", "us-central1")
        monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", str(tmp_path / "missing-adc.json"))
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)

        assert result.exit_code == 1
        output = normalize_console_output(result.output)
        assert "vertexai_claude connection failed for claude-sonnet-5" in output
        assert "GOOGLE_APPLICATION_CREDENTIALS points to a file that does not exist" in output

    def test_vertexai_claude_invalid_adc_file_is_failure(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor fails when an explicit Vertex AI ADC file cannot be loaded."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_VERTEXAI_CLAUDE_CONFIG)
        storage = tmp_path / "storage"
        adc_path = tmp_path / "invalid-adc.json"
        adc_path.write_text('{"type":"authorized_user"}\n', encoding="utf-8")
        monkeypatch.setenv("ANTHROPIC_VERTEX_PROJECT_ID", "mindroom-test")
        monkeypatch.setenv("CLOUD_ML_REGION", "us-central1")
        monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", str(adc_path))
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)

        assert result.exit_code == 1
        output = normalize_console_output(result.output)
        assert "vertexai_claude connection failed for claude-sonnet-5" in output
        assert "Failed to load GOOGLE_APPLICATION_CREDENTIALS" in output

    def test_vertexai_claude_api_rejection_is_failure(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor fails when Vertex AI rejects the configured Claude request."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(_VALID_VERTEXAI_CLAUDE_CONFIG)
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_VERTEX_PROJECT_ID", "mindroom-test")
        monkeypatch.setenv("CLOUD_ML_REGION", "us-central1")
        monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        class _RejectedMessages:
            def create(self, **_kwargs: object) -> None:
                response = httpx.Response(403, request=httpx.Request("POST", "https://vertex.test"))
                message = "denied"
                raise PermissionDeniedError(message, response=response, body={"error": "denied"})

        class _RejectedModel:
            def __init__(self, **kwargs: object) -> None:
                self.id = str(kwargs["id"])

            def get_request_params(self) -> dict[str, object]:
                return {"timeout": 10}

            def get_client(self) -> SimpleNamespace:
                return SimpleNamespace(messages=_RejectedMessages())

        monkeypatch.setattr(vertexai_claude_module, "Claude", _RejectedModel)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 1
        assert "vertexai_claude connection failed for claude-sonnet-5" in result.output
        assert "HTTP 403" in result.output

    def test_custom_base_url_validation(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Doctor validates against custom base_url when configured."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n"
            "  default:\n"
            "    provider: openai\n"
            "    id: local-model\n"
            "    extra_kwargs:\n"
            "      base_url: http://localhost:9292/v1\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n",
        )
        storage = tmp_path / "storage"
        monkeypatch.setenv("OPENAI_API_KEY", "sk-local")
        monkeypatch.setattr(
            "mindroom.cli.doctor.constants.runtime_matrix_homeserver",
            lambda *_args, **_kwargs: "http://localhost:8008",
        )

        called_urls: list[str] = []

        def _mock_get(url: str, **_kw: object) -> httpx.Response:
            called_urls.append(str(url))
            if "/_matrix/client/versions" in str(url):
                return httpx.Response(200, json={"versions": ["v1.1"]})
            return httpx.Response(200, json={})

        monkeypatch.setattr("mindroom.cli.doctor.httpx.get", _mock_get)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "API key valid" in result.output
        # Should validate against the custom base_url, not api.openai.com
        assert any("localhost:9292" in u for u in called_urls)

    def test_memory_ollama_embedder_checks_reachability(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor checks ollama embedder reachability via /api/tags."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "memory:\n"
            "  embedder:\n"
            "    provider: ollama\n"
            "    config:\n"
            "      model: nomic-embed-text\n"
            "      host: http://localhost:11434\n",
        )
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "Memory embedder: ollama reachable" in result.output

    def test_memory_configured_llm_validates_key(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor validates configured memory LLM API key."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "memory:\n"
            "  llm:\n"
            "    provider: openai\n"
            "    config:\n"
            "      model: gpt-4o-mini\n",
        )
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "Memory LLM: openai/gpt-4o-mini API key valid" in result.output
        assert "Memory embedder:" in result.output

    def test_memory_llm_missing_key_is_warning(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor warns when memory LLM API key is not set."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "memory:\n"
            "  llm:\n"
            "    provider: openai\n"
            "    config:\n"
            "      model: gpt-4o-mini\n",
        )
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
        _patch_homeserver_ok(monkeypatch)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "Memory LLM (openai): OPENAI_API_KEY not set" in result.output

    def test_memory_llm_openai_base_url_used_when_host_absent(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor uses openai_base_url from mem0 LLM config when host is absent."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "memory:\n"
            "  llm:\n"
            "    provider: openai\n"
            "    config:\n"
            "      model: gpt-oss-low\n"
            "      openai_base_url: http://localllm:9292/v1\n",
        )
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        called_urls: list[str] = []

        def _mock_check(url: str, _headers: dict[str, str] | None = None) -> tuple[bool, str]:
            called_urls.append(url)
            return True, ""

        monkeypatch.setattr("mindroom.cli.doctor._http_check", _mock_check)
        monkeypatch.setattr(
            "mindroom.cli.doctor._validate_provider_key",
            lambda _prov, _key, base_url=None: called_urls.append(base_url or "NO_BASE_URL") or (True, ""),
        )

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "http://localllm:9292/v1" in called_urls, (
            f"Expected openai_base_url to be passed as base_url, got: {called_urls}"
        )

    def test_memory_openai_embedder_host_runs_embeddings_smoke_test(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor validates custom OpenAI embedder hosts with one probe round-trip."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "memory:\n"
            "  embedder:\n"
            "    provider: openai\n"
            "    config:\n"
            "      model: embeddinggemma:300m\n"
            "      host: http://llama.local/v1\n",
        )
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        probed_hosts: list[str | None] = []

        def _mock_probe(config: Config, _runtime_paths: object) -> None:
            probed_hosts.append(config.memory.embedder.config.host)

        monkeypatch.setattr("mindroom.cli.doctor.probe_embedder", _mock_probe)

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "embedding round-trip succeeded" in result.output
        assert probed_hosts == ["http://llama.local/v1"]

    def test_memory_openai_embedder_local_host_error_has_hint(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor adds a local-network hint for .local routing failures."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "memory:\n"
            "  embedder:\n"
            "    provider: openai\n"
            "    config:\n"
            "      model: embeddinggemma:300m\n"
            "      host: http://llama.local/v1\n",
        )
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)
        monkeypatch.setattr(
            "mindroom.cli.doctor.probe_embedder",
            lambda *_args: "embedder endpoint unreachable",
        )

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        output = normalize_console_output(result.output)
        assert "could not reach embeddings" in output
        assert "endpoint (http://llama.local/v1)" in output
        assert "reachable LAN" in output
        assert "instead of .local" in output

    def test_memory_sentence_transformers_embedder_runs_local_smoke_test(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Doctor validates sentence-transformers embedders by loading the local model."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models:\n  default:\n    provider: anthropic\n    id: claude-sonnet-5\n"
            "agents:\n  a:\n    display_name: A\n    model: default\n"
            "router:\n  model: default\n"
            "memory:\n"
            "  embedder:\n"
            "    provider: sentence_transformers\n"
            "    config:\n"
            "      model: sentence-transformers/all-MiniLM-L6-v2\n",
        )
        storage = tmp_path / "storage"
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
        _patch_homeserver_ok(monkeypatch)

        class _FakeEmbedder:
            def get_embedding(self, text: str) -> list[float]:
                assert text == "mindroom doctor embedder check"
                return [0.1, 0.2, 0.3]

        monkeypatch.setattr(
            "mindroom.cli.doctor.create_sentence_transformers_embedder",
            lambda _runtime_paths, _model: _FakeEmbedder(),
        )

        result = _invoke_with_runtime(["doctor"], cfg, storage_path=storage)
        assert result.exit_code == 0
        assert "sentence_transformers/sentence-transformers/all-MiniLM-L6-v2" in result.output
        assert "local model loaded" in result.output


# ---------------------------------------------------------------------------
# mindroom connect
# ---------------------------------------------------------------------------


class TestConnect:
    """Tests for `mindroom connect` pairing command."""

    def test_connect_persists_local_provisioning_credentials(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Successful pairing should write provisioning credentials to .env."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models: {}\nagents: {}\nrouter:\n  model: default\n"
            "matrix_room_access:\n"
            "  room_admins:\n"
            f"    - {OWNER_MATRIX_USER_ID_PLACEHOLDER}\n"
            "authorization:\n"
            "  default_room_access: false\n"
            "  global_users:\n"
            f"    - {OWNER_MATRIX_USER_ID_PLACEHOLDER}\n"
            "  agent_reply_permissions:\n"
            '    "*":\n'
            f"      - {OWNER_MATRIX_USER_ID_PLACEHOLDER}\n",
        )
        monkeypatch.setattr("mindroom.cli.main.socket.gethostname", lambda: "devbox")

        monkeypatch.setattr(
            "mindroom.cli.main._httpx_post",
            lambda *_a, **_kw: httpx.Response(
                200,
                json={
                    "client_id": "client-123",
                    "client_secret": "secret-123",
                    "namespace": "a1b2c3d4",
                    "owner_user_id": "@alice:mindroom.chat",
                    "connection": {
                        "id": "conn-1",
                        "client_name": "devbox",
                        "fingerprint": "sha256:abc",
                        "created_at": "2026-02-27T12:00:00Z",
                        "last_seen_at": "2026-02-27T12:00:00Z",
                        "revoked_at": None,
                    },
                },
            ),
        )

        result = _invoke_with_runtime(
            ["connect", "--pair-code", "ABCD-EFGH", "--provisioning-url", "https://provisioning.example"],
            cfg,
        )

        assert result.exit_code == 0
        assert "Paired successfully" in result.output
        env_content = (tmp_path / ".env").read_text()
        assert "MINDROOM_PROVISIONING_URL=https://provisioning.example" in env_content
        assert "MINDROOM_LOCAL_CLIENT_ID=client-123" in env_content
        assert "MINDROOM_LOCAL_CLIENT_SECRET=secret-123" in env_content
        assert "MINDROOM_NAMESPACE=a1b2c3d4" in env_content
        assert f"{OWNER_MATRIX_USER_ID_ENV}=@alice:mindroom.chat" in env_content
        updated_config = cfg.read_text()
        assert OWNER_MATRIX_USER_ID_PLACEHOLDER not in updated_config
        assert "@alice:mindroom.chat" in updated_config
        parsed_config = yaml.safe_load(updated_config)
        assert parsed_config["matrix_room_access"]["room_admins"] == ["@alice:mindroom.chat"]

    def test_connect_path_overrides_env_and_config_target(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """--path should control where .env is written and which config gets placeholder updates."""
        default_cfg = tmp_path / "default" / "config.yaml"
        default_cfg.parent.mkdir(parents=True, exist_ok=True)
        default_cfg.write_text(
            "models: {}\nagents: {}\nrouter:\n  model: default\n"
            "authorization:\n"
            "  default_room_access: false\n"
            "  global_users:\n"
            f"    - {OWNER_MATRIX_USER_ID_PLACEHOLDER}\n",
        )
        target_cfg = tmp_path / "custom" / "config.yaml"
        target_cfg.parent.mkdir(parents=True, exist_ok=True)
        target_cfg.write_text(
            "models: {}\nagents: {}\nrouter:\n  model: default\n"
            "authorization:\n"
            "  default_room_access: false\n"
            "  global_users:\n"
            f"    - {OWNER_MATRIX_USER_ID_PLACEHOLDER}\n",
        )
        monkeypatch.setattr(
            "mindroom.cli.main._httpx_post",
            lambda *_a, **_kw: httpx.Response(
                200,
                json={
                    "client_id": "client-123",
                    "client_secret": "secret-123",
                    "namespace": "a1b2c3d4",
                    "owner_user_id": "@alice:mindroom.chat",
                },
            ),
        )

        result = _invoke_with_runtime(
            [
                "connect",
                "--pair-code",
                "ABCD-EFGH",
                "--provisioning-url",
                "https://provisioning.example",
                "--path",
                str(target_cfg),
            ],
            default_cfg,
        )

        assert result.exit_code == 0
        assert (target_cfg.parent / ".env").exists()
        assert not (default_cfg.parent / ".env").exists()
        updated_target_config = target_cfg.read_text()
        unchanged_default_config = default_cfg.read_text()
        assert OWNER_MATRIX_USER_ID_PLACEHOLDER not in updated_target_config
        assert "@alice:mindroom.chat" in updated_target_config
        assert OWNER_MATRIX_USER_ID_PLACEHOLDER in unchanged_default_config

    def test_connect_no_persist_prints_exports(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """--no-persist-env should print export commands and avoid writing .env."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("agents: {}\nmodels: {}\nrouter:\n  model: default\n")
        monkeypatch.setattr(
            "mindroom.cli.main._httpx_post",
            lambda *_a, **_kw: httpx.Response(
                200,
                json={
                    "client_id": "client-123",
                    "client_secret": "secret-123",
                    "namespace": "a1b2c3d4",
                    "owner_user_id": "@alice:mindroom.chat",
                },
            ),
        )

        result = _invoke_with_runtime(
            [
                "connect",
                "--pair-code",
                "ABCD-EFGH",
                "--provisioning-url",
                "https://provisioning.example",
                "--no-persist-env",
            ],
            cfg,
        )

        assert result.exit_code == 0
        assert "export MINDROOM_PROVISIONING_URL=https://provisioning.example" in result.output
        assert "export MINDROOM_LOCAL_CLIENT_ID=client-123" in result.output
        assert "export MINDROOM_LOCAL_CLIENT_SECRET=secret-123" in result.output
        assert "export MINDROOM_NAMESPACE=a1b2c3d4" in result.output
        assert "export MINDROOM_OWNER_USER_ID=@alice:mindroom.chat" in result.output
        assert "Owner user ID from pairing: @alice:mindroom.chat" in result.output
        assert not (tmp_path / ".env").exists()

    def test_connect_uses_runtime_env_default_provisioning_url(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Provisioning URL default should be read at command runtime."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("agents: {}\nmodels: {}\nrouter:\n  model: default\n")
        monkeypatch.setenv("MINDROOM_PROVISIONING_URL", "https://env-provisioning.example")

        called: dict[str, object] = {}

        def _fake_post(url: str, **kwargs: object) -> httpx.Response:
            called["url"] = url
            called["kwargs"] = kwargs
            return httpx.Response(
                200,
                json={
                    "client_id": "client-123",
                    "client_secret": "secret-123",
                    "namespace": "a1b2c3d4",
                    "owner_user_id": "@alice:mindroom.chat",
                },
            )

        monkeypatch.setattr("mindroom.cli.main._httpx_post", _fake_post)

        result = _invoke_with_runtime(["connect", "--pair-code", "ABCD-EFGH", "--no-persist-env"], cfg)

        assert result.exit_code == 0
        assert called["url"] == "https://env-provisioning.example/v1/local-mindroom/pair/complete"
        assert "export MINDROOM_PROVISIONING_URL=https://env-provisioning.example" in result.output
        assert "export MINDROOM_NAMESPACE=a1b2c3d4" in result.output
        assert "export MINDROOM_OWNER_USER_ID=@alice:mindroom.chat" in result.output
        assert "Owner user ID from pairing: @alice:mindroom.chat" in result.output

    def test_connect_warns_when_owner_user_id_is_malformed(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Malformed owner_user_id should warn and skip placeholder replacement."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text(
            "models: {}\nagents: {}\nrouter:\n  model: default\n"
            "authorization:\n"
            "  default_room_access: false\n"
            "  global_users:\n"
            f"    - {OWNER_MATRIX_USER_ID_PLACEHOLDER}\n",
        )
        monkeypatch.setattr(
            "mindroom.cli.main._httpx_post",
            lambda *_a, **_kw: httpx.Response(
                200,
                json={
                    "client_id": "client-123",
                    "client_secret": "secret-123",
                    "namespace": "a1b2c3d4",
                    "owner_user_id": "not-a-mxid",
                },
            ),
        )

        result = _invoke_with_runtime(
            ["connect", "--pair-code", "ABCD-EFGH", "--provisioning-url", "https://provisioning.example"],
            cfg,
        )

        assert result.exit_code == 0
        assert "malformed owner_user_id" in normalize_console_output(result.output)
        env_content = (tmp_path / ".env").read_text(encoding="utf-8")
        assert "MINDROOM_OWNER_USER_ID=" not in env_content
        updated_config = cfg.read_text()
        assert OWNER_MATRIX_USER_ID_PLACEHOLDER in updated_config

    def test_connect_passes_matrix_ssl_verify_to_httpx(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Connect should pass MATRIX_SSL_VERIFY through to httpx.post."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("agents: {}\nmodels: {}\nrouter:\n  model: default\n")

        called: dict[str, object] = {}

        def _fake_post(url: str, **kwargs: object) -> httpx.Response:
            called["url"] = url
            called["kwargs"] = kwargs
            return httpx.Response(
                200,
                json={
                    "client_id": "client-123",
                    "client_secret": "secret-123",
                    "namespace": "a1b2c3d4",
                    "owner_user_id": "@alice:mindroom.chat",
                },
            )

        monkeypatch.setattr("mindroom.cli.main._httpx_post", _fake_post)

        result = _invoke_with_runtime(
            [
                "connect",
                "--pair-code",
                "ABCD-EFGH",
                "--provisioning-url",
                "https://provisioning.example",
                "--no-persist-env",
            ],
            cfg,
            env={"MATRIX_SSL_VERIFY": "false"},
        )

        assert result.exit_code == 0
        kwargs = called["kwargs"]
        assert isinstance(kwargs, dict)
        assert kwargs["verify"] is False


# ---------------------------------------------------------------------------
# mindroom local-stack-setup
# ---------------------------------------------------------------------------


class TestLocalStackSetup:
    """Tests for `mindroom local-stack-setup`."""

    def test_starts_synapse_and_cinny_containers(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """Command starts Synapse compose and the MindRoom Chat container."""
        synapse_dir = tmp_path / "matrix"
        synapse_dir.mkdir()
        (synapse_dir / "docker-compose.yml").write_text("services: {}\n")
        cfg = tmp_path / "config.yaml"
        cfg.write_text("agents: {}\nmodels: {}\nrouter:\n  model: default\n")
        storage_path = tmp_path / "mindroom_data"
        monkeypatch.setattr("mindroom.cli.local_stack.sys.platform", "linux")
        monkeypatch.setattr("mindroom.cli.local_stack.shutil.which", lambda _name: "/usr/bin/docker")
        monkeypatch.setattr(
            "mindroom.cli.local_stack._httpx_get",
            lambda *_a, **_kw: httpx.Response(200, json={"versions": ["v1.1"]}),
        )
        monkeypatch.setattr("mindroom.cli.local_stack.time.sleep", lambda *_a, **_kw: None)

        commands: list[list[str]] = []

        def _fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
            commands.append(cmd)
            return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

        monkeypatch.setattr("mindroom.cli.local_stack.subprocess.run", _fake_run)

        result = _invoke_with_runtime(
            [
                "local-stack-setup",
                "--synapse-dir",
                str(synapse_dir),
                "--cinny-port",
                "18080",
                "--cinny-container-name",
                "mindroom-cinny-test",
            ],
            cfg,
            storage_path=storage_path,
        )

        assert result.exit_code == 0
        assert ["docker", "compose", "up", "-d"] in commands
        assert any(cmd[:3] == ["docker", "rm", "-f"] for cmd in commands)
        assert any(cmd[:3] == ["docker", "run", "-d"] for cmd in commands)
        assert any(cmd[-1] == "ghcr.io/mindroom-ai/mindroom-chat:latest" for cmd in commands)

        cinny_config = storage_path / "local" / "cinny-config.json"
        assert cinny_config.exists()
        config = json.loads(cinny_config.read_text())
        assert config["homeserverList"] == ["http://localhost:8008"]
        assert config["featuredCommunities"]["rooms"] == ["#lobby:localhost"]

        env_path = tmp_path / ".env"
        assert env_path.exists()
        env_content = env_path.read_text()
        assert "MATRIX_HOMESERVER=http://localhost:8008" in env_content
        assert "MATRIX_SSL_VERIFY=false" in env_content
        assert "MATRIX_SERVER_NAME=localhost" in env_content
        assert "Local stack is ready." in result.output

    def test_skip_synapse_skips_compose_start(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """--skip-synapse should not run docker compose up."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("agents: {}\nmodels: {}\nrouter:\n  model: default\n")
        storage_path = tmp_path / "mindroom_data"
        monkeypatch.setattr("mindroom.cli.local_stack.sys.platform", "linux")
        monkeypatch.setattr("mindroom.cli.local_stack.shutil.which", lambda _name: "/usr/bin/docker")
        monkeypatch.setattr(
            "mindroom.cli.local_stack._httpx_get",
            lambda *_a, **_kw: httpx.Response(200, json={"versions": ["v1.1"]}),
        )
        monkeypatch.setattr("mindroom.cli.local_stack.time.sleep", lambda *_a, **_kw: None)

        commands: list[list[str]] = []

        def _fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
            commands.append(cmd)
            return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

        monkeypatch.setattr("mindroom.cli.local_stack.subprocess.run", _fake_run)

        result = _invoke_with_runtime(["local-stack-setup", "--skip-synapse"], cfg, storage_path=storage_path)

        assert result.exit_code == 0
        assert ["docker", "compose", "up", "-d"] not in commands
        assert any(cmd[:3] == ["docker", "run", "-d"] for cmd in commands)

    def test_no_persist_env_prints_inline_command(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """--no-persist-env should not write .env and should print inline env usage."""
        cfg = tmp_path / "config.yaml"
        cfg.write_text("agents: {}\nmodels: {}\nrouter:\n  model: default\n")
        storage_path = tmp_path / "mindroom_data"
        monkeypatch.setattr("mindroom.cli.local_stack.sys.platform", "linux")
        monkeypatch.setattr("mindroom.cli.local_stack.shutil.which", lambda _name: "/usr/bin/docker")
        monkeypatch.setattr(
            "mindroom.cli.local_stack._httpx_get",
            lambda *_a, **_kw: httpx.Response(200, json={"versions": ["v1.1"]}),
        )
        monkeypatch.setattr("mindroom.cli.local_stack.time.sleep", lambda *_a, **_kw: None)
        monkeypatch.setattr(
            "mindroom.cli.local_stack.subprocess.run",
            lambda cmd, **_kwargs: subprocess.CompletedProcess(cmd, 0, stdout="", stderr=""),
        )

        result = _invoke_with_runtime(
            ["local-stack-setup", "--skip-synapse", "--no-persist-env"],
            cfg,
            storage_path=storage_path,
        )

        assert result.exit_code == 0
        assert not (tmp_path / ".env").exists()
        assert "MATRIX_HOMESERVER=http://localhost:8008 MATRIX_SSL_VERIFY=false" in result.output
        assert "uv run" in result.output
        assert "mindroom run" in result.output

    def test_rejects_unsupported_platform(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Command fails on unsupported operating systems."""
        monkeypatch.setattr("mindroom.cli.local_stack.sys.platform", "win32")
        monkeypatch.setattr("mindroom.cli.local_stack.shutil.which", lambda _name: "/usr/bin/docker")

        result = runner.invoke(app, ["local-stack-setup", "--skip-synapse"])

        assert result.exit_code == 1
        assert "supports Linux and macOS only" in result.output

    def test_requires_docker_binary(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Command fails when Docker is missing from PATH."""
        monkeypatch.setattr("mindroom.cli.local_stack.sys.platform", "linux")
        monkeypatch.setattr("mindroom.cli.local_stack.shutil.which", lambda _name: None)

        result = runner.invoke(app, ["local-stack-setup", "--skip-synapse"])

        assert result.exit_code == 1
        assert "Docker is required" in result.output
