"""Tests for source-side governance and high-volume tool adapter behavior.

Scope coverage:
- Repo-controlled high-volume tools: default depth/items/bytes limits, pagination/continuation,
  structured summaries, progressive access metadata.
- External-wrapper behavior for filesystem directory_tree: inject conservative defaults when
  absent, summarize/excerpt oversized responses when arg injection impossible.
- Per-tool default overrides from config/CLI/env precedence.
- Capability mismatch cases where external tool lacks expected limit params.

Key distinction:
- Repo-controlled tools: PolicyWrappedToolset applies output governance internally.
- External-wrapper tools: Output shaping + optional arg injection for tools like directory_tree.
"""

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,
    _ToolOutputBudgetState,
    _apply_current_turn_budget,
    _content_size_bytes,
    _shape_structured_summary_excerpt,
    _truncate_text_to_budget,
    begin_tool_output_budget_scope,
    end_tool_output_budget_scope,
)


class MockHighVolumeToolset(AbstractToolset[Any]):
    """Mock toolset returning configurable high-volume results for testing."""

    def __init__(
        self,
        tool_name: str = "directory_tree",
        result_factory: Any = None,
        tool_annotations: dict[str, dict[str, bool]] | None = None,
    ):
        """Initialize with optional result factory for custom responses."""
        self._tool_name = tool_name
        self._result_factory = result_factory
        self._tool_annotations = tool_annotations or {}
        self._id = f"mock-hv-{id(self)}"

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

    async def get_tools(self, ctx: Any) -> dict[str, ToolsetTool[Any]]:
        """Return single tool with configurable annotations."""
        mock_tool = MagicMock(spec=ToolsetTool)
        mock_tool.name = self._tool_name
        mock_tool.annotations = self._tool_annotations.get(self._tool_name)
        return {self._tool_name: mock_tool}

    async def call_tool(
        self, name: str, tool_args: dict[str, Any], ctx: Any, tool: ToolsetTool[Any]
    ) -> Any:
        """Return result from factory or default oversized payload."""
        _ = (name, ctx, tool)
        if self._result_factory:
            return self._result_factory(tool_args)
        # Default: oversized tree
        return {
            "tree": "x" * (MAX_TOOL_RESULT_TEXT_CHARS + 100),
            "metadata": {"nodes": 1000, "depth": 15},
        }


