"""Tests for tool-output budget profile resolution and history degradation.

Spec sources:
- tool-output-governance-budgeting.test-budget-profiles-and-history-degradation
- provider/model profile resolution using configured hard caps + defaults
- per-call vs per-turn accounting and precedence
- current-turn vs historical degradation strategy
- multi-tool same-turn accumulation regression tests

Coverage Requirements:
- Provider/model profile resolution: reject runtime context limit as budget
- Per-call vs per-turn accounting precedence
- Current-turn keeps summary+excerpt; older tool results degrade
- Multi-tool accumulation where individually safe results exceed total budget

These tests verify CONTRACTS and EXPECTED BEHAVIOR.
Tests for unimplemented contracts use pytest.skip to allow graceful skip
until implementation exists in later steps.
This step creates tests/fixtures only - NO runtime budgeting implementation.
"""

from __future__ import annotations

import pytest

# History degradation tests use existing prune_tool_outputs
from anima.core.prune import (
    PLACEHOLDER,
    assemble_tool_output_history,
    prune_tool_outputs,
)

# Attempt to import schema types - skip tests if not yet implemented
try:
    from anima.core.schemas import (
        ToolOutputGovernanceConfig,
        ToolOutputModelProfile,
        ToolOutputProviderProfile,
    )

    SCHEMA_TYPES_AVAILABLE = True
except ImportError:
    SCHEMA_TYPES_AVAILABLE = False

# Attempt to import budget resolver functions - skip tests if not yet implemented
try:
    from anima.shell.model_config import (
        resolve_tool_output_budget_contract,
        resolve_tool_output_precedence,
    )

    BUDGET_RESOLVERS_AVAILABLE = True
except ImportError:
    BUDGET_RESOLVERS_AVAILABLE = False


# =============================================================================
# History Degradation Strategy (using existing prune_tool_outputs)
# =============================================================================


