"""Test the consolidated ConfigManager tool with fewer methods."""

from __future__ import annotations

import json
import tempfile
from pathlib import Path
from typing import Any, Literal
from unittest.mock import MagicMock, patch
from urllib.parse import parse_qs, urlparse

import nio
import pytest
import yaml
from agno.tools.function import Function
from pydantic import ValidationError

from mindroom.config.agent import AgentConfig, TeamConfig
from mindroom.config.knowledge import KnowledgeBaseConfig
from mindroom.config.main import Config
from mindroom.config.matrix import MindRoomUserConfig
from mindroom.config.models import DefaultsConfig
from mindroom.constants import DEFAULT_WORKER_GRANTABLE_CREDENTIALS, RuntimePaths, resolve_runtime_paths
from mindroom.credential_policy import _UNSUPPORTED_WORKER_GRANTABLE_CREDENTIALS
from mindroom.custom_tools.config_manager import ConfigManagerTools, _InfoType
from mindroom.matrix.state import MatrixState
from mindroom.mcp.config import MCPServerConfig
from mindroom.message_target import MessageTarget
from mindroom.oauth.google_drive import google_drive_oauth_provider
from mindroom.oauth.service import lookup_oauth_connect_token
from mindroom.tool_system.metadata import _AUTHORED_OVERRIDE_INHERIT
from mindroom.tool_system.runtime_context import ToolRuntimeContext, tool_runtime_context
from mindroom.tool_system.worker_routing import ToolExecutionIdentity, WorkerScope, resolve_worker_key
from tests.conftest import load_config_yaml, make_conversation_cache_mock, make_event_cache_mock, write_config_yaml
from tests.identity_helpers import persist_entity_accounts


def _minimal_config_path(tmp_path: Path) -> Path:
    """Write a minimal valid config file for ConfigManager tool tests."""
    config_path = tmp_path / "config.yaml"
    write_config_yaml(Config(models={"default": {"provider": "openai", "id": "gpt-4o"}}), config_path)
    return config_path


def _runtime_paths() -> RuntimePaths:
    return resolve_runtime_paths(config_path=Path("config.yaml"), process_env={})


def _config_manager(config_path: Path) -> ConfigManagerTools:
    """Construct ConfigManagerTools with explicit RuntimePaths."""
    return ConfigManagerTools(resolve_runtime_paths(config_path=config_path, process_env={}))


def _caller_context(
    config_manager: ConfigManagerTools,
    config: Config,
    *,
    agent_name: str = "admin",
    requester_id: str = "@alice:example.org",
) -> ToolRuntimeContext:
    """Build a live config-manager caller context with a stable human requester."""
    return ToolRuntimeContext(
        agent_name=agent_name,
        target=MessageTarget.resolve(
            room_id="!room:example.org",
            thread_id="$thread",
            reply_to_event_id="$request",
        ),
        requester_id=requester_id,
        client=MagicMock(),
        config=config,
        runtime_paths=config_manager.runtime_paths,
        event_cache=make_event_cache_mock(),
        conversation_cache=make_conversation_cache_mock(),
    )


def _connect_url_from_result(result: str) -> str:
    """Extract the first direct OAuth link from a config-manager result."""
    marker = "`connect_url`: "
    assert marker in result
    return result.split(marker, maxsplit=1)[1].split(";", maxsplit=1)[0]


def _invalid_plugin_config_path(tmp_path: Path, *, with_agent: bool = True) -> Path:
    """Write one config whose plugin manifest fails runtime validation."""
    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",
    )
    config_path = tmp_path / "config.yaml"
    write_config_yaml(
        Config(
            agents={"writer": AgentConfig(display_name="Writer", role="Write things")} if with_agent else {},
            models={"default": {"provider": "openai", "id": "gpt-4o"}},
            plugins=["./plugins/bad-name"],
        ),
        config_path,
    )
    return config_path


def _plugin_tool_config_path(tmp_path: Path, *, tool_name: str = "config_manager_plugin_tool") -> Path:
    """Write one config that enables a plugin-defined tool."""
    plugin_root = tmp_path / "plugins" / "demo"
    plugin_root.mkdir(parents=True)
    (plugin_root / "mindroom.plugin.json").write_text(
        json.dumps({"name": "demo_plugin", "tools_module": "tools.py", "skills": []}),
        encoding="utf-8",
    )
    (plugin_root / "tools.py").write_text(
        "from agno.tools import Toolkit\n"
        "from mindroom.tool_system.declarations import ToolCategory\nfrom mindroom.tool_system.registration import register_tool_with_metadata\n"
        "\n"
        "class DemoTool(Toolkit):\n"
        "    def __init__(self) -> None:\n"
        "        super().__init__(name='demo', tools=[])\n"
        "\n"
        "@register_tool_with_metadata(\n"
        f"    name='{tool_name}',\n"
        "    display_name='Plugin Tool',\n"
        "    description='Plugin-defined tool',\n"
        "    category=ToolCategory.DEVELOPMENT,\n"
        ")\n"
        "def demo_plugin_tools():\n"
        "    return DemoTool\n",
        encoding="utf-8",
    )
    config_path = tmp_path / "config.yaml"
    write_config_yaml(
        Config(
            agents={},
            models={"default": {"provider": "openai", "id": "gpt-4o"}},
            plugins=["./plugins/demo"],
        ),
        config_path,
    )
    return config_path


