"""Tests for the explicit memory tool (MemoryTools toolkit)."""

from __future__ import annotations

from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch

import httpx
import pytest
from openai import AuthenticationError

import mindroom.tools  # noqa: F401
from mindroom.config.agent import AgentConfig
from mindroom.config.main import Config
from mindroom.constants import resolve_runtime_paths
from mindroom.custom_tools.memory import MemoryTools
from mindroom.memory import MemorySearchOutcome, search_agent_memories
from mindroom.tool_system.metadata import TOOL_METADATA
from mindroom.tool_system.worker_routing import ToolExecutionIdentity, agent_workspace_root_path
from tests.conftest import bind_runtime_paths, runtime_paths_for

if TYPE_CHECKING:
    from pathlib import Path


def _auth_error_with_secret(secret: str) -> AuthenticationError:
    request = httpx.Request("POST", "http://embeddings.local/v1/embeddings")
    response = httpx.Response(401, request=request, json={"error": {"message": f"rejected {secret}"}})
    return AuthenticationError(f"rejected {secret}", response=response, body=None)


class TestMemoryTools:
    """Tests for the MemoryTools Toolkit."""

    @pytest.fixture
    def storage_path(self, tmp_path: Path) -> Path:
        """Create a temporary storage path."""
        return tmp_path

    @pytest.fixture
    def config(self, storage_path: Path) -> Config:
        """Load a self-contained runtime-bound config for testing."""
        runtime_paths = resolve_runtime_paths(
            config_path=storage_path / "config.yaml",
            storage_path=storage_path,
            process_env={
                "MATRIX_HOMESERVER": "http://localhost:8008",
                "MINDROOM_NAMESPACE": "",
            },
        )
        return bind_runtime_paths(
            Config(
                agents={
                    "test_agent": AgentConfig(display_name="Test Agent"),
                    "general": AgentConfig(display_name="General"),
                },
            ),
            runtime_paths,
        )

    @pytest.fixture
    def tools(self, storage_path: Path, config: Config) -> MemoryTools:
        """Create a MemoryTools instance for testing."""
        return MemoryTools(
            agent_name="test_agent",
            storage_path=storage_path,
            config=config,
            runtime_paths=runtime_paths_for(config),
        )

    @pytest.mark.asyncio
    async def test_add_memory(self, tools: MemoryTools) -> None:
        """Test that add_memory stores content via add_agent_memory."""
        with patch("mindroom.custom_tools.memory.add_agent_memory", new_callable=AsyncMock) as mock_add:
            result = await tools.add_memory("The user prefers dark mode")

            mock_add.assert_called_once_with(
                "The user prefers dark mode",
                "test_agent",
                tools._storage_path,
                tools._config,
                tools._runtime_paths,
                metadata={"source": "explicit_tool"},
                execution_identity=None,
            )
            assert "Memorized" in result
            assert "dark mode" in result

    @pytest.mark.asyncio
    async def test_add_memory_uses_stored_execution_identity(self, storage_path: Path, config: Config) -> None:
        """MemoryTools should forward the constructor-bound execution identity without ambient context."""
        execution_identity = ToolExecutionIdentity(
            channel="matrix",
            agent_name="test_agent",
            requester_id="@alice:example.org",
            room_id="!room:example.org",
            thread_id="$thread",
            resolved_thread_id="$thread",
            session_id="session-1",
        )
        tools = MemoryTools(
            agent_name="test_agent",
            storage_path=storage_path,
            config=config,
            runtime_paths=runtime_paths_for(config),
            execution_identity=execution_identity,
        )

        with patch("mindroom.custom_tools.memory.add_agent_memory", new_callable=AsyncMock) as mock_add:
            await tools.add_memory("Remember this")

        assert mock_add.await_args.kwargs["execution_identity"] == execution_identity

    @pytest.mark.asyncio
    async def test_add_memory_error(self, tools: MemoryTools) -> None:
        """Test that add_memory handles errors gracefully."""
        with patch(
            "mindroom.custom_tools.memory.add_agent_memory",
            new_callable=AsyncMock,
            side_effect=RuntimeError("DB down"),
        ):
            result = await tools.add_memory("something")

            assert "Failed to store memory" in result
            assert "DB down" in result

    @pytest.mark.asyncio
    async def test_add_memory_redacts_provider_error(self, tools: MemoryTools) -> None:
        """A rejected embedder key never reaches Matrix-visible tool output."""
        secret = "sk-secret-add"  # noqa: S105
        with patch(
            "mindroom.custom_tools.memory.add_agent_memory",
            new_callable=AsyncMock,
            side_effect=_auth_error_with_secret(secret),
        ):
            result = await tools.add_memory("something")

        assert result == "Failed to store memory: embedder authentication failed (HTTP 401)"
        assert secret not in result

    @pytest.mark.asyncio
    async def test_add_memory_uses_same_agent_file_memory_root_as_prompt_reads(
        self,
        storage_path: Path,
        config: Config,
    ) -> None:
        """Explicit memory writes should land in the same canonical files prompt reads use."""
        config.memory.backend = "file"
        config.memory.file.path = str(storage_path / "shared-memory")
        config.agents["general"].memory_backend = "file"

        tools = MemoryTools(
            agent_name="general",
            storage_path=storage_path,
            config=config,
            runtime_paths=runtime_paths_for(config),
        )

        result = await tools.add_memory("Tool memory stays canonical")
        memories = (
            await search_agent_memories(
                "Tool memory",
                "general",
                storage_path,
                config,
                runtime_paths_for(config),
                limit=5,
            )
        ).results

        assert result == "Memorized: Tool memory stays canonical"
        assert any(memory.get("memory") == "Tool memory stays canonical" for memory in memories)
        assert not (storage_path / "shared-memory" / "agent_general" / "MEMORY.md").exists()
        assert (agent_workspace_root_path(storage_path, "general") / "MEMORY.md").exists()

    @pytest.mark.asyncio
    async def test_search_memories(self, tools: MemoryTools) -> None:
        """Test that search_memories calls search_agent_memories and formats results."""
        mock_results = [
            {"id": "abc-1", "memory": "User likes Python", "score": 0.9},
            {"id": "abc-2", "memory": "User prefers dark mode", "score": 0.8},
        ]

        with patch(
            "mindroom.custom_tools.memory.search_agent_memories",
            new_callable=AsyncMock,
            return_value=MemorySearchOutcome(results=mock_results),
        ) as mock_search:
            result = await tools.search_memories("preferences", limit=3)

            mock_search.assert_called_once_with(
                "preferences",
                "test_agent",
                tools._storage_path,
                tools._config,
                tools._runtime_paths,
                limit=3,
                execution_identity=None,
            )
            assert "Found 2 memory(ies)" in result
            assert "[id=abc-1]" in result
            assert "User likes Python" in result
            assert "[id=abc-2]" in result
            assert "User prefers dark mode" in result

    def test_search_memories_tool_description_is_backend_neutral(self, tools: MemoryTools) -> None:
        """Agent-facing memory search should not mention a specific storage backend."""
        description = tools.search_memories.__doc__

        assert description is not None
        assert "notes" in description
        assert "configured Markdown paths" in description
        assert "default: memory/**/*.md" in description
        assert "falls back to keyword search" in description
        assert "MEMORY.md and memory/**/*.md" in description
        assert "semantic:*" in description
        assert "read-only locators" in description
        assert "get_memory, update_memory, or delete_memory" in description
        assert "knowledge base" not in description.lower()
        assert "mem0" not in description.lower()

    @pytest.mark.asyncio
    async def test_search_memories_marks_result_modes(self, tools: MemoryTools) -> None:
        """Search results should make semantic-vs-keyword mode visible when available."""
        mock_results = [
            {
                "id": "semantic:file.md:1",
                "memory": "Semantic match",
                "metadata": {"search_mode": "semantic"},
            },
            {
                "id": "file:MEMORY.md:2",
                "memory": "Keyword match",
                "metadata": {"search_mode": "keyword"},
            },
        ]

        with patch(
            "mindroom.custom_tools.memory.search_agent_memories",
            new_callable=AsyncMock,
            return_value=MemorySearchOutcome(results=mock_results),
        ):
            result = await tools.search_memories("preferences")

        assert "[id=semantic:file.md:1] [semantic] Semantic match" in result
        assert "[id=file:MEMORY.md:2] [keyword] Keyword match" in result

    @pytest.mark.asyncio
    async def test_search_memories_empty(self, tools: MemoryTools) -> None:
        """Test that search_memories returns a message when no results found."""
        with patch(
            "mindroom.custom_tools.memory.search_agent_memories",
            new_callable=AsyncMock,
            return_value=MemorySearchOutcome(results=[]),
        ):
            result = await tools.search_memories("nonexistent")

            assert result == "No relevant memories found."

    @pytest.mark.asyncio
    async def test_search_memories_auth_degraded_without_matches(self, tools: MemoryTools) -> None:
        """A confirmed auth failure with no fallback matches reports the exact failure line plus advice."""
        outcome = MemorySearchOutcome(results=[], degraded_reason="embedder authentication failed (HTTP 401)")

        with patch(
            "mindroom.custom_tools.memory.search_agent_memories",
            new_callable=AsyncMock,
            return_value=outcome,
        ):
            result = await tools.search_memories("anything")

        lines = result.splitlines()
        assert lines[0] == "Semantic memory search unavailable: embedder authentication failed (HTTP 401)."
        assert "memory.embedder.config.api_key" in result
        assert "'embedder' credential" in result
        assert "EMBEDDER_API_KEY" in result

    @pytest.mark.asyncio
    async def test_search_memories_auth_degraded_with_keyword_matches(self, tools: MemoryTools) -> None:
        """Keyword fallback matches keep flowing under the failure line."""
        outcome = MemorySearchOutcome(
            results=[{"id": "k1", "memory": "Keyword hit", "metadata": {"search_mode": "keyword"}}],
            degraded_reason="embedder authentication failed (HTTP 401)",
        )

        with patch(
            "mindroom.custom_tools.memory.search_agent_memories",
            new_callable=AsyncMock,
            return_value=outcome,
        ):
            result = await tools.search_memories("anything")

        lines = result.splitlines()
        assert lines[0] == (
            "Semantic memory search unavailable: embedder authentication failed (HTTP 401). "
            "Showing keyword matches only."
        )
        assert "EMBEDDER_API_KEY" in result
        assert "Found 1 memory(ies):" in result
        assert "[id=k1] [keyword] Keyword hit" in result

    @pytest.mark.asyncio
    async def test_search_memories_non_auth_degraded_has_no_credential_advice(self, tools: MemoryTools) -> None:
        """Non-credential causes surface the safe detail without credential advice."""
        outcome = MemorySearchOutcome(results=[], degraded_reason="embedder endpoint unreachable")

        with patch(
            "mindroom.custom_tools.memory.search_agent_memories",
            new_callable=AsyncMock,
            return_value=outcome,
        ):
            result = await tools.search_memories("anything")

        assert result == "Semantic memory search unavailable: embedder endpoint unreachable."
        assert "EMBEDDER_API_KEY" not in result
        assert "credential" not in result

    @pytest.mark.asyncio
    async def test_search_memories_non_auth_degraded_with_keyword_matches(self, tools: MemoryTools) -> None:
        """Non-credential causes with fallback matches keep the punctuated failure line."""
        outcome = MemorySearchOutcome(
            results=[{"id": "k1", "memory": "Keyword hit", "metadata": {"search_mode": "keyword"}}],
            degraded_reason="embedder endpoint unreachable",
        )

        with patch(
            "mindroom.custom_tools.memory.search_agent_memories",
            new_callable=AsyncMock,
            return_value=outcome,
        ):
            result = await tools.search_memories("anything")

        lines = result.splitlines()
        assert lines[0] == (
            "Semantic memory search unavailable: embedder endpoint unreachable. Showing keyword matches only."
        )
        assert "credential" not in result
        assert "[id=k1] [keyword] Keyword hit" in result

    @pytest.mark.asyncio
    async def test_search_memories_partial_semantic_results_are_not_called_keyword_only(
        self,
        tools: MemoryTools,
    ) -> None:
        """A failed Mem0 scope can coexist with semantic results from healthy scopes."""
        outcome = MemorySearchOutcome(
            results=[{"id": "m1", "memory": "Healthy scope hit", "metadata": {}}],
            degraded_reason="embedder endpoint unreachable",
        )

        with patch(
            "mindroom.custom_tools.memory.search_agent_memories",
            new_callable=AsyncMock,
            return_value=outcome,
        ):
            result = await tools.search_memories("anything")

        assert result.splitlines()[0] == (
            "Semantic memory search partially unavailable: embedder endpoint unreachable. "
            "Showing results from available scopes."
        )
        assert "keyword matches only" not in result

    @pytest.mark.asyncio
    async def test_credential_advice_names_the_configured_service(
        self,
        storage_path: Path,
        config: Config,
    ) -> None:
        """Auth guidance should identify the strict credential binding that can recover the embedder."""
        runtime_paths = runtime_paths_for(config)
        config = Config.model_validate(
            {
                **config.model_dump(),
                "memory": {
                    **config.memory.model_dump(),
                    "embedder": {
                        "provider": "openai",
                        "config": {
                            "model": "text-embedding-3-small",
                            "credentials_service": "embedding-production",
                        },
                    },
                },
            },
        )
        tools = MemoryTools(
            agent_name="test_agent",
            storage_path=storage_path,
            config=config,
            runtime_paths=runtime_paths,
        )
        outcome = MemorySearchOutcome(results=[], degraded_reason="embedder authentication failed (HTTP 401)")

        with patch(
            "mindroom.custom_tools.memory.search_agent_memories",
            new_callable=AsyncMock,
            return_value=outcome,
        ):
            result = await tools.search_memories("anything")

        assert "memory.embedder.config.api_key" in result
        assert "'embedding-production' credential service" in result
        assert "EMBEDDER_API_KEY" not in result

    @pytest.mark.asyncio
    async def test_search_memories_error(self, tools: MemoryTools) -> None:
        """Test that search_memories handles errors gracefully."""
        with patch(
            "mindroom.custom_tools.memory.search_agent_memories",
            new_callable=AsyncMock,
            side_effect=RuntimeError("Search failed"),
        ):
            result = await tools.search_memories("anything")

            assert "Failed to search memories" in result
            assert "Search failed" in result

    @pytest.mark.asyncio
    async def test_update_memory_redacts_provider_error(self, tools: MemoryTools) -> None:
        """Update failures use the same credential-safe provider detail."""
        secret = "sk-secret-update"  # noqa: S105
        with patch(
            "mindroom.custom_tools.memory.update_agent_memory",
            new_callable=AsyncMock,
            side_effect=_auth_error_with_secret(secret),
        ):
            result = await tools.update_memory("memory-id", "new content")

        assert result == "Failed to update memory: embedder authentication failed (HTTP 401)"
        assert secret not in result

    def test_toolkit_name(self, tools: MemoryTools) -> None:
        """Test that the toolkit is registered with the correct name."""
        assert tools.name == "memory"

    def test_toolkit_has_six_tools(self, tools: MemoryTools) -> None:
        """Test that the toolkit exposes all memory tools."""
        func_names = [f.name for f in tools.async_functions.values()]
        assert "add_memory" in func_names
        assert "search_memories" in func_names
        assert "list_memories" in func_names
        assert "get_memory" in func_names
        assert "update_memory" in func_names
        assert "delete_memory" in func_names

    @pytest.mark.asyncio
    async def test_list_memories(self, tools: MemoryTools) -> None:
        """Test that list_memories calls list_all_agent_memories and formats results."""
        mock_results = [
            {"id": "m1", "memory": "User likes Python"},
            {"id": "m2", "memory": "User prefers dark mode"},
            {"id": "m3", "memory": "Project uses FastAPI"},
        ]

        with patch(
            "mindroom.custom_tools.memory.list_all_agent_memories",
            new_callable=AsyncMock,
            return_value=mock_results,
        ) as mock_list:
            result = await tools.list_memories(limit=10)

            mock_list.assert_called_once_with(
                "test_agent",
                tools._storage_path,
                tools._config,
                tools._runtime_paths,
                limit=10,
                execution_identity=None,
            )
            assert "All memories (3)" in result
            assert "[id=m1]" in result
            assert "User likes Python" in result
            assert "[id=m2]" in result
            assert "User prefers dark mode" in result
            assert "[id=m3]" in result
            assert "Project uses FastAPI" in result

    @pytest.mark.asyncio
    async def test_list_memories_empty(self, tools: MemoryTools) -> None:
        """Test that list_memories returns a message when no memories exist."""
        with patch(
            "mindroom.custom_tools.memory.list_all_agent_memories",
            new_callable=AsyncMock,
            return_value=[],
        ):
            result = await tools.list_memories()

            assert result == "No memories stored yet."

    @pytest.mark.asyncio
    async def test_list_memories_error(self, tools: MemoryTools) -> None:
        """Test that list_memories handles errors gracefully."""
        with patch(
            "mindroom.custom_tools.memory.list_all_agent_memories",
            new_callable=AsyncMock,
            side_effect=RuntimeError("DB down"),
        ):
            result = await tools.list_memories()

            assert "Failed to list memories" in result
            assert "DB down" in result

    @pytest.mark.asyncio
    async def test_get_memory(self, tools: MemoryTools) -> None:
        """Test that get_memory retrieves a single memory by ID."""
        mock_result = {"id": "abc-123", "memory": "User likes Python"}

        with patch(
            "mindroom.custom_tools.memory.get_agent_memory",
            new_callable=AsyncMock,
            return_value=mock_result,
        ) as mock_get:
            result = await tools.get_memory("abc-123")

            mock_get.assert_called_once_with(
                "abc-123",
                "test_agent",
                tools._storage_path,
                tools._config,
                tools._runtime_paths,
                execution_identity=None,
            )
            assert "[id=abc-123]" in result
            assert "User likes Python" in result

    @pytest.mark.asyncio
    async def test_get_memory_not_found(self, tools: MemoryTools) -> None:
        """Test that get_memory returns a message when memory not found."""
        with patch(
            "mindroom.custom_tools.memory.get_agent_memory",
            new_callable=AsyncMock,
            return_value=None,
        ):
            result = await tools.get_memory("nonexistent")

            assert "No memory found" in result

    @pytest.mark.asyncio
    async def test_get_memory_error(self, tools: MemoryTools) -> None:
        """Test that get_memory handles errors gracefully."""
        with patch(
            "mindroom.custom_tools.memory.get_agent_memory",
            new_callable=AsyncMock,
            side_effect=RuntimeError("DB down"),
        ):
            result = await tools.get_memory("abc-123")

            assert "Failed to get memory" in result
            assert "DB down" in result

    @pytest.mark.asyncio
    async def test_update_memory(self, tools: MemoryTools) -> None:
        """Test that update_memory updates a memory by ID."""
        with patch(
            "mindroom.custom_tools.memory.update_agent_memory",
            new_callable=AsyncMock,
        ) as mock_update:
            result = await tools.update_memory("abc-123", "Updated content")

            mock_update.assert_called_once_with(
                "abc-123",
                "Updated content",
                "test_agent",
                tools._storage_path,
                tools._config,
                tools._runtime_paths,
                execution_identity=None,
            )
            assert "Updated memory" in result
            assert "[id=abc-123]" in result
            assert "Updated content" in result

    @pytest.mark.asyncio
    async def test_update_memory_error(self, tools: MemoryTools) -> None:
        """Test that update_memory handles errors gracefully."""
        with patch(
            "mindroom.custom_tools.memory.update_agent_memory",
            new_callable=AsyncMock,
            side_effect=RuntimeError("Not found"),
        ):
            result = await tools.update_memory("abc-123", "new content")

            assert "Failed to update memory" in result
            assert "Not found" in result

    @pytest.mark.asyncio
    async def test_delete_memory(self, tools: MemoryTools) -> None:
        """Test that delete_memory deletes a memory by ID."""
        with patch(
            "mindroom.custom_tools.memory.delete_agent_memory",
            new_callable=AsyncMock,
        ) as mock_delete:
            result = await tools.delete_memory("abc-123")

            mock_delete.assert_called_once_with(
                "abc-123",
                "test_agent",
                tools._storage_path,
                tools._config,
                tools._runtime_paths,
                execution_identity=None,
            )
            assert "Deleted memory" in result
            assert "[id=abc-123]" in result

    @pytest.mark.asyncio
    async def test_delete_memory_error(self, tools: MemoryTools) -> None:
        """Test that delete_memory handles errors gracefully."""
        with patch(
            "mindroom.custom_tools.memory.delete_agent_memory",
            new_callable=AsyncMock,
            side_effect=RuntimeError("Not found"),
        ):
            result = await tools.delete_memory("abc-123")

            assert "Failed to delete memory" in result
            assert "Not found" in result


class TestMemoryToolRegistration:
    """Test that the memory tool is properly registered in the metadata registry."""

    def test_memory_in_tool_metadata(self) -> None:
        """Test that memory tool appears in the metadata registry."""
        assert "memory" in TOOL_METADATA
        meta = TOOL_METADATA["memory"]
        assert meta.display_name == "Agent Memory"
        assert meta.status.value == "available"
        assert meta.setup_type.value == "none"
        assert meta.category.value == "productivity"
