"""Unit/component tests for MCP toolset policy wrapper behavior.

Tests for:
- PolicyWrappedToolset: Wraps AbstractToolset with policy checks
- wrap_mcp_toolsets_with_policy: Policy-aware wrapping function
- read_only policy blocking mutating MCP tool calls with ToolBlockedError

Wrapper-only checks live in this unit file; real stdio transport coverage is in
tests/test_integration_mcp_tool_policy_runtime.py.
"""

from typing import Any
from unittest.mock import MagicMock

import pytest
from pydantic_ai.toolsets import AbstractToolset, ToolsetTool

from anima.core.schemas import SideEffectPolicy
from anima.shell.mcp_toolset_policy import (
    MAX_TOOL_RESULT_TEXT_CHARS,
    PolicyWrappedToolset,
    TRUNCATED_MARKER,
    begin_tool_output_budget_scope,
    end_tool_output_budget_scope,
    wrap_mcp_toolsets_with_policy,
)


class MockToolset(AbstractToolset[Any]):
    """Mock MCP toolset for testing policy enforcement."""

    def __init__(
        self,
        tool_names: list[str],
        tool_annotations: dict[str, dict[str, bool]] | None = None,
    ):
        """Initialize with tool names to expose.

        Args:
            tool_names: List of tool names this toolset provides.
            tool_annotations: Optional per-tool MCP annotations.
        """
        self._tool_names = tool_names
        self._tool_annotations = tool_annotations or {}
        self._id = f"mock-toolset-{id(self)}"

    @property
    def id(self) -> str:
        return self._id

    async def get_tools(self, ctx: Any) -> dict[str, ToolsetTool[Any]]:
        """Return mock tools with specified names."""
        tools: dict[str, ToolsetTool[Any]] = {}
        for name in self._tool_names:
            mock_tool = MagicMock(spec=ToolsetTool)
            mock_tool.name = name
            mock_tool.annotations = self._tool_annotations.get(name)
            tools[name] = mock_tool
        return tools

    async def call_tool(
        self, name: str, tool_args: dict[str, Any], ctx: Any, tool: ToolsetTool[Any]
    ) -> Any:
        """Mock tool call implementation."""
        return f"result from {name}: {tool_args}"