class TestConsolidatedConfigManager:
    """Test the consolidated ConfigManager with four tools."""

    def test_init(self, tmp_path: Path) -> None:
        """Test ConfigManagerTools initialization."""
        cm = _config_manager(_minimal_config_path(tmp_path))
        assert cm.config_path is not None
        assert cm.name == "config_manager"
        assert len(cm.tools) == 4
        assert any(tool.__name__ == "get_info" for tool in cm.tools)
        assert any(tool.__name__ == "manage_config" for tool in cm.tools)
        assert any(tool.__name__ == "manage_agent" for tool in cm.tools)
        assert any(tool.__name__ == "manage_team" for tool in cm.tools)

    def test_manage_config_inspects_authored_subtree_with_redaction(self, tmp_path: Path) -> None:
        """Inspection should be path-scoped, authored-only, and centrally redacted."""
        config_path = tmp_path / "config.yaml"
        write_config_yaml(
            Config(
                models={
                    "default": {
                        "provider": "openai",
                        "id": "gpt-4o",
                        "api_key": "sk-test-secret",
                    },
                },
            ),
            config_path,
        )

        result = _config_manager(config_path).manage_config(operation="inspect", path="/models/default")

        assert "Authored MindRoom configuration" in result
        assert "authored values only" in result
        assert "api_key: '***redacted***'" in result
        assert "id: gpt-4o" in result
        assert "sk-test-secret" not in result
        assert str(config_path.resolve()) in result

    def test_manage_config_inspect_redacts_secret_leaf_pointers(self, tmp_path: Path) -> None:
        """A pointer directly at a secret leaf must stay redacted at any depth."""
        config_path = tmp_path / "config.yaml"
        write_config_yaml(
            Config(
                models={
                    "default": {
                        "provider": "openai",
                        "id": "gpt-4o",
                        # A secret that no token-shape regex matches, so only
                        # key-context redaction can catch it.
                        "api_key": "plain-local-secret",
                    },
                },
            ),
            config_path,
        )

        result = _config_manager(config_path).manage_config(
            operation="inspect",
            path="/models/default/api_key",
        )

        assert "plain-local-secret" not in result
        assert "```yaml\n'***redacted***'\n```" in result

    def test_manage_config_schema_accepts_arbitrary_json_values(self, tmp_path: Path) -> None:
        """The model-facing patch schema must allow scalars, lists, objects, and null."""
        function = Function.from_callable(_config_manager(_minimal_config_path(tmp_path)).manage_config)
        changes_schema = function.parameters["properties"]["changes"]["anyOf"][0]
        entry_schema = changes_schema["items"]

        assert entry_schema["properties"]["op"]["enum"] == ["add", "replace", "remove"]
        assert entry_schema["additionalProperties"] is False
        assert entry_schema["properties"]["value"] == {}

    def test_manage_config_patches_full_schema_atomically(self, tmp_path: Path) -> None:
        """One patch should update unrelated Config sections through shared validation."""
        config_path = _minimal_config_path(tmp_path)
        cm = _config_manager(config_path)

        result = cm.manage_config(
            operation="patch",
            changes=[
                {"op": "add", "path": "/authorization", "value": {"room_permissions": {}}},
                {
                    "op": "add",
                    "path": "/authorization/room_permissions/room~1a~0b",
                    "value": ["@user:example.org"],
                },
                {"op": "replace", "path": "/models/default/id", "value": "gpt-5"},
                {"op": "add", "path": "/tool_approval", "value": {"default": "require_approval"}},
            ],
        )

        assert "patch updated" in result
        assert "Persisted: yes" in result
        assert "/tool_approval" in result
        saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
        assert saved["models"]["default"]["id"] == "gpt-5"
        assert saved["authorization"]["room_permissions"]["room/a~b"] == ["@user:example.org"]
        assert saved["tool_approval"]["default"] == "require_approval"

    def test_manage_config_appends_and_preserves_explicit_null(self, tmp_path: Path) -> None:
        """Array append and explicit null should remain distinct from removal."""
        config_path = tmp_path / "config.yaml"
        write_config_yaml(
            Config(
                models={"default": {"provider": "openai", "id": "gpt-4o"}},
                agents={"writer": AgentConfig(display_name="Writer", role="Writes", instructions=["first"])},
            ),
            config_path,
        )

        result = _config_manager(config_path).manage_config(
            operation="patch",
            changes=[
                {"op": "add", "path": "/agents/writer/instructions/-", "value": "second"},
                {"op": "add", "path": "/agents/writer/compaction", "value": {"model": None}},
            ],
        )

        assert "patch updated" in result
        saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
        assert saved["agents"]["writer"]["instructions"] == ["first", "second"]
        assert saved["agents"]["writer"]["compaction"]["model"] is None

    def test_manage_config_dry_run_validates_without_writing(self, tmp_path: Path) -> None:
        """Dry runs should return a receipt while preserving the original bytes."""
        config_path = _minimal_config_path(tmp_path)
        original = config_path.read_bytes()

        result = _config_manager(config_path).manage_config(
            operation="patch",
            changes=[{"op": "replace", "path": "/models/default/id", "value": "gpt-5"}],
            dry_run=True,
        )

        assert "patch validated" in result
        assert "Persisted: no (dry run)" in result
        assert config_path.read_bytes() == original

    def test_manage_config_rejects_invalid_batch_without_writing(self, tmp_path: Path) -> None:
        """A later bad pointer should roll back the whole in-memory patch."""
        config_path = _minimal_config_path(tmp_path)
        original = config_path.read_bytes()

        result = _config_manager(config_path).manage_config(
            operation="patch",
            changes=[
                {"op": "replace", "path": "/models/default/id", "value": "gpt-5"},
                {"op": "remove", "path": "/models/missing"},
            ],
        )

        assert "Remove target does not exist" in result
        assert "Changes were NOT applied" in result
        assert config_path.read_bytes() == original

    @pytest.mark.parametrize(
        ("change", "expected"),
        [
            ({"op": "move", "path": "/models/default"}, "Input should be 'add', 'replace' or 'remove'"),
            ({"op": "add", "path": "models/default", "value": {}}, "must be empty for the root or start"),
            ({"op": "add", "path": "/models/~2bad", "value": {}}, "Invalid JSON Pointer escape"),
            ({"op": "add", "path": "/models/new"}, "requires a value"),
            ({"op": "remove", "path": "/models/default", "value": {}}, "does not take a value"),
        ],
    )
    def test_manage_config_rejects_malformed_patch_entries(
        self,
        tmp_path: Path,
        change: dict[str, Any],
        expected: str,
    ) -> None:
        """Malformed operations and pointers should fail without touching disk."""
        config_path = _minimal_config_path(tmp_path)
        original = config_path.read_bytes()

        result = _config_manager(config_path).manage_config(operation="patch", changes=[change])

        assert expected in result
        assert "Changes were NOT applied" in result
        assert config_path.read_bytes() == original

    def test_manage_config_labels_patch_schema_errors_as_patch_errors(self, tmp_path: Path) -> None:
        """A malformed request should not imply that the authored config is broken."""
        config_path = _minimal_config_path(tmp_path)

        result = _config_manager(config_path).manage_config(
            operation="patch",
            changes=[{"op": "move", "path": "/models/default"}],
        )

        assert "Invalid patch request" in result
        assert "Invalid configuration" not in result
        assert "Changes were NOT applied" in result

    def test_manage_config_rejects_root_null_that_schema_normalizes_away(self, tmp_path: Path) -> None:
        """A root null must fail rather than persist with removal semantics."""
        config_path = _minimal_config_path(tmp_path)
        original = config_path.read_bytes()

        result = _config_manager(config_path).manage_config(
            operation="patch",
            changes=[{"op": "add", "path": "/tool_approval", "value": None}],
        )

        assert "cannot be set to null" in result
        assert "schema normalizes null to unset/default" in result
        assert "Changes were NOT applied" in result
        assert config_path.read_bytes() == original

    def test_manage_config_replace_missing_suggests_add(self, tmp_path: Path) -> None:
        """Unset authored fields should explain the replace-versus-add distinction."""
        config_path = _minimal_config_path(tmp_path)

        result = _config_manager(config_path).manage_config(
            operation="patch",
            changes=[{"op": "replace", "path": "/defaults", "value": {"markdown": False}}],
        )

        assert "use add instead" in result
        assert "Changes were NOT applied" in result

    def test_manage_config_rejects_schema_invalid_patch_without_writing(self, tmp_path: Path) -> None:
        """Runtime-aware Config validation should reject unknown fields atomically."""
        config_path = tmp_path / "config.yaml"
        write_config_yaml(
            Config(
                models={"default": {"provider": "openai", "id": "gpt-4o"}},
                agents={"writer": AgentConfig(display_name="Writer", role="Writes")},
            ),
            config_path,
        )
        original = config_path.read_bytes()

        result = _config_manager(config_path).manage_config(
            operation="patch",
            changes=[{"op": "add", "path": "/agents/writer/not_a_field", "value": True}],
        )

        assert "Invalid configuration" in result
        assert "not_a_field" in result
        assert config_path.read_bytes() == original

    def test_manage_config_preserves_tool_overrides_and_runtime_overlay(self, tmp_path: Path) -> None:
        """Unrelated patches must preserve authored overrides without persisting runtime overlays."""
        config_path = tmp_path / "config.yaml"
        write_config_yaml(
            Config(
                models={"default": {"provider": "openai", "id": "gpt-4o"}},
                defaults=DefaultsConfig(
                    tools=[{"shell": {"enable_run_shell_command": True}}],
                ),
            ),
            config_path,
        )
        runtime_paths = resolve_runtime_paths(
            config_path=config_path,
            process_env={"MINDROOM_APPROVED_EGRESS_ENABLED": "true"},
        )

        result = ConfigManagerTools(runtime_paths).manage_config(
            operation="patch",
            changes=[{"op": "add", "path": "/timezone", "value": "Europe/Amsterdam"}],
        )

        assert "patch updated" in result
        saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
        assert saved["defaults"]["tools"] == [{"shell": {"enable_run_shell_command": True}}]
        assert "approved_egress" not in str(saved)
        assert "tool_approval" not in saved

    def test_manage_config_surfaces_include_boundary_on_inspect_and_patch(self, tmp_path: Path) -> None:
        """Composed configs should remain inspectable but reject structured writes."""
        config_path = tmp_path / "config.yaml"
        models_path = tmp_path / "models.yaml"
        config_path.write_text("models: !include models.yaml\n", encoding="utf-8")
        models_path.write_text("default:\n  provider: openai\n  id: gpt-4o\n", encoding="utf-8")
        cm = _config_manager(config_path)

        inspected = cm.manage_config(operation="inspect", path="/models/default")
        rejected = cm.manage_config(
            operation="patch",
            changes=[{"op": "replace", "path": "/models/default/id", "value": "gpt-5"}],
        )

        assert "composed from multiple files" in inspected
        assert "structured patching is unavailable" in inspected
        assert "composed from multiple files" in rejected
        assert "Changes were NOT applied" in rejected
        assert "gpt-4o" in models_path.read_text(encoding="utf-8")

    def test_init_uses_explicit_config_path(self) -> None:
        """Initialization should preserve the explicitly provided config path."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(agents={})
            config.agents["test"] = AgentConfig(
                display_name="Test Agent",
                role="Test role",
                tools=["googlesearch"],
                model="default",
            )
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)

            assert cm.config_path == config_path.resolve()
            assert "Test Agent" in cm.get_info(info_type="agents")
        finally:
            config_path.unlink(missing_ok=True)

    def test_get_info_agents(self) -> None:
        """Test get_info with agents info type."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(agents={})
            config.agents["test"] = AgentConfig(
                display_name="Test Agent",
                role="Test role",
                tools=["googlesearch"],
                model="default",
            )
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.get_info(info_type="agents")
            assert "Test Agent" in result
            assert "test" in result
            assert "googlesearch" in result
        finally:
            config_path.unlink(missing_ok=True)

    def test_get_info_agents_defaults_to_sender_visible_configured_room_candidates(self, tmp_path: Path) -> None:
        """Current-room agent listing should use configured-room responder candidates."""
        room_id = "!room:localhost"
        config = Config(
            agents={
                "present": AgentConfig(display_name="Present Agent", role="Here", model="default", rooms=[room_id]),
                "blocked": AgentConfig(display_name="Blocked Agent", role="Blocked", model="default", rooms=[room_id]),
                "unconfigured_present": AgentConfig(
                    display_name="Unconfigured Present",
                    role="Present but not configured",
                    model="default",
                ),
                "elsewhere": AgentConfig(display_name="Elsewhere Agent", role="Not here", model="default"),
            },
            models={"default": {"provider": "openai", "id": "gpt-4o"}},
            authorization={
                "default_room_access": True,
                "agent_reply_permissions": {
                    "blocked": ["@other:localhost"],
                },
            },
        )
        config_path = tmp_path / "config.yaml"
        write_config_yaml(config, config_path)
        cm = _config_manager(config_path)
        persist_entity_accounts(
            config,
            cm.runtime_paths,
            usernames={
                "router": "mindroom_router_oldns",
                "present": "mindroom_present_oldns",
                "blocked": "mindroom_blocked_oldns",
                "unconfigured_present": "mindroom_unconfigured_present_oldns",
                "elsewhere": "mindroom_elsewhere_oldns",
            },
        )

        room = nio.MatrixRoom(room_id, "@mindroom_present_oldns:localhost")
        room.add_member("@mindroom_present_oldns:localhost", "Present Agent", None)
        room.add_member("@mindroom_blocked_oldns:localhost", "Blocked Agent", None)
        room.add_member("@mindroom_unconfigured_present_oldns:localhost", "Unconfigured Present", None)
        room.add_member("@user:localhost", "User", None)
        room.members_synced = True
        runtime_context = ToolRuntimeContext(
            agent_name="present",
            target=MessageTarget.resolve(
                room_id=room.room_id,
                thread_id=None,
                reply_to_event_id=None,
            ),
            requester_id="@user:localhost",
            client=MagicMock(),
            config=config,
            runtime_paths=cm.runtime_paths,
            event_cache=make_event_cache_mock(),
            conversation_cache=make_conversation_cache_mock(),
            room=room,
        )

        with tool_runtime_context(runtime_context):
            current_room_result = cm.get_info(info_type="agents")
            all_agents_result = cm.get_info(info_type="agents", agent_scope="all")

        assert "Present Agent" in current_room_result
        assert "Blocked Agent" not in current_room_result
        assert "Unconfigured Present" not in current_room_result
        assert "Elsewhere Agent" not in current_room_result
        assert "Blocked Agent" in all_agents_result
        assert "Unconfigured Present" in all_agents_result
        assert "Elsewhere Agent" in all_agents_result

    def test_get_info_agents_tolerates_invalid_plugin_manifest(self, tmp_path: Path) -> None:
        """Read-only config-manager info should keep working when runtime plugin loading degrades."""
        cm = _config_manager(_invalid_plugin_config_path(tmp_path))

        result = cm.get_info(info_type="agents")

        assert "Writer" in result
        assert "writer" in result
        assert "Invalid configuration" not in result

    def test_get_info_agents_returns_malformed_yaml_error(self, tmp_path: Path) -> None:
        """Malformed YAML should return one user-facing invalid-config message."""
        config_path = tmp_path / "config.yaml"
        config_path.write_text("agents:\n  bad: [\n", encoding="utf-8")
        cm = _config_manager(config_path)

        result = cm.get_info(info_type="agents")

        assert "Invalid configuration" in result
        assert "Could not parse configuration YAML" in result

    def test_get_info_teams(self) -> None:
        """Test get_info with teams info type."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(
                agents={
                    "agent1": AgentConfig(display_name="Agent One"),
                    "agent2": AgentConfig(display_name="Agent Two"),
                },
                teams={},
            )
            config.teams["test_team"] = TeamConfig(
                display_name="Test Team",
                role="Test team role",
                agents=["agent1", "agent2"],
                mode="coordinate",
            )
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.get_info(info_type="teams")
            assert "Test Team" in result
            assert "test_team" in result
            assert "agent1" in result
            assert "agent2" in result
        finally:
            config_path.unlink(missing_ok=True)

    def test_get_info_available_tools(self, tmp_path: Path) -> None:
        """Test get_info with available_tools info type."""
        cm = _config_manager(_minimal_config_path(tmp_path))
        result = cm.get_info(info_type="available_tools")
        assert "Available Tools by Category" in result

    def test_get_info_available_tools_includes_plugin_tools_from_current_config(self, tmp_path: Path) -> None:
        """Available tool listing should resolve plugin tools from the current config."""
        cm = _config_manager(_plugin_tool_config_path(tmp_path))

        result = cm.get_info(info_type="available_tools")

        assert "config_manager_plugin_tool" in result
        assert "Plugin-defined tool" in result

    def test_get_info_tool_details(self, tmp_path: Path) -> None:
        """Test get_info with tool_details info type."""
        cm = _config_manager(_minimal_config_path(tmp_path))
        # Should require name parameter
        result = cm.get_info(info_type="tool_details")
        assert "Error" in result
        assert "requires 'name' parameter" in result

        # With valid tool name (using googlesearch which we know exists)
        result = cm.get_info(info_type="tool_details", name="googlesearch")
        assert "Tool: googlesearch" in result

    def test_get_info_tool_details_for_openclaw_compat(self, tmp_path: Path) -> None:
        """Tool details should describe openclaw_compat as a registered tool."""
        cm = _config_manager(_minimal_config_path(tmp_path))
        result = cm.get_info(info_type="tool_details", name="openclaw_compat")
        assert "Tool: openclaw_compat" in result
        assert "OpenClaw Compat" in result

    def test_get_info_tool_details_includes_plugin_tool_from_current_config(self, tmp_path: Path) -> None:
        """Tool details should resolve plugin tools from the current config."""
        cm = _config_manager(_plugin_tool_config_path(tmp_path))

        result = cm.get_info(info_type="tool_details", name="config_manager_plugin_tool")

        assert "Tool: config_manager_plugin_tool" in result
        assert "Plugin-defined tool" in result

    def test_get_info_invalid_type(self, tmp_path: Path) -> None:
        """Test get_info with invalid info type."""
        cm = _config_manager(_minimal_config_path(tmp_path))
        result = cm.get_info(info_type="invalid_type")
        assert "Error: Unknown info_type" in result
        assert "Valid options" in result

    def test_manage_agent_create(self) -> None:
        """Test manage_agent with create operation."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(agents={})
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.manage_agent(
                operation="create",
                agent_name="test_agent",
                display_name="Test Agent",
                role="Test role",
                tools=[],
                model="default",
            )
            assert "Successfully created" in result
            assert "test_agent" in result

            # Verify agent was created
            config = load_config_yaml(config_path)
            assert "test_agent" in config.agents
            assert config.agents["test_agent"].display_name == "Test Agent"
        finally:
            config_path.unlink(missing_ok=True)

    @pytest.mark.parametrize("worker_scope", [None, "shared", "user", "user_agent"])
    def test_manage_agent_create_returns_updated_agent_oauth_target(
        self,
        tmp_path: Path,
        worker_scope: WorkerScope | None,
    ) -> None:
        """Create links should use the new agent and requester's effective execution scope."""
        config = Config(
            agents={"admin": AgentConfig(display_name="Admin", role="Configure agents")},
            defaults=DefaultsConfig(tools=[], worker_scope=worker_scope),
            models={"default": {"provider": "openai", "id": "gpt-4o"}},
        )
        config_path = tmp_path / "config.yaml"
        write_config_yaml(config, config_path)
        cm = _config_manager(config_path)

        with tool_runtime_context(_caller_context(cm, config)):
            result = cm.manage_agent(
                operation="create",
                agent_name="research",
                display_name="Research",
                role="Read Drive files",
                tools=["google_drive"],
            )

        connect_url = _connect_url_from_result(result)
        query = parse_qs(urlparse(connect_url).query)
        assert query["agent_name"] == ["research"]
        assert query.get("execution_scope") == ([worker_scope] if worker_scope is not None else None)
        assert "admin" not in connect_url
        assert "`requires_host_browser`: true" in result
        assert "Localhost links must be opened in a browser on the computer where MindRoom is running" in result
        assert "direct links instead of sending them to the dashboard" in result
        assert "After connection, have agent `research` retry" in result
        assert "not guaranteed to be available to the current agent or in the current run" in result
        assert "Before replying" not in result

        connect_tokens = query.get("connect_token")
        if worker_scope is None:
            assert connect_tokens is None
            return

        connect_target = lookup_oauth_connect_token(
            google_drive_oauth_provider(),
            cm.runtime_paths,
            connect_tokens[0],
        )
        expected_identity = ToolExecutionIdentity(
            channel="matrix",
            agent_name="research",
            requester_id="@alice:example.org",
            room_id="!room:example.org",
            thread_id="$thread",
            resolved_thread_id="$thread",
            session_id="!room:example.org:$thread",
        )
        assert connect_target.agent_name == "research"
        assert connect_target.requester_id == "@alice:example.org"
        assert connect_target.worker_scope == worker_scope
        assert connect_target.worker_key == resolve_worker_key(
            worker_scope,
            expected_identity,
            agent_name="research",
        )

    @pytest.mark.parametrize("private_scope", ["user", "user_agent"])
    def test_manage_agent_update_returns_private_target_oauth_link(
        self,
        tmp_path: Path,
        private_scope: Literal["user", "user_agent"],
    ) -> None:
        """Updating another private agent should mint only that agent's requester-bound link."""
        config = Config(
            agents={
                "admin": AgentConfig(display_name="Admin", role="Configure agents"),
                "research": AgentConfig(
                    display_name="Research",
                    role="Research",
                    tools=["calculator"],
                    private={"per": private_scope},
                ),
            },
            defaults=DefaultsConfig(tools=[]),
            models={"default": {"provider": "openai", "id": "gpt-4o"}},
        )
        config_path = tmp_path / "config.yaml"
        write_config_yaml(config, config_path)
        cm = _config_manager(config_path)

        with tool_runtime_context(_caller_context(cm, config)):
            result = cm.manage_agent(
                operation="update",
                agent_name="research",
                tools=["calculator", "google_drive"],
            )

        connect_url = _connect_url_from_result(result)
        query = parse_qs(urlparse(connect_url).query)
        assert query["agent_name"] == ["research"]
        assert query["execution_scope"] == [private_scope]
        connect_target = lookup_oauth_connect_token(
            google_drive_oauth_provider(),
            cm.runtime_paths,
            query["connect_token"][0],
        )
        assert connect_target.agent_name == "research"
        assert connect_target.requester_id == "@alice:example.org"
        assert connect_target.worker_scope == private_scope

    def test_manage_agent_update_does_not_mint_caller_link_when_requester_cannot_manage_target(
        self,
        tmp_path: Path,
    ) -> None:
        """Target authorization failure should not fall back to the config-manager caller."""
        config = Config(
            agents={
                "admin": AgentConfig(display_name="Admin", role="Configure agents"),
                "research": AgentConfig(display_name="Research", role="Research"),
            },
            defaults=DefaultsConfig(tools=[]),
            models={"default": {"provider": "openai", "id": "gpt-4o"}},
            authorization={"agent_reply_permissions": {"research": ["@bob:example.org"]}},
        )
        config_path = tmp_path / "config.yaml"
        write_config_yaml(config, config_path)
        cm = _config_manager(config_path)

        with (
            tool_runtime_context(_caller_context(cm, config)),
            patch("mindroom.custom_tools.config_manager.oauth_connect_url") as connect_url,
        ):
            result = cm.manage_agent(
                operation="update",
                agent_name="research",
                tools=["google_drive"],
            )

        connect_url.assert_not_called()
        assert "not authorized to manage credentials for agent `research`" in result
        assert "connect_url" not in result
        assert "admin" not in result

    def test_manage_agent_self_update_does_not_promise_same_run_tool_use(self, tmp_path: Path) -> None:
        """Self-update should return the scoped link without claiming the new schema is callable now."""
        config = Config(
            agents={"research": AgentConfig(display_name="Research", role="Research", worker_scope="user_agent")},
            defaults=DefaultsConfig(tools=[]),
            models={"default": {"provider": "openai", "id": "gpt-4o"}},
        )
        config_path = tmp_path / "config.yaml"
        write_config_yaml(config, config_path)
        cm = _config_manager(config_path)

        with tool_runtime_context(_caller_context(cm, config, agent_name="research")):
            result = cm.manage_agent(
                operation="update",
                agent_name="research",
                tools=["google_drive"],
            )

        assert "`connect_url`:" in result
        assert "not guaranteed to be available to the current agent or in the current run" in result
        assert "call a harmless" not in result

    def test_manage_agent_oauth_guidance_respects_inherited_default_tools(self, tmp_path: Path) -> None:
        """Effective default-tool changes should produce links only for agents that inherit them."""
        config = Config(
            agents={
                "admin": AgentConfig(display_name="Admin", role="Configure agents"),
                "research": AgentConfig(
                    display_name="Research",
                    role="Research",
                    include_default_tools=False,
                ),
            },
            defaults=DefaultsConfig(tools=["google_drive"], worker_scope="user_agent"),
            models={"default": {"provider": "openai", "id": "gpt-4o"}},
        )
        config_path = tmp_path / "config.yaml"
        write_config_yaml(config, config_path)
        cm = _config_manager(config_path)

        with tool_runtime_context(_caller_context(cm, config)):
            inherited_create = cm.manage_agent(
                operation="create",
                agent_name="inherited",
                display_name="Inherited",
                role="Use defaults",
                tools=[],
            )
            excluded_create = cm.manage_agent(
                operation="create",
                agent_name="excluded",
                display_name="Excluded",
                role="Skip defaults",
                tools=[],
                include_default_tools=False,
            )
            inherited_update = cm.manage_agent(
                operation="update",
                agent_name="research",
                include_default_tools=True,
            )

        assert "`connect_url`:" in inherited_create
        assert "agent `inherited`" in inherited_create
        assert "connect_url" not in excluded_create
        assert "`connect_url`:" in inherited_update
        assert "agent `research`" in inherited_update

    def test_manage_agent_excludes_setup_type_oauth_without_auth_provider(self, tmp_path: Path) -> None:
        """SetupType.OAUTH alone should not claim the structured generic-provider contract."""
        config = Config(
            agents={"admin": AgentConfig(display_name="Admin", role="Configure agents")},
            defaults=DefaultsConfig(tools=[]),
            models={"default": {"provider": "openai", "id": "gpt-4o"}},
        )
        config_path = tmp_path / "config.yaml"
        write_config_yaml(config, config_path)
        cm = _config_manager(config_path)

        with tool_runtime_context(_caller_context(cm, config)):
            result = cm.manage_agent(
                operation="create",
                agent_name="meetings",
                display_name="Meetings",
                role="Manage Zoom meetings",
                tools=["zoom"],
            )

        assert "Successfully created" in result
        assert "connect_url" not in result
        assert "MindRoom-managed OAuth" not in result

    def test_manage_agent_returns_target_link_for_oauth_mcp_tool(self, tmp_path: Path) -> None:
        """Generic OAuth MCP metadata should use the same updated-agent target flow."""
        mcp_server = MCPServerConfig(
            transport="streamable-http",
            url="https://mcp.example.test/mcp",
            auth={
                "type": "oauth",
                "display_name": "Demo MCP",
                "discovery": "manual",
                "authorization_url": "https://auth.example.test/authorize",
                "token_url": "https://auth.example.test/token",
            },
        )
        config = Config(
            agents={"admin": AgentConfig(display_name="Admin", role="Configure agents")},
            defaults=DefaultsConfig(tools=[], worker_scope="user_agent"),
            models={"default": {"provider": "openai", "id": "gpt-4o"}},
            mcp_servers={"demo": mcp_server},
        )
        config_path = tmp_path / "config.yaml"
        write_config_yaml(config, config_path)
        cm = _config_manager(config_path)

        with tool_runtime_context(_caller_context(cm, config)):
            result = cm.manage_agent(
                operation="create",
                agent_name="research",
                display_name="Research",
                role="Use Demo MCP",
                tools=["mcp_demo"],
            )

        query = parse_qs(urlparse(_connect_url_from_result(result)).query)
        assert query["agent_name"] == ["research"]
        assert query["execution_scope"] == ["user_agent"]
        assert "/api/oauth/mcp_demo/authorize" in result

    def test_manage_agent_oauth_link_failure_does_not_mask_saved_update(self, tmp_path: Path) -> None:
        """Optional link generation must not report a persisted config change as failed."""
        config = Config(
            agents={
                "admin": AgentConfig(display_name="Admin", role="Configure agents"),
                "research": AgentConfig(display_name="Research", role="Research"),
            },
            defaults=DefaultsConfig(tools=[]),
            models={"default": {"provider": "openai", "id": "gpt-4o"}},
        )
        config_path = tmp_path / "config.yaml"
        write_config_yaml(config, config_path)
        cm = _config_manager(config_path)

        with (
            tool_runtime_context(_caller_context(cm, config)),
            patch("mindroom.custom_tools.config_manager.oauth_connect_url", side_effect=RuntimeError("disk failed")),
        ):
            result = cm.manage_agent(
                operation="update",
                agent_name="research",
                tools=["google_drive"],
            )

        assert "Successfully updated" in result
        assert load_config_yaml(config_path).agents["research"].tool_names == ["google_drive"]

    def test_manage_agent_create_returns_invalid_plugin_manifest_error(self, tmp_path: Path) -> None:
        """Write config-manager flows should keep runtime plugin validation in the invalid-config channel."""
        cm = _config_manager(_invalid_plugin_config_path(tmp_path, with_agent=False))

        result = cm.manage_agent(
            operation="create",
            agent_name="test_agent",
            display_name="Test Agent",
            role="Test role",
            tools=[],
            model="default",
        )

        assert "Invalid configuration" in result
        assert "Invalid plugin name" in result
        assert "Changes were NOT applied." in result

    def test_manage_agent_create_returns_malformed_yaml_error(self, tmp_path: Path) -> None:
        """Malformed YAML should be reported through the invalid-config path for mutating flows."""
        config_path = tmp_path / "config.yaml"
        config_path.write_text("agents:\n  bad: [\n", encoding="utf-8")
        cm = _config_manager(config_path)

        result = cm.manage_agent(
            operation="create",
            agent_name="test_agent",
            display_name="Test Agent",
            role="Test role",
            tools=[],
            model="default",
        )

        assert "Invalid configuration" in result
        assert "Could not parse configuration YAML" in result
        assert "Changes were NOT applied." in result

    def test_manage_agent_create_accepts_plugin_tool_from_current_config(self, tmp_path: Path) -> None:
        """Agent creation should accept plugin tools without relying on ambient registry state."""
        config_path = _plugin_tool_config_path(tmp_path, tool_name="config_manager_plugin_tool")
        cm = _config_manager(config_path)

        result = cm.manage_agent(
            operation="create",
            agent_name="test_agent",
            display_name="Test Agent",
            role="Test role",
            tools=["config_manager_plugin_tool"],
            model="default",
        )

        assert "Successfully created" in result
        saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
        assert saved["agents"]["test_agent"]["tools"] == ["config_manager_plugin_tool"]

    def test_manage_agent_create_accepts_openclaw_preset_tool(self) -> None:
        """Agent create should accept preset entries in tools."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(agents={})
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.manage_agent(
                operation="create",
                agent_name="test_agent",
                display_name="Test Agent",
                role="Test role",
                tools=["openclaw_compat"],
            )
            assert "Successfully created" in result

            config = load_config_yaml(config_path)
            assert config.agents["test_agent"].tool_names == ["openclaw_compat"]
            effective = config.resolve_entity("test_agent").available_tools
            assert effective[0] == "openclaw_compat"
            assert "shell" in effective
            assert "matrix_message" in effective
        finally:
            config_path.unlink(missing_ok=True)

    def test_manage_agent_validate_accepts_openclaw_preset_tool(self) -> None:
        """Validate should not flag preset entries as invalid tools."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(
                agents={
                    "test_agent": AgentConfig(
                        display_name="Test Agent",
                        role="Test role",
                        tools=["openclaw_compat", "python"],
                    ),
                },
            )
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.manage_agent(operation="validate", agent_name="test_agent")
            assert "Invalid tools" not in result
        finally:
            config_path.unlink(missing_ok=True)

    def test_manage_agent_create_rejects_unknown_knowledge_bases(self) -> None:
        """Create must fail when knowledge base IDs are not configured."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(
                agents={},
                knowledge_bases={
                    "docs": KnowledgeBaseConfig(path="./docs"),
                },
            )
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.manage_agent(
                operation="create",
                agent_name="test_agent",
                display_name="Test Agent",
                role="Test role",
                knowledge_bases=["missing_docs"],
            )
            assert result == "Error: Unknown knowledge bases: missing_docs. Available knowledge bases: docs."

            config = load_config_yaml(config_path)
            assert "test_agent" not in config.agents
        finally:
            config_path.unlink(missing_ok=True)

    def test_manage_agent_create_rejects_runtime_invalid_config(self) -> None:
        """Create must not persist configs that fail runtime-aware validation."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            write_config_yaml(
                Config(
                    agents={},
                    mindroom_user=MindRoomUserConfig(username="mindroom_assistant"),
                    models={"default": {"provider": "openai", "id": "gpt-4o"}},
                ),
                config_path,
            )

        try:
            runtime_paths = resolve_runtime_paths(config_path=config_path, process_env={})
            matrix_state = MatrixState.load(runtime_paths=runtime_paths)
            matrix_state.add_account("agent_assistant", "mindroom_assistant", "pw", domain="localhost")
            matrix_state.save(runtime_paths=runtime_paths)
            cm = _config_manager(config_path)
            result = cm.manage_agent(
                operation="create",
                agent_name="assistant",
                display_name="Assistant",
                role="Test role",
                tools=[],
                model="default",
            )

            assert "Invalid configuration" in result
            assert "conflicts" in result
            assert "Changes were NOT applied." in result
            config = load_config_yaml(config_path)
            assert "assistant" not in config.agents
        finally:
            config_path.unlink(missing_ok=True)

    def test_manage_agent_create_rejects_duplicate_knowledge_bases(self) -> None:
        """Create must fail when duplicate knowledge base IDs are provided."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(
                agents={},
                knowledge_bases={
                    "docs": KnowledgeBaseConfig(path="./docs"),
                },
            )
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.manage_agent(
                operation="create",
                agent_name="test_agent",
                display_name="Test Agent",
                role="Test role",
                knowledge_bases=["docs", "docs"],
            )
            assert result == "Error: Duplicate knowledge bases are not allowed: docs."

            config = load_config_yaml(config_path)
            assert "test_agent" not in config.agents
        finally:
            config_path.unlink(missing_ok=True)

    def test_manage_agent_update(self) -> None:
        """Test manage_agent with update operation."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(agents={})
            config.agents["test_agent"] = AgentConfig(
                display_name="Old Name",
                role="Old role",
            )
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.manage_agent(
                operation="update",
                agent_name="test_agent",
                display_name="New Name",
            )
            assert "Successfully updated" in result
            assert "Display Name -> New Name" in result

            # Verify agent was updated
            config = load_config_yaml(config_path)
            assert config.agents["test_agent"].display_name == "New Name"
            assert config.agents["test_agent"].role == "Old role"  # Unchanged
        finally:
            config_path.unlink(missing_ok=True)

    def test_tool_config_entries_parse_and_merge(self) -> None:
        """Mixed string and mapping syntax should normalize and merge defaults with agent overrides."""
        config = Config.validate_with_runtime(
            {
                "defaults": {
                    "tools": [
                        "scheduler",
                        {"shell": {"extra_env_passthrough": "DAWARICH_*", "enable_run_shell_command": False}},
                    ],
                },
                "agents": {
                    "code": {
                        "display_name": "Code",
                        "tools": [
                            "file",
                            {"shell": {"enable_run_shell_command": True, "extra_env_passthrough": None}},
                        ],
                    },
                },
            },
            _runtime_paths(),
        )

        assert config.defaults.tool_names == ["scheduler", "shell"]
        assert config.agents["code"].tool_names == ["file", "shell"]

        resolved = config.resolve_entity("code").tool_configs
        assert [entry.name for entry in resolved[:3]] == ["file", "shell", "scheduler"]
        resolved_shell = next(entry for entry in resolved if entry.name == "shell")
        assert resolved_shell.tool_config_overrides == {
            "enable_run_shell_command": True,
            "extra_env_passthrough": None,
        }

    def test_tool_config_inherit_sentinel_clears_required_default_override(self) -> None:
        """A per-agent sentinel should remove an inherited required override and fall back to lower layers."""
        config = Config.validate_with_runtime(
            {
                "defaults": {
                    "tools": [
                        {"clickup": {"master_space_id": "space-default"}},
                    ],
                },
                "agents": {
                    "code": {
                        "display_name": "Code",
                        "tools": [
                            {"clickup": {"master_space_id": _AUTHORED_OVERRIDE_INHERIT}},
                        ],
                    },
                },
            },
            _runtime_paths(),
        )

        resolved = next(entry for entry in config.resolve_entity("code").tool_configs if entry.name == "clickup")
        assert resolved.tool_config_overrides == {}

    def test_tool_approval_null_section_uses_default_config(self) -> None:
        """An uncommented blank tool_approval section should behave like an empty mapping."""
        config = Config.model_validate({"tool_approval": None})

        assert config.tool_approval.default == "auto_approve"
        assert config.tool_approval.timeout_days == 7.0
        assert config.tool_approval.rules == []

    def test_runtime_approved_egress_flag_adds_tool_and_approval_rule(self) -> None:
        """Runtime-managed approved egress should not require editing authored config."""
        runtime_paths = resolve_runtime_paths(
            config_path=Path("config.yaml"),
            process_env={"MINDROOM_APPROVED_EGRESS_ENABLED": "true"},
        )

        config = Config.validate_with_runtime(
            {
                "defaults": {"tools": ["scheduler"]},
                "tool_approval": {
                    "rules": [
                        {"match": "run_shell_command", "action": "require_approval"},
                    ],
                },
            },
            runtime_paths,
        )

        assert config.defaults.tool_names == ["scheduler", "approved_egress"]
        assert [rule.model_dump(exclude_none=True) for rule in config.tool_approval.rules] == [
            {"match": "request_network_access", "action": "require_approval"},
            {"match": "run_shell_command", "action": "require_approval"},
        ]

    def test_runtime_approved_egress_flag_keeps_authored_dump_unmodified(self) -> None:
        """Runtime-managed approved egress should not become persisted authored config."""
        runtime_paths = resolve_runtime_paths(
            config_path=Path("config.yaml"),
            process_env={"MINDROOM_APPROVED_EGRESS_ENABLED": "true"},
        )

        config = Config.validate_with_runtime(
            {
                "defaults": {"tools": ["scheduler"]},
            },
            runtime_paths,
        )
        empty_config = Config.validate_with_runtime({}, runtime_paths)

        assert config.defaults.tool_names == ["scheduler", "approved_egress"]
        assert config.authored_model_dump()["defaults"]["tools"] == ["scheduler"]
        assert "tool_approval" not in config.authored_model_dump()
        assert empty_config.defaults.tool_names == ["approved_egress"]
        assert empty_config.authored_model_dump() == {}

    def test_runtime_approved_egress_flag_forces_approval_ahead_of_script_rules(self) -> None:
        """Runtime-managed approved egress must require Matrix approval even with authored scripts."""
        runtime_paths = resolve_runtime_paths(
            config_path=Path("config.yaml"),
            process_env={"MINDROOM_APPROVED_EGRESS_ENABLED": "true"},
        )

        config = Config.validate_with_runtime(
            {
                "tool_approval": {
                    "rules": [
                        {"match": "*", "script": "approval.py"},
                    ],
                },
            },
            runtime_paths,
        )

        assert [rule.model_dump(exclude_none=True) for rule in config.tool_approval.rules] == [
            {"match": "request_network_access", "action": "require_approval"},
            {"match": "*", "script": "approval.py"},
        ]
        assert config.authored_model_dump()["tool_approval"]["rules"] == [
            {"match": "*", "script": "approval.py"},
        ]

    def test_tool_output_auto_save_threshold_is_configurable_in_defaults(self) -> None:
        """The automatic tool-output save threshold should be a validated config setting."""
        config = Config.model_validate({"defaults": {"tool_output_auto_save_threshold_bytes": 51200}})

        assert config.defaults.tool_output_auto_save_threshold_bytes == 50 * 1024
        with pytest.raises(ValidationError, match="tool_output_auto_save_threshold_bytes"):
            DefaultsConfig(tool_output_auto_save_threshold_bytes=0)

    def test_matrix_sync_null_section_uses_default_config(self) -> None:
        """An uncommented blank matrix_sync section should behave like an empty mapping."""
        config = Config.model_validate({"matrix_sync": None})

        assert config.matrix_sync.mode == "classic"
        assert config.matrix_sync.sliding_timeline_limit == 100

    def test_duplicate_tool_entries_are_rejected_for_agents_and_defaults(self) -> None:
        """Duplicate tool names should be rejected even across mixed string and mapping syntax."""
        with pytest.raises(ValueError, match="Duplicate default tools are not allowed: shell"):
            DefaultsConfig(tools=["shell", {"shell": {"enable_run_shell_command": True}}])

        with pytest.raises(ValueError, match="Duplicate agent tools are not allowed: shell"):
            AgentConfig(
                display_name="Code",
                tools=["shell", {"shell": {"enable_run_shell_command": True}}],
            )

    def test_tool_config_roundtrip_preserves_mapping_entries(self, tmp_path: Path) -> None:
        """Saving and reloading should preserve inline override entries for defaults and agents."""
        config_path = tmp_path / "config.yaml"
        config = Config(
            defaults=DefaultsConfig(
                tools=[
                    "scheduler",
                    {"shell": {"extra_env_passthrough": "DAWARICH_*"}},
                ],
            ),
            agents={
                "code": AgentConfig(
                    display_name="Code",
                    tools=[
                        "file",
                        {"shell": {"enable_run_shell_command": True}},
                    ],
                ),
            },
        )
        write_config_yaml(config, config_path)

        reloaded = load_config_yaml(config_path)
        assert reloaded.model_dump(exclude_none=True)["defaults"]["tools"] == [
            "scheduler",
            {"shell": {"extra_env_passthrough": "DAWARICH_*"}},
        ]
        assert reloaded.model_dump(exclude_none=True)["agents"]["code"]["tools"] == [
            "file",
            {"shell": {"enable_run_shell_command": True}},
        ]

    def test_defaults_tool_assignment_normalizes_strings(self) -> None:
        """DefaultsConfig assignment validation should still coerce plain strings."""
        config = Config()

        config.defaults.tools = ["shell"]

        assert config.defaults.tool_names == ["shell"]
        assert config.defaults.tools[0].overrides == {}

    def test_implied_tools_do_not_receive_preset_overrides(self) -> None:
        """Only explicit tool entries should carry overrides after preset expansion."""
        config = Config.validate_with_runtime(
            {
                "agents": {
                    "code": {
                        "display_name": "Code",
                        "include_default_tools": False,
                        "tools": [
                            {"openclaw_compat": None},
                            {"shell": {"enable_run_shell_command": False}},
                        ],
                    },
                },
            },
            _runtime_paths(),
        )

        resolved = {entry.name: entry.tool_config_overrides for entry in config.resolve_entity("code").tool_configs}
        assert resolved["openclaw_compat"] == {}
        assert resolved["shell"] == {"enable_run_shell_command": False}
        assert resolved["coding"] == {}
        assert resolved["browser"] == {}

    def test_manage_agent_update_preserves_inline_tool_overrides(self, tmp_path: Path) -> None:
        """String-only tool updates should keep overrides for retained tools."""
        config_path = tmp_path / "config.yaml"
        config = Config(
            agents={
                "code": AgentConfig(
                    display_name="Code",
                    tools=[
                        {"shell": {"enable_run_shell_command": False}},
                        {"file": {"enable_delete_file": True}},
                    ],
                ),
            },
        )
        write_config_yaml(config, config_path)

        cm = _config_manager(config_path)
        result = cm.manage_agent(
            operation="update",
            agent_name="code",
            tools=["shell", "calculator"],
        )

        assert "Successfully updated" in result
        reloaded = load_config_yaml(config_path)
        assert reloaded.agents["code"].model_dump(exclude_none=True)["tools"] == [
            {"shell": {"enable_run_shell_command": False}},
            "calculator",
        ]

    @pytest.mark.parametrize(
        ("tool_entry", "expected_path"),
        [
            pytest.param("does_not_exist", "agents.code.tools[0].does_not_exist", id="string"),
            pytest.param({"does_not_exist": None}, "agents.code.tools[0].does_not_exist", id="null-mapping"),
            pytest.param({"does_not_exist": {}}, "agents.code.tools[0].does_not_exist", id="empty-mapping"),
        ],
    )
    def test_validate_with_runtime_rejects_unknown_tool_names(
        self,
        tool_entry: object,
        expected_path: str,
    ) -> None:
        """Runtime config validation should reject unknown tool names before agent construction."""
        with pytest.raises(ValueError, match=r"Unknown tool 'does_not_exist'") as exc_info:
            Config.validate_with_runtime(
                {
                    "agents": {
                        "code": {
                            "display_name": "Code",
                            "tools": [tool_entry],
                        },
                    },
                },
                _runtime_paths(),
            )

        assert expected_path in str(exc_info.value)

    def test_tool_config_entry_invalid_scalar_raises_validation_error(self) -> None:
        """Non-string, non-mapping tool entries should surface as structured Pydantic errors."""
        with pytest.raises(ValidationError) as exc_info:
            Config.model_validate(
                {
                    "agents": {
                        "code": {
                            "display_name": "Code",
                            "tools": [123],
                        },
                    },
                },
            )

        errors = exc_info.value.errors()
        assert any(error["loc"] == ("agents", "code", "tools", 0) for error in errors)
        assert "Tool entries must be strings or single-key mappings" in str(exc_info.value)

    def test_manage_agent_update_rejects_unknown_knowledge_bases(self) -> None:
        """Update must fail when setting unknown knowledge base IDs."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(
                agents={},
                knowledge_bases={
                    "docs": KnowledgeBaseConfig(path="./docs"),
                },
            )
            config.agents["test_agent"] = AgentConfig(
                display_name="Test Agent",
                role="Test role",
                knowledge_bases=["docs"],
            )
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.manage_agent(
                operation="update",
                agent_name="test_agent",
                knowledge_bases=["missing_docs"],
            )
            assert result == "Error: Unknown knowledge bases: missing_docs. Available knowledge bases: docs."

            config = load_config_yaml(config_path)
            assert config.agents["test_agent"].knowledge_bases == ["docs"]
        finally:
            config_path.unlink(missing_ok=True)

    def test_manage_agent_update_rejects_duplicate_knowledge_bases(self) -> None:
        """Update must fail when duplicate knowledge base IDs are provided."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(
                agents={},
                knowledge_bases={
                    "docs": KnowledgeBaseConfig(path="./docs"),
                },
            )
            config.agents["test_agent"] = AgentConfig(
                display_name="Test Agent",
                role="Test role",
                knowledge_bases=["docs"],
            )
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.manage_agent(
                operation="update",
                agent_name="test_agent",
                knowledge_bases=["docs", "docs"],
            )
            assert result == "Error: Duplicate knowledge bases are not allowed: docs."

            config = load_config_yaml(config_path)
            assert config.agents["test_agent"].knowledge_bases == ["docs"]
        finally:
            config_path.unlink(missing_ok=True)

    def test_manage_agent_learning_field(self) -> None:
        """Test manage_agent supports learning and learning_mode create and update."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            write_config_yaml(Config(agents={}), config_path)

        try:
            cm = _config_manager(config_path)
            create_result = cm.manage_agent(
                operation="create",
                agent_name="learning_agent",
                display_name="Learning Agent",
                role="Learns from chats",
                learning=False,
                learning_mode="always",
            )
            assert "Successfully created" in create_result

            update_result = cm.manage_agent(
                operation="update",
                agent_name="learning_agent",
                learning=True,
                learning_mode="agentic",
            )
            assert "Successfully updated" in update_result
            assert "Learning -> True" in update_result
            assert "Learning Mode -> agentic" in update_result

            config = load_config_yaml(config_path)
            assert config.agents["learning_agent"].learning is True
            assert config.agents["learning_agent"].learning_mode == "agentic"
        finally:
            config_path.unlink(missing_ok=True)

    def test_manage_agent_validate(self) -> None:
        """Test manage_agent with validate operation."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(agents={})
            config.agents["test_agent"] = AgentConfig(
                display_name="Test Agent",
                role="Test role",
            )
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.manage_agent(
                operation="validate",
                agent_name="test_agent",
            )
            assert "Validation Results" in result
            assert "test_agent" in result
        finally:
            config_path.unlink(missing_ok=True)

    def test_manage_agent_invalid_operation(self, tmp_path: Path) -> None:
        """Test manage_agent with invalid operation."""
        cm = _config_manager(_minimal_config_path(tmp_path))
        result = cm.manage_agent(
            operation="invalid",
            agent_name="test",
        )
        assert "Error: Unknown operation" in result
        assert "Valid options: create, update, validate" in result

    def test_manage_agent_with_memory_tool(self) -> None:
        """Regression: memory tool must be accepted in create/update/validate."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            write_config_yaml(Config(agents={}), config_path)

        try:
            cm = _config_manager(config_path)

            # Create accepts memory
            result = cm.manage_agent(
                operation="create",
                agent_name="mem_agent",
                display_name="Mem Agent",
                role="Remembers things",
                tools=["memory"],
                model="default",
            )
            assert "Successfully created" in result
            assert "Error" not in result

            # Update accepts memory alongside other tools
            result = cm.manage_agent(
                operation="update",
                agent_name="mem_agent",
                tools=["memory", "calculator"],
            )
            assert "Successfully updated" in result
            assert "Error" not in result

            # Validate does not flag memory as invalid
            result = cm.manage_agent(
                operation="validate",
                agent_name="mem_agent",
            )
            assert "Invalid tools" not in result
        finally:
            config_path.unlink(missing_ok=True)

    def test_manage_team(self) -> None:
        """Test manage_team tool."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(agents={}, teams={})
            # Add agents that the team will reference
            config.agents["agent1"] = AgentConfig(
                display_name="Agent 1",
                role="Role 1",
            )
            config.agents["agent2"] = AgentConfig(
                display_name="Agent 2",
                role="Role 2",
            )
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.manage_team(
                team_name="test_team",
                display_name="Test Team",
                role="Test team role",
                agents=["agent1", "agent2"],
                mode="coordinate",
            )
            assert "Successfully created team" in result
            assert "test_team" in result

            # Verify team was created
            config = load_config_yaml(config_path)
            assert "test_team" in config.teams
            assert config.teams["test_team"].display_name == "Test Team"
            assert config.teams["test_team"].agents == ["agent1", "agent2"]
        finally:
            config_path.unlink(missing_ok=True)

    def test_info_type_enum_values(self, tmp_path: Path) -> None:
        """Test that all InfoType enum values work."""
        cm = _config_manager(_minimal_config_path(tmp_path))

        # Test each enum value
        for info_type in _InfoType:
            # Some require name parameter
            if info_type in [_InfoType.TOOL_DETAILS, _InfoType.AGENT_CONFIG, _InfoType.AGENT_TEMPLATE]:
                result = cm.get_info(info_type=info_type.value)
                assert "requires 'name' parameter" in result
            else:
                result = cm.get_info(info_type=info_type.value)
                # Should not error for valid types without name
                assert "Error: Unknown info_type" not in result

    def test_reduced_tool_count(self, tmp_path: Path) -> None:
        """Verify the toolkit remains consolidated into four functions."""
        cm = _config_manager(_minimal_config_path(tmp_path))

        assert len(cm.tools) == 4

        # Check the specific tools
        tool_names = [tool.__name__ for tool in cm.tools]
        assert "get_info" in tool_names
        assert "manage_config" in tool_names
        assert "manage_agent" in tool_names
        assert "manage_team" in tool_names

        # Old tool names should NOT be present
        old_tools = [
            "get_mindroom_info",
            "get_config_schema",
            "get_available_models",
            "list_agents",
            "list_teams",
            "list_available_tools",
            "get_tool_details",
            "suggest_tools_for_task",
            "create_agent_config",
            "update_agent_config",
            "create_team_config",
            "validate_agent_config",
            "get_agent_config",
            "generate_agent_template",
        ]
        for old_tool in old_tools:
            assert old_tool not in tool_names

    def test_agent_template_generation(self, tmp_path: Path) -> None:
        """Test agent template generation through get_info."""
        cm = _config_manager(_minimal_config_path(tmp_path))

        # Test valid template type
        result = cm.get_info(info_type="agent_template", name="researcher")
        assert "Template for 'researcher' agent" in result
        assert "Research specialist" in result

        # Test invalid template type
        result = cm.get_info(info_type="agent_template", name="invalid_type")
        assert "Unknown template type" in result
        assert "Available templates" in result

    def test_config_schema_info(self, tmp_path: Path) -> None:
        """Test config schema retrieval."""
        cm = _config_manager(_minimal_config_path(tmp_path))
        result = cm.get_info(info_type="config_schema")
        assert "MindRoom Configuration Schema" in result
        assert "Agent Configuration Fields" in result
        assert "Team Configuration Fields" in result

    def test_available_models_info(self) -> None:
        """Test available models retrieval."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config_path = Path(f.name)
            config = Config(
                models={
                    "default": {
                        "provider": "openai",
                        "id": "gpt-4",
                    },
                    "fast": {
                        "provider": "anthropic",
                        "id": "claude-3-haiku",
                    },
                },
            )
            write_config_yaml(config, config_path)

        try:
            cm = _config_manager(config_path)
            result = cm.get_info(info_type="available_models")
            assert "Available Models" in result
            assert "default" in result
            assert "openai" in result
            assert "gpt-4" in result
            assert "fast" in result
            assert "anthropic" in result
        finally:
            config_path.unlink(missing_ok=True)


class TestAgentWorkerScope:
    """Tests for the resolved per-agent execution scope."""

    def test_worker_scope_prefers_agent_override(self) -> None:
        """Agent-level worker_scope should override defaults."""
        config = Config(
            defaults=DefaultsConfig(worker_scope="shared"),
            agents={
                "code": AgentConfig(display_name="Code", worker_scope="user_agent"),
            },
        )
        assert config.resolve_entity("code").execution_scope == "user_agent"

    def test_worker_scope_falls_back_to_defaults(self) -> None:
        """Worker scope should inherit from defaults when agent config omits it."""
        config = Config(
            defaults=DefaultsConfig(worker_scope="user"),
            agents={
                "code": AgentConfig(display_name="Code"),
            },
        )
        assert config.resolve_entity("code").execution_scope == "user"


class TestWorkerGrantableCredentials:
    """Tests for the worker credential mirror allowlist defaults helper."""

    def test_worker_grantable_credentials_none_uses_builtin_default(self) -> None:
        """An explicit or implicit None should preserve the built-in deny-all default."""
        config = Config(defaults=DefaultsConfig(worker_grantable_credentials=None))

        assert config.get_worker_grantable_credentials() == DEFAULT_WORKER_GRANTABLE_CREDENTIALS == frozenset()

    def test_worker_grantable_credentials_roundtrip_and_helper(self, tmp_path: Path) -> None:
        """Authored worker_grantable_credentials should survive YAML roundtrips and helper resolution."""
        config_path = tmp_path / "config.yaml"
        write_config_yaml(
            Config(
                models={"default": {"provider": "openai", "id": "gpt-4o"}},
                defaults=DefaultsConfig(worker_grantable_credentials=["openai", "github_private"]),
            ),
            config_path,
        )

        reloaded = load_config_yaml(config_path)

        assert reloaded.defaults.worker_grantable_credentials == ["openai", "github_private"]
        assert reloaded.get_worker_grantable_credentials() == frozenset({"openai", "github_private"})

    def test_worker_grantable_credentials_empty_list_denies_all(self) -> None:
        """An explicit empty worker_grantable_credentials list should deny all worker credential mirroring."""
        config = Config(defaults=DefaultsConfig(worker_grantable_credentials=[]))

        assert config.get_worker_grantable_credentials() == frozenset()

    def test_worker_grantable_credentials_reject_invalid_service_names(self) -> None:
        """worker_grantable_credentials should validate credential service names."""
        with pytest.raises(ValidationError, match="Service name"):
            DefaultsConfig(worker_grantable_credentials=["bad name"])

    def test_worker_grantable_credentials_reject_google_vertex_adc(self) -> None:
        """Worker credential mirroring should reject Google credentials that must stay local."""
        with pytest.raises(ValidationError, match="google_vertex_adc"):
            DefaultsConfig(worker_grantable_credentials=["google_vertex_adc"])

        with pytest.raises(ValidationError, match="google_oauth_client"):
            DefaultsConfig(worker_grantable_credentials=["google_oauth_client"])

    @pytest.mark.parametrize("service", sorted(_UNSUPPORTED_WORKER_GRANTABLE_CREDENTIALS))
    def test_worker_grantable_credentials_reject_unsupported_services(self, service: str) -> None:
        """Worker credential mirroring should reject every unsupported credential service."""
        with pytest.raises(ValidationError, match=service):
            DefaultsConfig(worker_grantable_credentials=[service])