class TestHistoryDegradationStrategy:
    """Tests for current-turn vs historical tool result degradation.

    Strategy:
    - Current turn: May preserve summary + excerpt
    - Older turns: Degrade to summary/placeholder, structure preserved

    These tests use existing prune_tool_outputs implementation.
    """

    def test_current_turn_preserves_structure(self) -> None:
        """Current turn (last N messages) preserves tool result structure."""
        from pydantic_ai.messages import ModelRequest, ToolReturnPart, UserPromptPart

        messages = [
            ModelRequest(
                parts=[
                    UserPromptPart(content="query"),
                    ToolReturnPart(
                        content="current tool result",
                        tool_name="read_file",
                        tool_call_id="call_current",
                    ),
                ]
            ),
        ]

        # Preserve all (preserve_after_index=0 means nothing to prune)
        pruned = prune_tool_outputs(messages, preserve_after_index=0)

        # Current turn tool result preserved
        assert len(pruned) == 1
        assert isinstance(pruned[0].parts[1], ToolReturnPart)
        assert pruned[0].parts[1].content == "current tool result"

    def test_older_turn_drops_content_to_placeholder(self) -> None:
        """Turns before preserve_after_index get content replaced.

        Structure preserved (ToolReturnPart exists), content becomes placeholder.
        """
        from pydantic_ai.messages import ModelRequest, ToolReturnPart, UserPromptPart

        older_message = ModelRequest(
            parts=[
                UserPromptPart(content="older query"),
                ToolReturnPart(
                    content="older tool result with lots of data",
                    tool_name="read_file",
                    tool_call_id="call_old",
                ),
            ]
        )
        current_message = ModelRequest(
            parts=[
                UserPromptPart(content="current query"),
            ]
        )

        messages = [older_message, current_message]

        # Preserve only turn 1 (index 1+), prune turn 0
        pruned = prune_tool_outputs(messages, preserve_after_index=1)

        # Older turn tool result is placeholder
        assert len(pruned) == 2
        assert isinstance(pruned[0].parts[1], ToolReturnPart)
        assert pruned[0].parts[1].content == PLACEHOLDER
        assert pruned[0].parts[1].tool_name == "read_file"
        assert pruned[0].parts[1].tool_call_id == "call_old"

    def test_structure_preserved_with_placeholder(self) -> None:
        """Tool results preserve structure (tool_name, tool_call_id) even when degraded.

        This allows agent to understand which tool was called, even with
        placeholder content.
        """
        from pydantic_ai.messages import ModelRequest, ToolReturnPart, UserPromptPart

        messages = [
            ModelRequest(
                parts=[
                    UserPromptPart(content="q1"),
                    ToolReturnPart(
                        content="result1",
                        tool_name="tool_A",
                        tool_call_id="call_1",
                    ),
                ]
            ),
            ModelRequest(
                parts=[
                    UserPromptPart(content="q2"),
                    ToolReturnPart(
                        content="result2",
                        tool_name="tool_B",
                        tool_call_id="call_2",
                    ),
                ]
            ),
        ]

        # Prune all tool results
        pruned = prune_tool_outputs(messages, preserve_after_index=2)

        # Structure preserved
        assert isinstance(pruned[0].parts[1], ToolReturnPart)
        assert pruned[0].parts[1].tool_name == "tool_A"
        assert pruned[0].parts[1].tool_call_id == "call_1"
        assert pruned[0].parts[1].content == PLACEHOLDER

        assert isinstance(pruned[1].parts[1], ToolReturnPart)
        assert pruned[1].parts[1].tool_name == "tool_B"
        assert pruned[1].parts[1].tool_call_id == "call_2"
        assert pruned[1].parts[1].content == PLACEHOLDER

    def test_mixed_current_and_older_preserves_current(self) -> None:
        """Current turn preserved, older turns degraded.

        Boundary index determines which is current vs older.
        """
        from pydantic_ai.messages import ModelRequest, ToolReturnPart, UserPromptPart

        messages = [
            ModelRequest(
                parts=[
                    UserPromptPart(content="old1"),
                    ToolReturnPart(
                        content="old1",
                        tool_name="t1",
                        tool_call_id="c1",
                    ),
                ]
            ),
            ModelRequest(
                parts=[
                    UserPromptPart(content="old2"),
                    ToolReturnPart(
                        content="old2",
                        tool_name="t2",
                        tool_call_id="c2",
                    ),
                ]
            ),
            ModelRequest(
                parts=[
                    UserPromptPart(content="current"),
                    ToolReturnPart(
                        content="current",
                        tool_name="t3",
                        tool_call_id="c3",
                    ),
                ]
            ),
        ]

        # Preserve only last message
        pruned = prune_tool_outputs(messages, preserve_after_index=2)

        # First two are degraded
        assert pruned[0].parts[1].content == PLACEHOLDER
        assert pruned[1].parts[1].content == PLACEHOLDER

        # Last one preserved
        assert pruned[2].parts[1].content == "current"


