"""Tests for core + shell compaction behavior.

Spec link: context-compaction.verify-compaction

This file focuses on:
- Core helpers (contracts + behavior):
  - is_overflow
  - build_compaction_prompt
  - apply_compaction
  - find_compaction_boundary
  - prune_tool_outputs
- Shell orchestration edges not covered elsewhere.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import pytest
from deal import PreContractError
from pydantic_ai.agent import AgentRunResult
from pydantic_ai.messages import (
    ModelRequest,
    ModelResponse,
    TextPart,
    ToolReturnPart,
    UserPromptPart,
)
from returns.result import Failure, Success

from anima.core.compaction import (
    apply_compaction,
    build_compaction_prompt,
    find_compaction_boundary,
    is_overflow,
)
from anima.core.conversation import CompactionConfig
from anima.core.prune import prune_tool_outputs
from anima.core.schemas import InternalError
from anima.shell import agent_compaction


def test_is_overflow_threshold_boundary_and_below() -> None:
    assert is_overflow(179_999, 200_000, reserved=20_000) is False
    assert is_overflow(180_000, 200_000, reserved=20_000) is True
    assert is_overflow(199_999, 200_000, reserved=0) is False
    assert is_overflow(200_000, 200_000, reserved=0) is True


def test_is_overflow_contracts_reject_invalid_inputs() -> None:
    with pytest.raises(PreContractError):
        _ = is_overflow(-1, 200_000)
    with pytest.raises(PreContractError):
        _ = is_overflow(1, 0)
    with pytest.raises(PreContractError):
        _ = is_overflow(1, 10, reserved=-1)


def test_build_compaction_prompt_default_includes_rendered_history() -> None:
    history: list[Any] = [
        {"role": "system", "content": "system"},
        {"role": "user", "content": "Hello"},
    ]
    prompt = build_compaction_prompt(history)

    assert "{history}" not in prompt
    assert "hello" in prompt.lower()
    assert "decision" in prompt.lower()
    assert "tool result" in prompt.lower()


def test_build_compaction_prompt_custom_template_replaces_history_placeholder() -> None:
    history: list[Any] = [{"role": "user", "content": "Hello"}]
    custom = "Persona: summarize. HISTORY=\n{history}\nEND"

    prompt = build_compaction_prompt(history, custom_prompt=custom)
    assert "{history}" not in prompt
    assert "Hello" in prompt
    assert prompt.startswith("Persona: summarize")


def test_build_compaction_prompt_custom_without_placeholder_appends_history() -> None:
    history: list[Any] = [{"role": "user", "content": "Hello"}]
    custom = "Persona: summarize concisely."

    prompt = build_compaction_prompt(history, custom_prompt=custom)
    assert prompt.startswith(custom)
    assert "Conversation to summarize" in prompt
    assert "Hello" in prompt


def test_build_compaction_prompt_contract_requires_nonempty_history() -> None:
    with pytest.raises(PreContractError):
        _ = build_compaction_prompt([])


def test_apply_compaction_replaces_old_messages_and_preserves_postconditions() -> None:
    system_message = ModelRequest(parts=[UserPromptPart(content="system")])
    recent_messages = [
        ModelResponse(parts=[TextPart(content="recent reply")]),
        ModelRequest(parts=[UserPromptPart(content="recent user")]),
    ]

    new_history = apply_compaction(
        original_system_request=system_message,
        summary_text="SUMMARY",
        recent_messages=recent_messages,
    )

    assert new_history[0] is system_message
    assert len(new_history) == 2 + len(recent_messages)

    summary_msg = new_history[1]
    assert isinstance(summary_msg, ModelRequest)
    assert isinstance(summary_msg.parts[0].content, str)
    assert "<compaction-summary>" in summary_msg.parts[0].content
    assert "SUMMARY" in summary_msg.parts[0].content

    # Ensure recent message objects are preserved (identity), not re-created.
    assert new_history[2] is recent_messages[0]
    assert new_history[3] is recent_messages[1]


def test_apply_compaction_with_no_recent_messages_keeps_expected_length() -> None:
    system_message = ModelRequest(parts=[UserPromptPart(content="system")])

    new_history = apply_compaction(
        original_system_request=system_message,
        summary_text="EMPTY",
        recent_messages=[],
    )

    assert new_history[0] is system_message
    assert len(new_history) == 2
    assert isinstance(new_history[1], ModelRequest)
    summary_content = new_history[1].parts[0].content
    assert isinstance(summary_content, str)
    assert "<compaction-summary>" in summary_content


def test_find_compaction_boundary_minimum_two_messages() -> None:
    messages = [
        ModelRequest(parts=[UserPromptPart(content="m1")]),
        ModelRequest(parts=[UserPromptPart(content="m2")]),
    ]
    assert find_compaction_boundary(messages) == 1


def test_find_compaction_boundary_uses_last_compaction_summary() -> None:
    system = ModelRequest(parts=[UserPromptPart(content="system")])
    summary_1 = ModelRequest(
        parts=[
            UserPromptPart(content="<compaction-summary>\nOld\n</compaction-summary>")
        ]
    )
    user_after_1 = ModelRequest(parts=[UserPromptPart(content="after1")])
    summary_2 = ModelRequest(
        parts=[
            UserPromptPart(content="<compaction-summary>\nNewer\n</compaction-summary>")
        ]
    )
    user_after_2 = ModelRequest(parts=[UserPromptPart(content="after2")])

    messages = [system, summary_1, user_after_1, summary_2, user_after_2]
    assert find_compaction_boundary(messages) == 4


def test_prune_tool_outputs_preserves_message_count_and_tool_return_boundary() -> None:
    messages: list[Any] = [
        ModelRequest(
            parts=[
                UserPromptPart(content="u1"),
                ToolReturnPart(
                    content="old tool result", tool_name="t", tool_call_id="c1"
                ),
            ]
        ),
        ModelRequest(parts=[UserPromptPart(content="u2")]),
        ModelRequest(
            parts=[
                UserPromptPart(content="u3"),
                ToolReturnPart(
                    content="recent tool result", tool_name="t", tool_call_id="c2"
                ),
            ]
        ),
        ModelRequest(
            parts=[
                UserPromptPart(content="u4"),
                ToolReturnPart(
                    content="recent response tool result",
                    tool_name="t",
                    tool_call_id="c3",
                ),
            ]
        ),
    ]

    pruned = prune_tool_outputs(messages, preserve_after_index=2)
    assert len(pruned) == len(messages)
    assert isinstance(pruned[0].parts[1], ToolReturnPart)
    assert pruned[0].parts[1].content == "[Old tool result content cleared]"

    assert pruned[2].parts[1].content == "recent tool result"
    assert pruned[3].parts[1].content == "recent response tool result"

    for old_part, new_part in zip(messages[0].parts, pruned[0].parts):
        if isinstance(old_part, ToolReturnPart):
            assert isinstance(new_part, ToolReturnPart)
            assert new_part.content == "[Old tool result content cleared]"
            assert new_part.tool_call_id == old_part.tool_call_id
            assert new_part.tool_name == old_part.tool_name

    for old_part, new_part in zip(messages[2].parts, pruned[2].parts):
        if isinstance(old_part, ToolReturnPart):
            assert isinstance(new_part, ToolReturnPart)
            assert new_part.content == old_part.content

    for old_part, new_part in zip(messages[3].parts, pruned[3].parts):
        if isinstance(old_part, ToolReturnPart):
            assert isinstance(new_part, ToolReturnPart)
            assert new_part.content == old_part.content


@dataclass
class _StubAgent:
    calls: list[dict[str, Any]]
    output: str = "summary text"

    async def run(self, user_prompt: str, **kwargs: Any) -> AgentRunResult[str]:
        self.calls.append({"user_prompt": user_prompt, "kwargs": kwargs})
        return AgentRunResult(self.output)


@pytest.mark.asyncio
async def test_shell_run_compaction_prune_false_does_not_call_prune(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    calls: list[dict[str, Any]] = []
    agent: Any = _StubAgent(calls=calls, output="condensed")

    def _boom(*_args: Any, **_kwargs: Any) -> Any:
        raise AssertionError("prune_tool_outputs should not be called")

    monkeypatch.setattr(agent_compaction, "prune_tool_outputs", _boom)
    monkeypatch.setattr(agent_compaction, "append_log", lambda *_a, **_k: Success(None))

    system_message = ModelRequest(parts=[UserPromptPart(content="system")])
    message_history = [
        system_message,
        ModelRequest(parts=[UserPromptPart(content="older")]),
        ModelResponse(parts=[TextPart(content="recent")]),
    ]

    result = await agent_compaction.run_compaction(
        agent=agent,
        message_history=message_history,
        compaction_config=CompactionConfig(auto=True, prune=False, prompt=None),
        persona_compaction_prompt=None,
        instance_id="inst_no_prune",
    )

    assert isinstance(result, Success)


@pytest.mark.asyncio
async def test_shell_run_compaction_llm_failure_logs_and_does_not_crash(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class _FailingAgent:
        async def run(self, *_args: Any, **_kwargs: Any) -> AgentRunResult[str]:
            raise RuntimeError("upstream unavailable")

    log_calls: list[tuple[str, str, dict[str, Any] | None]] = []

    def _fake_append_log(
        instance_id: str,
        event: str,
        details: dict[str, Any] | None = None,
    ) -> Success[None]:
        log_calls.append((instance_id, event, details))
        return Success(None)

    monkeypatch.setattr(agent_compaction, "append_log", _fake_append_log)

    message_history = [
        ModelRequest(parts=[UserPromptPart(content="system")]),
        ModelRequest(parts=[UserPromptPart(content="older")]),
    ]
    agent: Any = _FailingAgent()

    result = await agent_compaction.run_compaction(
        agent=agent,
        message_history=message_history,
        compaction_config=CompactionConfig(),
        persona_compaction_prompt=None,
        instance_id="inst_err",
    )

    assert isinstance(result, Failure)
    err = result.failure()
    assert isinstance(err, InternalError)

    assert log_calls
    assert log_calls[0][0] == "inst_err"
    assert log_calls[0][1] == "compaction_error"
    assert isinstance(log_calls[0][2], dict)
    assert "error" in (log_calls[0][2] or {})