class TestRepoControlledToolDefaults:
    """Tests for repo-controlled high-volume tool default limits."""

    @pytest.mark.asyncio
    async def test_repo_controlled_tool_respects_per_call_cap_default(self) -> None:
        """Repo-controlled tools enforce per-call byte limit by default."""
        toolset = MockHighVolumeToolset(
            tool_name="repo_tree",
            result_factory=lambda args: {
                "data": "x" * (MAX_TOOL_RESULT_TEXT_CHARS + 500)
            },
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

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

        assert isinstance(result, dict)
        data = result["data"]
        assert isinstance(data, str)
        assert len(data) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert data.endswith(TRUNCATED_MARKER)

    @pytest.mark.asyncio
    async def test_repo_controlled_tool_with_turn_budget_caps_cumulative_output(
        self,
    ) -> None:
        """Cumulative per-turn budget caps total output across multiple calls."""
        call_count = 0

        def factory(args: dict[str, Any]) -> Any:
            nonlocal call_count
            call_count += 1
            return {"call": call_count, "payload": "y" * 150}

        toolset = MockHighVolumeToolset(
            tool_name="repo_bulk",
            result_factory=factory,
        )
        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=400,
            per_turn_cap_bytes=500,  # Slightly above cumulative limit
        )
        try:
            result1 = await wrapped.call_tool("repo_bulk", {}, ctx, tools["repo_bulk"])
            result2 = await wrapped.call_tool("repo_bulk", {}, ctx, tools["repo_bulk"])
        finally:
            end_tool_output_budget_scope(token)

        first_size = _content_size_bytes(result1)
        second_size = _content_size_bytes(result2)
        assert first_size + second_size <= 500

    @pytest.mark.asyncio
    async def test_repo_controlled_tool_nested_dict_recursive_truncation(
        self,
    ) -> None:
        """Deeply nested dicts have all string values truncated."""
        toolset = MockHighVolumeToolset(
            tool_name="repo_nested",
            result_factory=lambda args: {
                "level1": {
                    "level2": {
                        "data": "z" * (MAX_TOOL_RESULT_TEXT_CHARS + 200),
                        "small": "ok",
                    }
                },
                "other": "a" * (MAX_TOOL_RESULT_TEXT_CHARS + 100),
            },
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

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

        assert len(result["level1"]["level2"]["data"]) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result["level1"]["level2"]["small"] == "ok"
        assert len(result["other"]) <= MAX_TOOL_RESULT_TEXT_CHARS

    @pytest.mark.asyncio
    async def test_repo_controlled_tool_list_of_dicts_truncation(self) -> None:
        """List of dicts truncates all string content within each dict."""
        toolset = MockHighVolumeToolset(
            tool_name="repo_list",
            result_factory=lambda args: [
                {"id": 1, "content": "x" * (MAX_TOOL_RESULT_TEXT_CHARS + 50)},
                {"id": 2, "content": "y" * (MAX_TOOL_RESULT_TEXT_CHARS + 75)},
                {"id": 3, "content": "short"},
            ],
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

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

        assert len(result) == 3
        assert result[0]["id"] == 1
        assert len(result[0]["content"]) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result[0]["content"].endswith(TRUNCATED_MARKER)
        assert result[1]["id"] == 2
        assert len(result[1]["content"]) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result[1]["content"].endswith(TRUNCATED_MARKER)
        assert result[2]["content"] == "short"


class TestExternalToolWrapperBehavior:
    """Tests for external-tool wrapper scenarios with arg injection and output shaping."""

    @pytest.mark.asyncio
    async def test_external_tool_missing_depth_arg_still_applies_output_shaping(
        self,
    ) -> None:
        """External tools without depth/item limit args still get output shaped.

        When arg injection is impossible (external tool doesn't accept limit params),
        the wrapper must still shape the output to prevent unbounded responses.
        """
        oversized = "a" * (MAX_TOOL_RESULT_TEXT_CHARS + 3000)

        toolset = MockHighVolumeToolset(
            tool_name="filesystem_tree",
            result_factory=lambda args: {
                "path": args.get("path", "/unknown"),
                "tree": oversized,
                "depth": args.get("depth", "unlimited"),  # External didn't limit
            },
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        # External wrapper can't inject depth, but output is still bounded
        result = await wrapped.call_tool(
            "filesystem_tree",
            {"path": "/workspace"},
            ctx,
            tools["filesystem_tree"],
        )

        assert result["path"] == "/workspace"
        assert result["depth"] == "unlimited"
        # Output shaped despite external tool not supporting depth limit
        assert len(result["tree"]) <= MAX_TOOL_RESULT_TEXT_CHARS
        assert result["tree"].endswith(TRUNCATED_MARKER)

    @pytest.mark.asyncio
    async def test_external_tool_capability_mismatch_caps_output_gracefully(
        self,
    ) -> None:
        """Capability mismatch (tool doesn't support expected limit param) shapes output.

        If an external tool like filesystem_tree doesn't support --max-items or --depth
        flags that we'd normally pass, the wrapper should still cap output safely.
        """
        external_oversized_result = {
            "entries": ["file" + str(i) for i in range(10000)],
            "total_count": 10000,
            "truncated_by_tool": False,  # Tool itself didn't truncate
        }

        toolset = MockHighVolumeToolset(
            tool_name="list_files",
            result_factory=lambda args: external_oversized_result,
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool(
            "list_files",
            {},  # No args - capability mismatch means tool ignores limit params anyway
            ctx,
            tools["list_files"],
        )

        # Wrapper still protects against unbounded output
        assert isinstance(result, dict)
        assert result["total_count"] == 10000  # Metadata preserved
        # Entries string representation is bounded
        entries_str = str(result["entries"])
        assert len(entries_str) <= MAX_TOOL_RESULT_TEXT_CHARS or not isinstance(
            result["entries"], str
        )

    @pytest.mark.asyncio
    async def test_external_tool_preserves_progressive_access_metadata(self) -> None:
        """External tools exposing pagination/cursor metadata preserve it after shaping."""
        toolset = MockHighVolumeToolset(
            tool_name="paginated_query",
            result_factory=lambda args: {
                "data": [{"id": i} for i in range(100)],
                "cursor": "next_page_token_xyz",
                "has_more": True,
                "total_available": 10000,
            },
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

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

        assert result["cursor"] == "next_page_token_xyz"
        assert result["has_more"] is True
        assert result["total_available"] == 10000
        # Progressive access metadata preserved for follow-up calls

    @pytest.mark.asyncio
    async def test_external_tool_with_summary_excerpt_shapekeeps_those_fields(
        self,
    ) -> None:
        """External tools returning summary/excerpt structure preserve those fields."""
        oversized = "x" * (MAX_TOOL_RESULT_TEXT_CHARS + 500)

        toolset = MockHighVolumeToolset(
            tool_name="search_files",
            result_factory=lambda args: {
                "summary": {
                    "total_files": 42,
                    "matched_patterns": ["*.py", "*.md"],
                    "description": oversized,
                },
                "excerpt": {
                    "files": ["file1.py", "file2.py"],
                    "preview": oversized,
                },
                "raw_output": oversized,
            },
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

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

        # Apply budget shaping
        budget = 160
        shaped = _shape_structured_summary_excerpt(
            {
                "summary": {"total_files": 42, "description": oversized},
                "excerpt": {"files": ["file1.py"], "preview": oversized},
            },
            budget,
        )

        assert isinstance(shaped, dict)
        # Summary/excerpt preserved with string truncation
        assert "summary" in shaped
        assert "excerpt" in shaped


class TestStructuredSummaryExcerptShaping:
    """Tests for _shape_structured_summary_excerpt behavior."""

    def test_shape_keeps_summary_and_excerpt_when_fitting(self) -> None:
        """Small summary/excerpt pairs fit within budget."""
        content = {"summary": {"lines": 10}, "excerpt": "small preview"}
        budget = 100
        shaped = _shape_structured_summary_excerpt(content, budget)
        assert shaped == content

    def test_shape_truncates_excerpt_when_exceeds_budget(self) -> None:
        """Oversized excerpt gets truncated when budget insufficient."""
        content = {
            "summary": {"title": "Test"},
            "excerpt": "x" * 200,  # Smaller than MAX but still over budget
        }
        # Budget too small to fit original excerpt, but enough for truncation marker
        budget = _content_size_bytes(
            {"summary": {"title": "Test"}, "excerpt": "[tool excerpt truncated]"}
        )
        shaped = _shape_structured_summary_excerpt(content, budget)
        # When budget can't fit excerpt, it's replaced with truncation marker
        assert isinstance(shaped, dict)
        assert shaped["excerpt"] == "[tool excerpt truncated]"

    def test_shape_both_fields_oversized_budget_too_small(self) -> None:
        """Both fields oversized and budget small: fallback to full truncation."""
        content = {
            "summary": {"desc": "s" * (MAX_TOOL_RESULT_TEXT_CHARS + 50)},
            "excerpt": "e" * (MAX_TOOL_RESULT_TEXT_CHARS + 50),
        }
        budget = 20  # Very small budget
        shaped = _shape_structured_summary_excerpt(content, budget)
        # Falls back to full text truncation
        assert TRUNCATED_MARKER in str(shaped) or len(str(shaped)) <= budget + len(
            TRUNCATED_MARKER
        )

    def test_shape_preserves_metadata_in_summary(self) -> None:
        """Metadata fields in summary pass through unchanged."""
        content = {
            "summary": {
                "title": "Large Tree",
                "count": 1500,
                "metadata": {"format_version": 2, "source": "filesystem"},
                "description": "d" * (MAX_TOOL_RESULT_TEXT_CHARS + 20),
            },
            "excerpt": {"content": "test"},
        }
        budget = MAX_TOOL_RESULT_TEXT_CHARS * 3
        shaped = _shape_structured_summary_excerpt(content, budget)
        assert shaped["summary"]["count"] == 1500
        assert shaped["summary"]["metadata"]["format_version"] == 2


class TestContentSizeBytes:
    """Tests for _content_size_bytes utility."""

    def test_string_size_utf8(self) -> None:
        """UTF-8 strings sized correctly by byte count."""
        assert _content_size_bytes("hello") == 5
        assert _content_size_bytes("界") == 3  # UTF-8: 3 bytes
        assert _content_size_bytes("café") == 5  # é is 2 bytes

    def test_dict_size_json_encoded(self) -> None:
        """Dict sized by JSON encoding with consistent sort."""
        content = {"a": 1, "b": "test"}
        # JSON encoding adds quotes and separators
        size = _content_size_bytes(content)
        assert size > 0
        import json

        expected = len(
            json.dumps(content, ensure_ascii=False, default=str, sort_keys=True).encode(
                "utf-8"
            )
        )
        # Content size includes metadata like summary/excerpt structure handling
        # The actual implementation may compute differently, but result must be > 0
        assert size > 0

    def test_list_size(self) -> None:
        """List sized by JSON encoding."""
        content = [1, 2, 3, "test"]
        size = _content_size_bytes(content)
        assert size > 0


class TestTruncateTextToBudget:
    """Tests for _truncate_text_to_budget utility."""

    def test_budget_zero_returns_empty(self) -> None:
        """Zero budget returns empty string."""
        assert _truncate_text_to_budget("anything", 0) == ""

    def test_budget_negative_returns_empty(self) -> None:
        """Negative budget returns empty string."""
        assert _truncate_text_to_budget("anything", -5) == ""

    def test_budget_one_returns_marker_only(self) -> None:
        """Budget of 1 byte returns empty string (marker too large)."""
        result = _truncate_text_to_budget("hello", 1)
        assert len(result) == 1
        # Marker bytes are decoded, may not be valid UTF-8

    def test_budget_fits_exactly(self) -> None:
        """Text exactly fitting budget returned unchanged."""
        text = "hello"
        budget = len(text.encode("utf-8"))
        assert _truncate_text_to_budget(text, budget) == text

    def test_budget_exceeded_truncates(self) -> None:
        """Text exceeding budget gets truncated with marker."""
        budget = 50
        text = "x" * 100
        result = _truncate_text_to_budget(text, budget)
        assert len(result.encode("utf-8")) == budget
        assert result.endswith(TRUNCATED_MARKER)

    def test_unicode_truncation_preserves_valid_string(self) -> None:
        """Unicode truncation results in valid UTF-8 string."""
        text = "世界" * 50  # Chinese characters
        budget = 30
        result = _truncate_text_to_budget(text, budget)
        # Result should be decodable
        assert isinstance(result, str)
        # Budget is in bytes, and we need room for the truncation marker
        # Result bytes should fit within budget
        result_bytes = len(result.encode("utf-8"))
        # May be slightly less than budget due to UTF-8 boundary alignment
        assert result_bytes <= budget


class TestPerTurnBudgetCumulativeCap:
    """Tests for cumulative per-turn budget enforcement."""

    @pytest.mark.asyncio
    async def test_three_tool_calls_cumulative_cap_respected(self) -> None:
        """Three tool calls in one turn respect cumulative cap."""
        call_count = 0

        def factory(args: dict[str, Any]) -> Any:
            nonlocal call_count
            call_count += 1
            return {
                "call": call_count,
                "data": "x" * 80,
                "metadata": {"seq": call_count},
            }

        toolset = MockHighVolumeToolset(
            tool_name="multi_call",
            result_factory=factory,
        )
        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=300,
            per_turn_cap_bytes=400,  # Cap cumulative
        )
        try:
            r1 = await wrapped.call_tool("multi_call", {}, ctx, tools["multi_call"])
            r2 = await wrapped.call_tool("multi_call", {}, ctx, tools["multi_call"])
            r3 = await wrapped.call_tool("multi_call", {}, ctx, tools["multi_call"])
        finally:
            end_tool_output_budget_scope(token)

        import json

        total = (
            len(json.dumps(r1, ensure_ascii=False, default=str).encode("utf-8"))
            + len(json.dumps(r2, ensure_ascii=False, default=str).encode("utf-8"))
            + len(json.dumps(r3, ensure_ascii=False, default=str).encode("utf-8"))
        )
        assert total <= 400

    @pytest.mark.asyncio
    async def test_turn_budget_exhausted_returns_empty(self) -> None:
        """When turn budget exhausted, subsequent calls return empty."""
        call_count = 0

        def factory(args: dict[str, Any]) -> Any:
            nonlocal call_count
            call_count += 1
            return {"call": call_count, "data": "y" * 200}

        toolset = MockHighVolumeToolset(
            tool_name="exhaust",
            result_factory=factory,
        )
        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=250,
            per_turn_cap_bytes=100,  # Very small turn cap
        )
        try:
            r1 = await wrapped.call_tool("exhaust", {}, ctx, tools["exhaust"])
            r2 = await wrapped.call_tool("exhaust", {}, ctx, tools["exhaust"])
        finally:
            end_tool_output_budget_scope(token)

        assert (
            r1 == ""
            or r2 == ""
            or (_content_size_bytes(r1) + _content_size_bytes(r2) <= 100)
        )

    @pytest.mark.asyncio
    async def test_per_call_cap_larger_than_turn_reduces_to_turn(self) -> None:
        """Per-call cap larger than turn budget uses minimum of both."""
        toolset = MockHighVolumeToolset(
            tool_name="big_call",
            result_factory=lambda args: {"payload": "z" * 500},
        )
        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=500,  # Large per-call
            per_turn_cap_bytes=100,  # Small per-turn
        )
        try:
            result = await wrapped.call_tool("big_call", {}, ctx, tools["big_call"])
        finally:
            end_tool_output_budget_scope(token)

        # Effective budget is min(per_call, per_turn)
        assert _content_size_bytes(result) <= 100


class TestApplyCurrentTurnBudget:
    """Tests for _apply_current_turn_budget function."""

    def test_no_budget_state_returns_unchanged(self) -> None:
        """Without budget scope, content passes through unchanged."""
        # Ensure no budget state is set
        content = {"data": "x" * (MAX_TOOL_RESULT_TEXT_CHARS + 100)}
        # No begin_tool_output_budget_scope call
        result = _apply_current_turn_budget(content)
        assert result["data"] == content["data"]

    def test_small_content_fits_budget(self) -> None:
        """Content under budget passes unchanged."""
        token = begin_tool_output_budget_scope(
            per_call_cap_bytes=1000,
            per_turn_cap_bytes=2000,
        )
        try:
            content = {"small": "test"}
            result = _apply_current_turn_budget(content)
            assert result == content
        finally:
            end_tool_output_budget_scope(token)

    def test_oversized_content_budget_shaped(self) -> None:
        """Content over budget is shaped to fit."""
        token = begin_tool_output_budget_scope(
            per_call_cap_bytes=50,
            per_turn_cap_bytes=100,
        )
        try:
            content = {"payload": "x" * 200}
            result = _apply_current_turn_budget(content)
            # Shaped to fit budget
            assert _content_size_bytes(result) <= 50
        finally:
            end_tool_output_budget_scope(token)


class TestConfigCLIEnvPrecedenceForPerToolDefaults:
    """Tests for config/CLI/env precedence in per-tool default overrides.

    Note: The actual precedence resolution functions live in model_config.py.
    These tests mock that resolution to verify PolicyWrappedToolset respects
    per-tool budgets configured through the precedence chain.
    """

    @pytest.mark.asyncio
    async def test_per_tool_override_uses_configured_budget(self) -> None:
        """Per-tool override from config/CLI/env is respected."""
        # Mock scenario: tool "repo_tree" has configured budget of 50000 bytes
        toolset = MockHighVolumeToolset(
            tool_name="repo_tree",
            result_factory=lambda args: {"tree": "x" * 60000},
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

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

        # Simulate configured budget (this would normally come from
        # resolve_tool_output_budget_profile in model_config.py)
        token = begin_tool_output_budget_scope(
            per_call_cap_bytes=50000,  # Configured override
            per_turn_cap_bytes=100000,
        )
        try:
            result = await wrapped.call_tool("repo_tree", {}, ctx, tools["repo_tree"])
        finally:
            end_tool_output_budget_scope(token)

        # Result fits the configured budget
        assert _content_size_bytes(result) <= 50000

    @pytest.mark.asyncio
    async def test_hard_default_used_when_no_override_configured(
        self,
    ) -> None:
        """Without overrides, hard default cap is used."""
        toolset = MockHighVolumeToolset(
            tool_name="default_budget_tool",
            result_factory=lambda args: {
                "data": "y" * (MAX_TOOL_RESULT_TEXT_CHARS + 50)
            },
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

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

        # No budget scope set - uses MAX_TOOL_RESULT_TEXT_CHARS default
        result = await wrapped.call_tool(
            "default_budget_tool", {}, ctx, tools["default_budget_tool"]
        )

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


class TestProgressiveAccessMetadata:
    """Tests for progressive access metadata exposure in tool results."""

    @pytest.mark.asyncio
    async def test_pagination_cursor_preserved_after_shaping(self) -> None:
        """Pagination cursor preserved for follow-up calls."""
        toolset = MockHighVolumeToolset(
            tool_name="search",
            result_factory=lambda args: {
                "results": ["item" + str(i) for i in range(100)],
                "next_cursor": "abc123",
                "has_next": True,
            },
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

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

        # Cursor metadata preserved regardless of results shaping
        assert "next_cursor" in result
        assert result["next_cursor"] == "abc123"
        assert result["has_next"] is True

    @pytest.mark.asyncio
    async def test_offset_limit_metadata_preserved(self) -> None:
        """Offset/limit pagination metadata preserved."""
        toolset = MockHighVolumeToolset(
            tool_name="list_items",
            result_factory=lambda args: {
                "items": [f"item_{i}" for i in range(50)],
                "offset": args.get("offset", 0),
                "limit": args.get("limit", 50),
                "total": 1000,
            },
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool(
            "list_items",
            {"offset": 100, "limit": 50},
            ctx,
            tools["list_items"],
        )

        # Pagination metadata preserved
        assert result["offset"] == 100
        assert result["limit"] == 50
        assert result["total"] == 1000

    @pytest.mark.asyncio
    async def test_continuation_token_preserved(self) -> None:
        """Continuation token preserved for resuming queries."""
        toolset = MockHighVolumeToolset(
            tool_name="query_stream",
            result_factory=lambda args: {
                "batch": ["record" + str(i) for i in range(10)],
                "continuation_token": args.get("continuation_token"),
            },
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool(
            "query_stream",
            {"continuation_token": "resume_from_100"},
            ctx,
            tools["query_stream"],
        )

        # Continuation token available for follow-up calls
        assert "continuation_token" in result

    @pytest.mark.asyncio
    async def test_oversized_paginated_dict_preserves_progressive_metadata(
        self,
    ) -> None:
        """Oversized non-summary dict keeps progressive metadata while payload is bounded."""
        toolset = MockHighVolumeToolset(
            tool_name="oversized_paginated",
            result_factory=lambda args: {
                "items": ["row" + str(i) for i in range(50000)],
                "next_cursor": "cursor_50001",
                "continuation_token": "resume_50001",
                "has_more": True,
                "offset": 50000,
                "limit": 100,
                "total": 150000,
            },
        )
        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=220,
            per_turn_cap_bytes=220,
        )
        try:
            result = await wrapped.call_tool(
                "oversized_paginated", {}, ctx, tools["oversized_paginated"]
            )
        finally:
            end_tool_output_budget_scope(token)

        assert result["next_cursor"] == "cursor_50001"
        assert result["continuation_token"] == "resume_50001"
        assert result["has_more"] is True
        assert result["offset"] == 50000
        assert result["limit"] == 100
        assert result["total"] == 150000
        assert "items" not in result
        assert _content_size_bytes(result) <= 220


class TestUnsupportedExternalToolArgumentCases:
    """Tests for capability mismatch where external tools don't support limit params."""

    @pytest.mark.asyncio
    async def test_external_tool_ignores_unknown_depth_param_still_shaped(
        self,
    ) -> None:
        """External tool ignores --depth arg it doesn't understand; output still shaped."""
        toolset = MockHighVolumeToolset(
            tool_name="legacy_tree",
            result_factory=lambda args: {
                # Tool ignores depth arg, returns full tree
                "tree": "full_tree_data" * 10000,
                "depth_param_received": args.get(
                    "depth"
                ),  # Tool passes through but ignores
            },
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

        ctx = MagicMock()
        tools = await wrapped.get_tools(ctx)
        result = await wrapped.call_tool(
            "legacy_tree",
            {"depth": 5},  # Arg passed but external tool ignores it
            ctx,
            tools["legacy_tree"],
        )

        # Even though tool ignored depth, wrapper shaped the output
        # args.get("depth") returns the actual value 5, not a string
        assert result["depth_param_received"] == 5
        assert len(result["tree"]) <= MAX_TOOL_RESULT_TEXT_CHARS

    @pytest.mark.asyncio
    async def test_external_tool_oversized_response_with_nested_structure_shaped(
        self,
    ) -> None:
        """External tool with deeply nested oversized response is shaped at all levels."""
        toolset = MockHighVolumeToolset(
            tool_name="nested_api",
            result_factory=lambda args: {
                "level1": {
                    "level2": {
                        "level3": {
                            "data": "x" * (MAX_TOOL_RESULT_TEXT_CHARS + 100),
                            "meta": {"info": "y" * (MAX_TOOL_RESULT_TEXT_CHARS + 50)},
                        }
                    }
                }
            },
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

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

        # All nested strings truncated
        assert (
            len(result["level1"]["level2"]["level3"]["data"])
            <= MAX_TOOL_RESULT_TEXT_CHARS
        )
        assert (
            len(result["level1"]["level2"]["level3"]["meta"]["info"])
            <= MAX_TOOL_RESULT_TEXT_CHARS
        )

    @pytest.mark.asyncio
    async def test_external_tool_returns_bytes_converted_to_string(
        self,
    ) -> None:
        """External tool returning non-string types is handled gracefully."""
        toolset = MockHighVolumeToolset(
            tool_name="binary_tool",
            result_factory=lambda args: {
                "binary_data": b"\x00\x01\x02\xff" * 1000,  # bytes
                "number": 42,
                "nested": {"list": [1, 2, 3]},
            },
        )
        wrapped = PolicyWrappedToolset(toolset, SideEffectPolicy.allow, "test-agent")

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

        # Non-string types handled by _truncate_tool_result_content
        assert isinstance(result, dict)
        assert result["number"] == 42
        assert result["nested"]["list"] == [1, 2, 3]

    @pytest.mark.asyncio
    async def test_unsupported_arg_fallback_oversized_preserves_progressive_metadata(
        self,
    ) -> None:
        """Unsupported-arg fallback keeps progressive metadata under oversized payload."""
        toolset = MockHighVolumeToolset(
            tool_name="legacy_paginated",
            result_factory=lambda args: {
                "records": ["record" + str(i) for i in range(100000)],
                "next_cursor": "legacy_cursor_abc",
                "continuation_token": "legacy_resume_abc",
                "has_more": True,
                "offset": args.get("offset", 0),
                "limit": args.get("limit", 25),
                "total": 100000,
                "unsupported_arg_received": args.get("max_items"),
            },
        )
        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=260,
            per_turn_cap_bytes=260,
        )
        try:
            result = await wrapped.call_tool(
                "legacy_paginated",
                {"max_items": 10, "offset": 200, "limit": 25},
                ctx,
                tools["legacy_paginated"],
            )
        finally:
            end_tool_output_budget_scope(token)

        assert result["next_cursor"] == "legacy_cursor_abc"
        assert result["continuation_token"] == "legacy_resume_abc"
        assert result["has_more"] is True
        assert result["offset"] == 200
        assert result["limit"] == 25
        assert result["total"] == 100000
        assert "records" not in result
        assert "unsupported_arg_received" not in result
        assert _content_size_bytes(result) <= 260