class TestToolOutputAssemblyBudgets:
    """Tests for pre-model-call budget-aware assembly behavior."""

    def test_current_turn_preserves_summary_excerpt_and_drops_extra_fields(
        self,
    ) -> None:
        """Current-turn payload keeps summary/excerpt and drops extra oversized fields."""
        from pydantic_ai.messages import ModelRequest, ToolReturnPart

        messages = [
            ModelRequest(
                parts=[
                    ToolReturnPart(
                        content={
                            "summary": "short summary",
                            "excerpt": "short excerpt",
                            "raw": "x" * 2048,
                            "debug": "y" * 2048,
                        },
                        tool_name="directory_tree",
                        tool_call_id="call_1",
                    )
                ]
            )
        ]

        assembled = assemble_tool_output_history(
            messages,
            preserve_after_index=0,
            per_call_cap_bytes=256,
            per_turn_cap_bytes=256,
        )

        content = assembled[0].parts[0].content
        assert isinstance(content, dict)
        assert content["summary"] == "short summary"
        assert content["excerpt"] == "short excerpt"
        assert "raw" not in content
        assert "debug" not in content

    def test_older_history_uses_summary_placeholder_shape(self) -> None:
        """Older history degrades to summary+placeholder while preserving ToolReturnPart."""
        from pydantic_ai.messages import ModelRequest, ToolReturnPart

        messages = [
            ModelRequest(
                parts=[
                    ToolReturnPart(
                        content={"summary": "older summary", "raw": "x" * 2000},
                        tool_name="read_file",
                        tool_call_id="old_call",
                    )
                ]
            ),
            ModelRequest(parts=[]),
        ]

        assembled = assemble_tool_output_history(
            messages,
            preserve_after_index=1,
            per_call_cap_bytes=256,
            per_turn_cap_bytes=256,
        )

        old_content = assembled[0].parts[0].content
        assert isinstance(old_content, dict)
        assert old_content["summary"] == "older summary"
        assert old_content["placeholder"] == PLACEHOLDER

    def test_same_turn_multi_tool_outputs_respect_total_turn_cap(self) -> None:
        """Multiple same-turn tool results cannot exceed cumulative per-turn cap."""
        import json

        from pydantic_ai.messages import ModelRequest, ToolReturnPart

        messages = [
            ModelRequest(
                parts=[
                    ToolReturnPart(
                        content={
                            "summary": "s1",
                            "excerpt": "e1",
                            "raw": "a" * 4000,
                        },
                        tool_name="tool_a",
                        tool_call_id="a1",
                    )
                ]
            ),
            ModelRequest(
                parts=[
                    ToolReturnPart(
                        content={
                            "summary": "s2",
                            "excerpt": "e2",
                            "raw": "b" * 4000,
                        },
                        tool_name="tool_b",
                        tool_call_id="b1",
                    )
                ]
            ),
        ]

        per_turn_cap = 180
        assembled = assemble_tool_output_history(
            messages,
            preserve_after_index=0,
            per_call_cap_bytes=180,
            per_turn_cap_bytes=per_turn_cap,
        )

        total_bytes = 0
        for message in assembled:
            content = message.parts[0].content
            total_bytes += len(
                json.dumps(content, ensure_ascii=False, default=str).encode("utf-8")
            )

        assert total_bytes <= per_turn_cap


# =============================================================================
# Profile Resolution Contract Tests (Skip if implementation not available)
# =============================================================================


@pytest.mark.skipif(
    not BUDGET_RESOLVERS_AVAILABLE,
    reason="resolve_tool_output_budget_contract not yet implemented",
)
class TestToolOutputBudgetContractContract:
    """Contract tests for resolve_tool_output_budget_contract.

    These tests define the expected behavior of budget contract resolution.
    Implementation will be in anima.shell.model_config.resolve_tool_output_budget_contract.
    """

    def test_provider_hard_cap_limits_model_profile(self) -> None:
        """Provider hard_cap caps model profile when provider is lower."""
        provider_profile: dict[str, object] = {
            "hard_cap_per_call_bytes": 50000,
            "hard_cap_per_turn_bytes": 100000,
        }
        model_profile: dict[str, object] = {
            "per_call_cap_bytes": 80000,
            "per_turn_cap_bytes": 160000,
        }

        result = resolve_tool_output_budget_contract(
            context_window_tokens=200000,
            selected_provider_profile=provider_profile,
            selected_model_profile=model_profile,
        )

        # Provider cap wins when lower (conservative)
        assert result["per_call_cap_bytes"] == 50000
        assert result["per_turn_cap_bytes"] == 100000

    def test_context_window_not_used_as_budget_fallback(self) -> None:
        """context_window_tokens is NOT fallback for missing budget caps.

        REJECT: using full runtime context limit as tool-output budget.
        When both profiles missing caps, result should reflect "no cap".
        """
        result = resolve_tool_output_budget_contract(
            context_window_tokens=200000,
            selected_provider_profile=None,
            selected_model_profile=None,
        )

        # Document: current implementation falls back to context_window_tokens
        # Future: should return None or use conservative default (~100KB)
        # Test verifies explicit behavior for now
        assert "per_call_cap_bytes" in result
        assert "per_turn_cap_bytes" in result

    def test_missing_profiles_fall_back_to_conservative_defaults(self) -> None:
        """Missing profiles use fixed conservative defaults, not context window."""
        result = resolve_tool_output_budget_contract(
            context_window_tokens=2_000_000,
            selected_provider_profile=None,
            selected_model_profile=None,
        )

        assert result["per_call_cap_bytes"] == 32000
        assert result["per_turn_cap_bytes"] == 64000

    def test_oversized_runtime_metadata_cannot_exceed_provider_hard_caps(self) -> None:
        """Oversized model/runtime hints are clamped by provider hard caps."""
        result = resolve_tool_output_budget_contract(
            context_window_tokens=2_000_000,
            selected_provider_profile={
                "hard_cap_per_call_bytes": 48000,
                "hard_cap_per_turn_bytes": 96000,
            },
            selected_model_profile={
                "per_call_cap_bytes": 300000,
                "per_turn_cap_bytes": 500000,
                "expand_to_context_window": True,
            },
        )

        assert result["per_call_cap_bytes"] == 48000
        assert result["per_turn_cap_bytes"] == 96000