class TestPolicyWrappedToolset:
    """Tests for PolicyWrappedToolset."""

    @pytest.mark.asyncio
    async def test_allow_policy_passes_through(self) -> None:
        """Allow policy passes through all tool calls."""
        toolset = MockToolset(tool_names=["write_file", "delete_record"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        mock_tool = tools["write_file"]

        result = await wrapped.call_tool(
            "write_file", {"path": "/tmp/test"}, ctx, mock_tool
        )
        assert result == "result from write_file: {'path': '/tmp/test'}"

    @pytest.mark.asyncio
    async def test_allow_policy_truncates_oversized_tool_result_text(self) -> None:
        """Large tool output is truncated before returning to model history."""

        class _LargeResultToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return {
                    "tree": "x" * (MAX_TOOL_RESULT_TEXT_CHARS + 128),
                    "tuple": [
                        "a" * (MAX_TOOL_RESULT_TEXT_CHARS + 17),
                        ("b" * (MAX_TOOL_RESULT_TEXT_CHARS + 19),),
                    ],
                    "nested": {
                        "chunk": "y" * (MAX_TOOL_RESULT_TEXT_CHARS + 64),
                        "metadata": {"count": 3, "fields": ["a", "b"]},
                    },
                    "summary": {
                        "text": "s" * (MAX_TOOL_RESULT_TEXT_CHARS + 10),
                    },
                    "excerpt": "e" * (MAX_TOOL_RESULT_TEXT_CHARS + 20),
                }

        toolset = _LargeResultToolset(tool_names=["directory_tree"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        mock_tool = tools["directory_tree"]
        result = await wrapped.call_tool("directory_tree", {}, ctx, mock_tool)

        assert isinstance(result, dict)
        tree = result["tree"]
        nested = result["nested"]["chunk"]

        assert isinstance(tree, str)
        tuple_payload = result["tuple"]
        metadata = result["nested"]["metadata"]
        summary = result["summary"]
        assert isinstance(summary, dict)
        assert "text" in summary
        assert isinstance(nested, str)
        assert len(tree) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert len(nested) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert len(tuple_payload) == 2
        assert isinstance(tuple_payload[0], str)
        assert tuple_payload[0].endswith(TRUNCATED_MARKER)
        assert isinstance(tuple_payload[1][0], str)
        assert tuple_payload[1][0].endswith(TRUNCATED_MARKER)
        assert len(tuple_payload[1][0]) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert len(result["nested"]) == 2
        assert isinstance(metadata, dict)
        assert metadata["count"] == 3
        assert metadata["fields"] == ["a", "b"]
        assert result["excerpt"].endswith(TRUNCATED_MARKER)
        assert summary["text"].endswith(TRUNCATED_MARKER)
        assert tree.endswith(TRUNCATED_MARKER)
        assert nested.endswith(TRUNCATED_MARKER)

    @pytest.mark.asyncio
    async def test_current_turn_budget_scope_enforces_cumulative_multi_tool_cap(
        self,
    ) -> None:
        """Multiple tool calls in one turn cannot exceed total configured cap."""
        import json

        class _StructuredPayloadToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return {
                    "summary": "s" * 120,
                    "excerpt": "e" * 120,
                    "raw": "x" * 8000,
                }

        toolset = _StructuredPayloadToolset(tool_names=["tool_a", "tool_b"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        token = begin_tool_output_budget_scope(
            per_call_cap_bytes=160,
            per_turn_cap_bytes=220,
        )
        try:
            first = await wrapped.call_tool("tool_a", {}, ctx, tools["tool_a"])
            second = await wrapped.call_tool("tool_b", {}, ctx, tools["tool_b"])
        finally:
            end_tool_output_budget_scope(token)

        first_bytes = len(
            json.dumps(first, ensure_ascii=False, default=str).encode("utf-8")
        )
        second_bytes = len(
            json.dumps(second, ensure_ascii=False, default=str).encode("utf-8")
        )

        assert first_bytes + second_bytes <= 220

    @pytest.mark.asyncio
    async def test_allow_policy_truncates_large_unicode_text_payload(self) -> None:
        """Non-ASCII payloads truncate by character count, not formatting."""

        class _LargeUnicodeResultToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return "界" * (MAX_TOOL_RESULT_TEXT_CHARS + 7)

        toolset = _LargeUnicodeResultToolset(tool_names=["unicode_probe"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        mock_tool = tools["unicode_probe"]
        result = await wrapped.call_tool("unicode_probe", {}, ctx, mock_tool)

        assert isinstance(result, str)
        assert len(result) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result.endswith(TRUNCATED_MARKER)

    @pytest.mark.asyncio
    async def test_recursive_truncation_deeply_nested_dict_payload(self) -> None:
        """Deeply nested dicts are recursively truncated at every string level."""

        class _DeeplyNestedToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return {
                    "level1": {
                        "level2": {
                            "level3": {
                                "level4": {
                                    "level5": {
                                        "deep_value": "z"
                                        * (MAX_TOOL_RESULT_TEXT_CHARS + 100),
                                        "short_value": "ok",
                                    }
                                }
                            }
                        }
                    }
                }

        toolset = _DeeplyNestedToolset(tool_names=["deep_nested"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool("deep_nested", {}, ctx, tools["deep_nested"])

        deep_value = result["level1"]["level2"]["level3"]["level4"]["level5"][
            "deep_value"
        ]
        assert isinstance(deep_value, str)
        assert len(deep_value) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert deep_value.endswith(TRUNCATED_MARKER)
        assert (
            result["level1"]["level2"]["level3"]["level4"]["level5"]["short_value"]
            == "ok"
        )

    @pytest.mark.asyncio
    async def test_recursive_truncation_tuple_of_tuples_payload(self) -> None:
        """Tuples preserve type and truncate all nested string elements."""

        class _TupleOfTuplesToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return (
                    ("a" * (MAX_TOOL_RESULT_TEXT_CHARS + 10),),
                    (
                        ("b" * (MAX_TOOL_RESULT_TEXT_CHARS + 20), "short_b"),
                        "c" * (MAX_TOOL_RESULT_TEXT_CHARS + 30),
                    ),
                )

        toolset = _TupleOfTuplesToolset(tool_names=["tuple_nested"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool("tuple_nested", {}, ctx, tools["tuple_nested"])

        assert isinstance(result, tuple)
        assert len(result) == 2
        assert isinstance(result[0], tuple)
        assert result[0][0].endswith(TRUNCATED_MARKER)
        assert isinstance(result[1], tuple)
        assert isinstance(result[1][0], tuple)
        assert result[1][0][0].endswith(TRUNCATED_MARKER)
        assert result[1][0][1] == "short_b"
        assert result[1][1].endswith(TRUNCATED_MARKER)

    @pytest.mark.asyncio
    async def test_recursive_truncation_list_of_dicts_payload(self) -> None:
        """List of dicts truncates all string values within each dict."""

        class _ListOfDictsToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return [
                    {"id": 1, "content": "x" * (MAX_TOOL_RESULT_TEXT_CHARS + 50)},
                    {
                        "id": 2,
                        "content": "y" * (MAX_TOOL_RESULT_TEXT_CHARS + 60),
                        "meta": "small",
                    },
                    {"id": 3, "content": "z"},
                ]

        toolset = _ListOfDictsToolset(tool_names=["list_dicts"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool("list_dicts", {}, ctx, tools["list_dicts"])

        assert isinstance(result, list)
        assert len(result) == 3
        assert result[0]["id"] == 1
        assert result[0]["content"].endswith(TRUNCATED_MARKER)
        assert len(result[0]["content"]) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result[1]["id"] == 2
        assert result[1]["content"].endswith(TRUNCATED_MARKER)
        assert result[1]["meta"] == "small"
        assert result[2]["id"] == 3
        assert result[2]["content"] == "z"

    @pytest.mark.asyncio
    async def test_truncation_preserves_structured_summary_fields(self) -> None:
        """Structured summary/excerpt dicts are preserved, only string content truncated."""

        class _StructuredSummaryToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return {
                    "summary": {
                        "title": "Test Title",
                        "description": "d" * (MAX_TOOL_RESULT_TEXT_CHARS + 100),
                        "metadata": {
                            "author": "test",
                            "count": 42,
                            "tags": ["a", "b"],
                        },
                    },
                    "excerpt": {
                        "lines": ["line1", "line2"],
                        "content": "e" * (MAX_TOOL_RESULT_TEXT_CHARS + 50),
                    },
                }

        toolset = _StructuredSummaryToolset(tool_names=["structured_summary"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool(
            "structured_summary", {}, ctx, tools["structured_summary"]
        )

        assert isinstance(result["summary"], dict)
        assert result["summary"]["title"] == "Test Title"
        assert result["summary"]["description"].endswith(TRUNCATED_MARKER)
        assert len(result["summary"]["description"]) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result["summary"]["metadata"]["author"] == "test"
        assert result["summary"]["metadata"]["count"] == 42
        assert result["summary"]["metadata"]["tags"] == ["a", "b"]
        assert isinstance(result["excerpt"], dict)
        assert result["excerpt"]["lines"] == ["line1", "line2"]
        assert result["excerpt"]["content"].endswith(TRUNCATED_MARKER)

    @pytest.mark.asyncio
    async def test_truncation_multi_field_payload_all_fields_truncated(self) -> None:
        """Multiple oversized string fields in same payload are all truncated."""

        class _MultiFieldToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return {
                    "field1": "a" * (MAX_TOOL_RESULT_TEXT_CHARS + 100),
                    "field2": "b" * (MAX_TOOL_RESULT_TEXT_CHARS + 200),
                    "field3": "c" * (MAX_TOOL_RESULT_TEXT_CHARS + 300),
                }

        toolset = _MultiFieldToolset(tool_names=["multi_field"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool("multi_field", {}, ctx, tools["multi_field"])

        assert result["field1"].endswith(TRUNCATED_MARKER)
        assert len(result["field1"]) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result["field2"].endswith(TRUNCATED_MARKER)
        assert len(result["field2"]) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result["field3"].endswith(TRUNCATED_MARKER)
        assert len(result["field3"]) <= MAX_TOOL_RESULT_TEXT_CHARS

    @pytest.mark.asyncio
    async def test_truncation_exact_limit_string_unmodified(self) -> None:
        """String at exact MAX_TOOL_RESULT_TEXT_CHARS is not truncated."""

        class _ExactLimitToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return {"content": "x" * MAX_TOOL_RESULT_TEXT_CHARS}

        toolset = _ExactLimitToolset(tool_names=["exact_limit"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool("exact_limit", {}, ctx, tools["exact_limit"])

        assert result["content"] == "x" * MAX_TOOL_RESULT_TEXT_CHARS
        assert TRUNCATED_MARKER not in result["content"]

    @pytest.mark.asyncio
    async def test_truncation_one_over_limit_truncated(self) -> None:
        """String at MAX + 1 is truncated."""

        class _OneOverToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return {"content": "x" * (MAX_TOOL_RESULT_TEXT_CHARS + 1)}

        toolset = _OneOverToolset(tool_names=["one_over"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool("one_over", {}, ctx, tools["one_over"])

        assert result["content"].endswith(TRUNCATED_MARKER)
        assert len(result["content"]) <= MAX_TOOL_RESULT_TEXT_CHARS

    @pytest.mark.asyncio
    async def test_current_turn_oversized_result_truncated_at_tool_level(self) -> None:
        """Single tool call returning oversized result is truncated before model receives it.

        This is the per-call cap enforcement: each tool result is capped at MAX_TOOL_RESULT_TEXT_CHARS.
        (Per-turn accumulation accounting is a separate concern for future implementation.)
        """

        class _OversizedResultToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return "x" * (MAX_TOOL_RESULT_TEXT_CHARS + 50000)

        toolset = _OversizedResultToolset(tool_names=["oversized_tool"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool(
            "oversized_tool", {}, ctx, tools["oversized_tool"]
        )

        assert isinstance(result, str)
        assert len(result) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result.endswith(TRUNCATED_MARKER)

    @pytest.mark.asyncio
    async def test_non_ascii_sizing_by_character_not_byte(self) -> None:
        """Non-ASCII characters are counted by Unicode codepoints, not UTF-8 bytes."""

        class _NonAsciiToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                # Create strings longer than MAX_TOOL_RESULT_TEXT_CHARS by character count
                emoji_count = MAX_TOOL_RESULT_TEXT_CHARS + 100
                chinese_count = MAX_TOOL_RESULT_TEXT_CHARS + 50
                return {
                    "emoji": "😀" * emoji_count,
                    "chinese": "中文" * chinese_count,
                }

        toolset = _NonAsciiToolset(tool_names=["non_ascii"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool("non_ascii", {}, ctx, tools["non_ascii"])

        # Both fields should be truncated since they exceed MAX_TOOL_RESULT_TEXT_CHARS chars
        assert len(result["emoji"]) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result["emoji"].endswith(TRUNCATED_MARKER)
        assert len(result["chinese"]) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result["chinese"].endswith(TRUNCATED_MARKER)

    @pytest.mark.asyncio
    async def test_truncation_marker_content_escaped_correctly(self) -> None:
        """Truncation marker is added after content, not inside content."""

        class _MarkerTestToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return {"content": "abc" + "x" * (MAX_TOOL_RESULT_TEXT_CHARS) + "def"}

        toolset = _MarkerTestToolset(tool_names=["marker_test"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool("marker_test", {}, ctx, tools["marker_test"])

        assert "def" not in result["content"]
        assert result["content"].endswith(TRUNCATED_MARKER)
        assert result["content"].startswith("abc")

    @pytest.mark.asyncio
    async def test_truncation_preserves_non_string_types(self) -> None:
        """Non-string values (ints, floats, bools, None) pass through unchanged."""

        class _MixedTypesToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                return {
                    "int_val": 42,
                    "float_val": 3.14,
                    "bool_val": True,
                    "none_val": None,
                    "list_val": [1, 2, 3],
                    "nested": {
                        "inner_int": 100,
                        "inner_str": "x" * (MAX_TOOL_RESULT_TEXT_CHARS + 10),
                    },
                }

        toolset = _MixedTypesToolset(tool_names=["mixed_types"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool("mixed_types", {}, ctx, tools["mixed_types"])

        assert result["int_val"] == 42
        assert result["float_val"] == 3.14
        assert result["bool_val"] is True
        assert result["none_val"] is None
        assert result["list_val"] == [1, 2, 3]
        assert result["nested"]["inner_int"] == 100
        assert result["nested"]["inner_str"].endswith(TRUNCATED_MARKER)

    @pytest.mark.asyncio
    async def test_long_line_without_newlines_truncated_correctly(self) -> None:
        """Single very long line (no newlines) is truncated by character count.

        This tests the edge case where a string has no newlines - the truncation
        should still work correctly by character count, not line count.
        """

        class _LongLineToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                # Single very long line with no newlines
                return "x" * (MAX_TOOL_RESULT_TEXT_CHARS + 10000)

        toolset = _LongLineToolset(tool_names=["long_line"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool("long_line", {}, ctx, tools["long_line"])

        assert isinstance(result, str)
        assert len(result) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result.endswith(TRUNCATED_MARKER)
        # Ensure it's still a valid string starting with 'x's
        assert result.startswith("x")

    @pytest.mark.asyncio
    async def test_truncation_with_newlines_preserves_structure(self) -> None:
        """Strings with newlines are truncated by total character count, not line count."""

        class _NewlinesToolset(MockToolset):
            async def call_tool(
                self,
                name: str,
                tool_args: dict[str, Any],
                ctx: Any,
                tool: ToolsetTool[Any],
            ) -> Any:
                _ = (name, tool_args, ctx, tool)
                # Create content with newlines that exceeds the limit
                line = "line content with some text\n"
                return {"output": line * (MAX_TOOL_RESULT_TEXT_CHARS // len(line) + 10)}

        toolset = _NewlinesToolset(tool_names=["newlines"])
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool("newlines", {}, ctx, tools["newlines"])

        assert len(result["output"]) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result["output"].endswith(TRUNCATED_MARKER)
        # Should preserve newlines in the remaining content
        assert "\n" in result["output"]

    @pytest.mark.asyncio
    async def test_read_only_allows_read_tools(self) -> None:
        """Read_only allows read-only tools (read_*, get_*, etc.)."""
        toolset = MockToolset(tool_names=["read_file", "get_status", "list_agents"])
        wrapped = PolicyWrappedToolset(
            toolset, SideEffectPolicy.read_only, "test-agent"
        )

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)

        # read_* tool
        mock_tool = tools["read_file"]
        result = await wrapped.call_tool(
            "read_file", {"path": "/etc/passwd"}, ctx, mock_tool
        )
        assert result == "result from read_file: {'path': '/etc/passwd'}"

        # get_* tool
        mock_tool = tools["get_status"]
        result = await wrapped.call_tool(
            "get_status", {"agent_id": "test"}, ctx, mock_tool
        )
        assert result == "result from get_status: {'agent_id': 'test'}"

        # list_* tool
        mock_tool = tools["list_agents"]
        result = await wrapped.call_tool("list_agents", {}, ctx, mock_tool)
        assert result == "result from list_agents: {}"

    @pytest.mark.asyncio
    async def test_read_only_blocks_write_tools(self) -> None:
        """Read_only blocks non-read-only tools with ToolBlockedError."""
        toolset = MockToolset(tool_names=["write_file", "delete_record", "create_user"])
        wrapped = PolicyWrappedToolset(
            toolset, SideEffectPolicy.read_only, "test-agent"
        )

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)

        # write_file is blocked
        mock_tool = tools["write_file"]
        result = await wrapped.call_tool(
            "write_file", {"path": "/tmp/test"}, ctx, mock_tool
        )
        assert isinstance(result, dict)
        assert result["error"]["code"] == "TOOL_BLOCKED"

    @pytest.mark.asyncio
    async def test_read_only_hint_annotation_allows_write_named_tool(self) -> None:
        """readOnlyHint annotation overrides name heuristic to allow tool."""
        toolset = MockToolset(
            tool_names=["write_file"],
            tool_annotations={"write_file": {"readOnlyHint": True}},
        )
        wrapped = PolicyWrappedToolset(
            toolset, SideEffectPolicy.read_only, "test-agent"
        )

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool(
            "write_file", {"path": "/tmp/test"}, ctx, tools["write_file"]
        )

        assert result == "result from write_file: {'path': '/tmp/test'}"

    @pytest.mark.asyncio
    async def test_destructive_hint_annotation_blocks_read_named_tool(self) -> None:
        """destructiveHint annotation overrides name heuristic to block tool."""
        toolset = MockToolset(
            tool_names=["read_file"],
            tool_annotations={"read_file": {"destructiveHint": True}},
        )
        wrapped = PolicyWrappedToolset(
            toolset, SideEffectPolicy.read_only, "test-agent"
        )

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool(
            "read_file", {"path": "/tmp/test"}, ctx, tools["read_file"]
        )

        assert isinstance(result, dict)
        assert result["error"]["code"] == "TOOL_BLOCKED"
        assert result["error"]["numeric_code"] == 330
        assert "blocked by read_only policy" in result["error"]["message"]
        assert result["error"]["details"]["tool_name"] == "read_file"
        assert result["error"]["details"]["policy"] == "read_only"
        assert result["error"]["details"]["source"] == "side_effect_policy"

    @pytest.mark.asyncio
    async def test_read_only_blocks_write_tool(self) -> None:
        """Read_only policy blocks write-like tools."""
        toolset = MockToolset(tool_names=["write_file", "ctl_finish"])
        wrapped = PolicyWrappedToolset(
            toolset, SideEffectPolicy.read_only, "test-agent"
        )

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)

        # write_file is blocked
        mock_tool = tools["write_file"]
        result = await wrapped.call_tool(
            "write_file", {"filename": "test.txt"}, ctx, mock_tool
        )
        assert isinstance(result, dict)
        assert result["error"]["code"] == "TOOL_BLOCKED"
        assert "write_file" in result["error"]["message"]

        # ctl_finish is allowed
        mock_tool = tools["ctl_finish"]
        result = await wrapped.call_tool("ctl_finish", {}, ctx, mock_tool)
        assert result == "result from ctl_finish: {}"

    @pytest.mark.asyncio
    async def test_approval_required_allows_read_tools(self) -> None:
        """Approval_required allows read-only tools."""
        toolset = MockToolset(tool_names=["read_file", "get_status"])
        wrapped = PolicyWrappedToolset(
            toolset, SideEffectPolicy.approval_required, "test-agent"
        )

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)

        # read_file is allowed
        mock_tool = tools["read_file"]
        result = await wrapped.call_tool("read_file", {"path": "/test"}, ctx, mock_tool)
        assert result == "result from read_file: {'path': '/test'}"

    @pytest.mark.asyncio
    async def test_approval_required_blocks_write_tools(self) -> None:
        """Approval_required blocks write tools unless approved."""
        toolset = MockToolset(tool_names=["write_file", "delete_record"])
        wrapped = PolicyWrappedToolset(
            toolset, SideEffectPolicy.approval_required, "test-agent"
        )

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)

        # write_file requires approval
        # Note: Since there's no real agent state, this will fail with STATE_READ_ERROR
        # But we can verify the code path by checking the error structure
        mock_tool = tools["write_file"]
        result = await wrapped.call_tool(
            "write_file", {"path": "/tmp/test"}, ctx, mock_tool
        )
        assert isinstance(result, dict)
        # The state read will fail, so we get STATE_READ_ERROR
        # In a real agent context with proper state, it would be APPROVAL_REQUIRED
        assert result["error"]["code"] == "STATE_READ_ERROR"

    @pytest.mark.asyncio
    async def test_wrapped_toolset_inherits_id_suffix(self) -> None:
        """Policy-wrapped toolset ID includes policy suffix."""
        toolset = MockToolset(tool_names=["test_tool"])
        wrapped = PolicyWrappedToolset(
            toolset, SideEffectPolicy.read_only, "test-agent"
        )
        assert wrapped.id.endswith(":policy-read_only")


class TestWrapMcpToolsetsWithPolicy:
    """Tests for wrap_mcp_toolsets_with_policy function."""

    def test_none_input_returns_none(self) -> None:
        """None input returns None."""
        result = wrap_mcp_toolsets_with_policy(None, SideEffectPolicy.read_only, "test")
        assert result is None

    def test_empty_list_returns_empty_list(self) -> None:
        """Empty list returns empty list."""
        result = wrap_mcp_toolsets_with_policy([], SideEffectPolicy.read_only, "test")
        assert result == []

    def test_allow_policy_returns_unchanged(self) -> None:
        """Allow policy returns unchanged toolsets."""
        toolset = MockToolset(tool_names=["test_tool"])
        result = wrap_mcp_toolsets_with_policy(
            [toolset], SideEffectPolicy.allow, "test-agent"
        )
        assert result is not None and len(result) == 1
        assert result[0] is toolset  # Same object, not wrapped

    def test_none_policy_returns_unchanged(self) -> None:
        """None policy returns unchanged toolsets."""
        toolset = MockToolset(tool_names=["test_tool"])
        result = wrap_mcp_toolsets_with_policy([toolset], None, "test-agent")
        assert result is not None and len(result) == 1
        assert result[0] is toolset  # Same object, not wrapped

    def test_read_only_wraps(self) -> None:
        """Read_only policy wraps toolsets."""
        toolset = MockToolset(tool_names=["test_tool"])
        result = wrap_mcp_toolsets_with_policy(
            [toolset], SideEffectPolicy.read_only, "test-agent"
        )
        assert result is not None and len(result) == 1
        assert isinstance(result[0], PolicyWrappedToolset)
        assert result[0].id.endswith(":policy-read_only")

    def test_multiple_toolsets_are_wrapped(self) -> None:
        """Multiple toolsets are all wrapped."""
        toolset1 = MockToolset(tool_names=["tool1"])
        toolset2 = MockToolset(tool_names=["tool2"])
        result = wrap_mcp_toolsets_with_policy(
            [toolset1, toolset2], SideEffectPolicy.read_only, "test-agent"
        )
        assert result is not None and len(result) == 2
        assert all(isinstance(t, PolicyWrappedToolset) for t in result)


class TestIntegrationAgentRunner:
    """Integration tests showing read_only agent blocking mutating MCP tool calls."""

    @pytest.mark.asyncio
    async def test_read_only_agent_blocks_mutating_mcp_call(self) -> None:
        """Read_only agent attempting mutating external MCP tool call gets ToolBlockedError.

        This is the main verification requirement from the task.
        """
        # Setup: Create a mock toolset with a mutating tool
        toolset = MockToolset(tool_names=["write_file", "read_file"])

        # Wrap with read_only policy
        wrapped = PolicyWrappedToolset(
            toolset, SideEffectPolicy.read_only, "test-agent"
        )

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)

        # Attempt: Call write_file (mutating)
        mock_tool = tools["write_file"]
        result = await wrapped.call_tool(
            "write_file", {"path": "/etc/passwd", "content": "hacked"}, ctx, mock_tool
        )

        # Verify: ToolBlockedError is returned
        assert isinstance(result, dict)
        assert result["error"]["code"] == "TOOL_BLOCKED"
        assert result["error"]["numeric_code"] == 330
        assert "write_file blocked by read_only policy" in result["error"]["message"]

        # Verify: read_file (read-only) works fine
        mock_tool = tools["read_file"]
        result = await wrapped.call_tool(
            "read_file", {"path": "/etc/passwd"}, ctx, mock_tool
        )
        assert result == "result from read_file: {'path': '/etc/passwd'}"

    @pytest.mark.asyncio
    async def test_side_effect_policy_block_path_has_side_effect_source_attribution(
        self,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers-loop2."""
        toolset = MockToolset(tool_names=["write_file"])
        wrapped = PolicyWrappedToolset(
            toolset,
            SideEffectPolicy.read_only,
            "test-agent",
        )

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool(
            "write_file", {"path": "/tmp/out"}, ctx, tools["write_file"]
        )

        assert isinstance(result, dict)
        assert result["error"]["code"] == "TOOL_BLOCKED"
        assert result["error"]["details"]["source"] == "side_effect_policy"
        assert "posture" not in result["error"]["details"]

    @pytest.mark.asyncio
    async def test_allow_policy_does_not_emit_persona_source_or_posture_details(
        self,
    ) -> None:
        """Allow policy executes mutating tool and has no persona.tools override path."""
        toolset = MockToolset(tool_names=["write_file"])
        wrapped = PolicyWrappedToolset(
            toolset,
            SideEffectPolicy.allow,
            "test-agent",
        )

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool(
            "write_file", {"path": "/tmp/out"}, ctx, tools["write_file"]
        )

        assert result == "result from write_file: {'path': '/tmp/out'}"