@pytest.mark.skipif(
    not BUDGET_RESOLVERS_AVAILABLE,
    reason="resolve_tool_output_precedence not yet implemented",
)
class TestToolOutputPrecedenceContract:
    """Contract tests for resolve_tool_output_precedence.

    CLI > env > config precedence for per-call/per-turn caps and profile.
    """

    def test_cli_overrides_env_and_config(self) -> None:
        """CLI takes highest precedence."""
        cli: dict[str, object] = {
            "per_call_cap_bytes": 10000,
            "per_turn_cap_bytes": 20000,
        }
        env: dict[str, object] = {
            "per_call_cap_bytes": 30000,
        }
        config: dict[str, object] = {
            "per_call_cap_bytes": 50000,
        }

        result = resolve_tool_output_precedence(cli=cli, env=env, config=config)

        assert result["per_call_cap_bytes"] == 10000
        assert result["per_turn_cap_bytes"] == 20000

    def test_per_call_and_per_turn_resolve_independently(self) -> None:
        """Each cap type resolves independently from different sources."""
        cli: dict[str, object] = {
            "per_call_cap_bytes": 10000,
        }
        env: dict[str, object] = {
            "per_turn_cap_bytes": 25000,
        }
        config: dict[str, object] = {
            "per_call_cap_bytes": 50000,
            "per_turn_cap_bytes": 100000,
        }

        result = resolve_tool_output_precedence(cli=cli, env=env, config=config)

        # per_call from CLI, per_turn from env
        assert result["per_call_cap_bytes"] == 10000
        assert result["per_turn_cap_bytes"] == 25000


# =============================================================================
# Per-Call vs Per-Turn Accounting Contract (Skip if implementation not available)
# =============================================================================


@pytest.mark.skipif(
    not BUDGET_RESOLVERS_AVAILABLE,
    reason="resolve_tool_output_budget_contract not yet implemented",
)
class TestPerCallPerTurnAccountingContract:
    """Contract tests for per-call vs per-turn budget tracking.

    Per-call: maximum for a single tool result.
    Per-turn: maximum for cumulative tool results in one turn.

    Both caps apply independently - a tool result must satisfy BOTH constraints.
    """

    def test_budget_contract_preserves_both_caps(self) -> None:
        """Budget contract preserves both per-call and per-turn caps.

        Runtime can then enforce both constraints.
        """
        provider_profile: dict[str, object] = {
            "hard_cap_per_call_bytes": 80000,
            "hard_cap_per_turn_bytes": 100000,
        }

        result = resolve_tool_output_budget_contract(
            context_window_tokens=200000,
            selected_provider_profile=provider_profile,
            selected_model_profile=None,
        )

        # Both caps present for runtime enforcement
        assert "per_call_cap_bytes" in result
        assert "per_turn_cap_bytes" in result


# =============================================================================
# Schema Contract Tests (Skip if schemas not available)
# =============================================================================


@pytest.mark.skipif(
    not SCHEMA_TYPES_AVAILABLE, reason="ToolOutputGovernanceConfig not yet implemented"
)
class TestHistoryStrategyConfiguration:
    """Tests for ToolOutputGovernanceConfig.history_strategy."""

    def test_default_history_strategy_is_summary_placeholder(self) -> None:
        """Default history_strategy is 'summary_placeholder'."""
        config = ToolOutputGovernanceConfig()

        assert config.history_strategy == "summary_placeholder"

    def test_history_strategy_only_accepts_summary_placeholder(self) -> None:
        """history_strategy accepts only the contract-defined value."""
        # Valid value
        config = ToolOutputGovernanceConfig(history_strategy="summary_placeholder")
        assert config.history_strategy == "summary_placeholder"

        # Invalid values rejected by Pydantic Literal typing
        with pytest.raises(Exception):  # ValidationError
            ToolOutputGovernanceConfig(history_strategy="rollup")  # type: ignore[arg-type]


@pytest.mark.skipif(
    not SCHEMA_TYPES_AVAILABLE, reason="ToolOutputProviderProfile not yet implemented"
)
class TestToolOutputProviderProfileSchema:
    """Tests for ToolOutputProviderProfile schema (hard caps)."""

    def test_required_hard_caps(self) -> None:
        """Provider profile requires both hard cap fields."""
        profile = ToolOutputProviderProfile(
            hard_cap_per_call_bytes=64000,
            hard_cap_per_turn_bytes=128000,
        )

        assert profile.hard_cap_per_call_bytes == 64000
        assert profile.hard_cap_per_turn_bytes == 128000
        assert profile.prefers_pagination is False

    def test_hard_caps_must_be_positive(self) -> None:
        """Hard caps must be >= 1."""
        with pytest.raises(Exception):  # ValidationError
            ToolOutputProviderProfile(
                hard_cap_per_call_bytes=0,
                hard_cap_per_turn_bytes=128000,
            )


@pytest.mark.skipif(
    not SCHEMA_TYPES_AVAILABLE, reason="ToolOutputModelProfile not yet implemented"
)
class TestToolOutputModelProfileSchema:
    """Tests for ToolOutputModelProfile schema (advisory caps)."""

    def test_optional_caps(self) -> None:
        """Model profile caps are optional."""
        profile = ToolOutputModelProfile()

        assert profile.per_call_cap_bytes is None
        assert profile.per_turn_cap_bytes is None
        assert profile.expand_to_context_window is False

    def test_caps_when_specified(self) -> None:
        """Model profile with specified caps."""
        profile = ToolOutputModelProfile(
            per_call_cap_bytes=120000,
            per_turn_cap_bytes=240000,
        )

        assert profile.per_call_cap_bytes == 120000
        assert profile.per_turn_cap_bytes == 240000

    def test_caps_must_be_positive_if_set(self) -> None:
        """Caps must be >= 1 when specified."""
        with pytest.raises(Exception):  # ValidationError
            ToolOutputModelProfile(per_call_cap_bytes=0)

        with pytest.raises(Exception):  # ValidationError
            ToolOutputModelProfile(per_turn_cap_bytes=-1)


@pytest.mark.skipif(
    not SCHEMA_TYPES_AVAILABLE, reason="ToolOutputGovernanceConfig not yet implemented"
)
class TestToolOutputGovernanceConfigSchema:
    """Tests for ToolOutputGovernanceConfig schema."""

    def test_default_config(self) -> None:
        """Default config has no explicit settings."""
        config = ToolOutputGovernanceConfig()

        assert config.profile is None
        assert config.per_call_cap_bytes is None
        assert config.per_turn_cap_bytes is None
        assert config.history_strategy == "summary_placeholder"
        assert config.provider_profiles == {}
        assert config.model_profiles == {}
        assert config.tool_defaults == {}

    def test_full_config(self) -> None:
        """Full configuration with all sections."""
        config = ToolOutputGovernanceConfig(
            profile="default",
            per_call_cap_bytes=120000,
            per_turn_cap_bytes=240000,
            provider_profiles={
                "openai": ToolOutputProviderProfile(
                    hard_cap_per_call_bytes=64000,
                    hard_cap_per_turn_bytes=128000,
                    prefers_pagination=True,
                )
            },
            model_profiles={
                "gpt-4": ToolOutputModelProfile(
                    per_call_cap_bytes=100000,
                    per_turn_cap_bytes=200000,
                )
            },
        )

        assert config.profile == "default"
        assert config.provider_profiles["openai"].hard_cap_per_call_bytes == 64000
        assert config.model_profiles["gpt-4"].per_call_cap_bytes == 100000

    def test_caps_must_be_positive(self) -> None:
        """per_call/per_turn caps must be >= 1 when specified."""
        with pytest.raises(Exception):  # ValidationError
            ToolOutputGovernanceConfig(per_call_cap_bytes=0)

        with pytest.raises(Exception):  # ValidationError
            ToolOutputGovernanceConfig(per_turn_cap_bytes=-1)
