"""Tests for handoff history duplication fix (Issue #2171).

These tests verify that when nest_handoff_history is enabled,
function_call and function_call_output items are NOT duplicated
in the input sent to the next agent.
"""

import json
from typing import Any, cast

import pytest
from openai.types.responses import (
    ResponseFunctionToolCall,
    ResponseOutputMessage,
    ResponseOutputText,
)
from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary

from agents import Agent, RunConfig, Runner, function_tool, handoff
from agents.handoffs import HandoffInputData, nest_handoff_history
from agents.items import (
    HandoffCallItem,
    HandoffOutputItem,
    MessageOutputItem,
    ReasoningItem,
    ToolApprovalItem,
    ToolCallItem,
    ToolCallOutputItem,
)

from .fake_model import FakeModel
from .test_responses import get_function_tool_call, get_handoff_tool_call, get_text_message


def _create_mock_agent() -> Agent:
    """Create a mock agent for testing."""
    return Agent(name="test_agent")


def _create_tool_call_item(agent: Agent) -> ToolCallItem:
    """Create a mock ToolCallItem."""
    raw_item = ResponseFunctionToolCall(
        id="call_tool_123",
        call_id="call_tool_123",
        name="get_weather",
        arguments='{"city": "London"}',
        type="function_call",
    )
    return ToolCallItem(agent=agent, raw_item=raw_item, type="tool_call_item")


def _create_tool_output_item(agent: Agent) -> ToolCallOutputItem:
    """Create a mock ToolCallOutputItem."""
    raw_item = {
        "type": "function_call_output",
        "call_id": "call_tool_123",
        "output": "Sunny, 22°C",
    }
    return ToolCallOutputItem(
        agent=agent,
        raw_item=raw_item,
        output="Sunny, 22°C",
        type="tool_call_output_item",
    )


def _create_handoff_call_item(agent: Agent) -> HandoffCallItem:
    """Create a mock HandoffCallItem."""
    raw_item = ResponseFunctionToolCall(
        id="call_handoff_456",
        call_id="call_handoff_456",
        name="transfer_to_agent_b",
        arguments="{}",
        type="function_call",
    )
    return HandoffCallItem(agent=agent, raw_item=raw_item, type="handoff_call_item")


def _create_handoff_output_item(agent: Agent[Any]) -> HandoffOutputItem:
    """Create a mock HandoffOutputItem."""
    raw_item: dict[str, str] = {
        "type": "function_call_output",
        "call_id": "call_handoff_456",
        "output": '{"assistant": "agent_b"}',
    }
    return HandoffOutputItem(
        agent=agent,
        raw_item=cast(Any, raw_item),
        source_agent=agent,
        target_agent=agent,
        type="handoff_output_item",
    )


def _create_message_item(agent: Agent) -> MessageOutputItem:
    """Create a mock MessageOutputItem."""
    raw_item = ResponseOutputMessage(
        id="msg_123",
        content=[ResponseOutputText(text="Hello!", type="output_text", annotations=[])],
        role="assistant",
        status="completed",
        type="message",
    )
    return MessageOutputItem(agent=agent, raw_item=raw_item, type="message_output_item")


def _create_reasoning_item(agent: Agent) -> ReasoningItem:
    """Create a mock ReasoningItem."""
    raw_item = ResponseReasoningItem(
        id="reasoning_123",
        type="reasoning",
        summary=[Summary(text="Thinking about handoff", type="summary_text")],
    )
    return ReasoningItem(agent=agent, raw_item=raw_item, type="reasoning_item")


def _create_tool_approval_item(agent: Agent) -> ToolApprovalItem:
    """Create a mock ToolApprovalItem."""
    raw_item = {
        "type": "function_call",
        "call_id": "call_tool_approve",
        "name": "needs_approval",
        "arguments": "{}",
    }
    return ToolApprovalItem(agent=agent, raw_item=raw_item)


class TestHandoffHistoryDuplicationFix:
    """Tests for Issue #2171: nest_handoff_history duplication fix."""

    def test_pre_handoff_tool_items_are_filtered(self):
        """Verify ToolCallItem and ToolCallOutputItem in pre_handoff_items are filtered.

        These items should NOT appear in the filtered output because they are
        already included in the summary message.
        """
        agent = _create_mock_agent()

        handoff_data = HandoffInputData(
            input_history=({"role": "user", "content": "Hello"},),
            pre_handoff_items=(
                _create_tool_call_item(agent),
                _create_tool_output_item(agent),
            ),
            new_items=(),
        )

        nested = nest_handoff_history(handoff_data)

        # pre_handoff_items should be empty (tool items filtered)
        assert len(nested.pre_handoff_items) == 0, (
            "ToolCallItem and ToolCallOutputItem should be filtered from pre_handoff_items"
        )

        # Summary should contain the conversation
        assert len(nested.input_history) == 1
        first_item = nested.input_history[0]
        assert isinstance(first_item, dict)
        assert "<CONVERSATION HISTORY>" in str(first_item.get("content", ""))

    def test_tool_approval_items_are_skipped(self):
        """Verify ToolApprovalItem does not break handoff history mapping."""
        agent = _create_mock_agent()

        handoff_data = HandoffInputData(
            input_history=({"role": "user", "content": "Hello"},),
            pre_handoff_items=(_create_tool_approval_item(agent),),
            new_items=(),
        )

        nested = nest_handoff_history(handoff_data)

        assert isinstance(nested.input_history, tuple)
        assert len(nested.pre_handoff_items) == 0
        assert nested.input_items == ()

    def test_pre_handoff_reasoning_items_are_filtered(self):
        """Verify ReasoningItem in pre_handoff_items is filtered.

        Reasoning is represented in the summary transcript and should not be
        forwarded as a raw item.
        """
        agent = _create_mock_agent()

        handoff_data = HandoffInputData(
            input_history=({"role": "user", "content": "Hello"},),
            pre_handoff_items=(_create_reasoning_item(agent),),
            new_items=(),
        )

        nested = nest_handoff_history(handoff_data)

        assert len(nested.pre_handoff_items) == 0
        first_item = nested.input_history[0]
        assert isinstance(first_item, dict)
        summary = str(first_item.get("content", ""))
        assert "reasoning" in summary

    def test_new_items_handoff_output_is_filtered_for_input(self):
        """Verify HandoffOutputItem in new_items is filtered from input_items.

        The HandoffOutputItem is a function_call_output which would be duplicated.
        It should be filtered from input_items but preserved in new_items.
        """
        agent = _create_mock_agent()

        handoff_data = HandoffInputData(
            input_history=({"role": "user", "content": "Hello"},),
            pre_handoff_items=(),
            new_items=(
                _create_handoff_call_item(agent),
                _create_handoff_output_item(agent),
            ),
        )

        nested = nest_handoff_history(handoff_data)

        # new_items should still have both items (for session history)
        assert len(nested.new_items) == 2, "new_items should preserve all items for session history"

        # input_items should be populated and filtered
        assert nested.input_items is not None, "input_items should be populated"

        # input_items should NOT contain HandoffOutputItem (it's function_call_output)
        has_handoff_output = any(isinstance(item, HandoffOutputItem) for item in nested.input_items)
        assert not has_handoff_output, "HandoffOutputItem should be filtered from input_items"

    def test_message_items_are_preserved_in_new_items(self):
        """Verify MessageOutputItem in new_items is preserved.

        Message items have a 'role' and should NOT be filtered from input_items.
        Note: pre_handoff_items are converted to summary text regardless of type.
        """
        agent = _create_mock_agent()

        handoff_data = HandoffInputData(
            input_history=({"role": "user", "content": "Hello"},),
            pre_handoff_items=(),  # pre_handoff items go into summary
            new_items=(_create_message_item(agent),),
        )

        nested = nest_handoff_history(handoff_data)

        # Message items should be preserved in new_items
        assert len(nested.new_items) == 1, "MessageOutputItem should be preserved in new_items"
        # And in input_items (since it has a role)
        assert nested.input_items is not None
        assert len(nested.input_items) == 1, "MessageOutputItem should be preserved in input_items"
        assert isinstance(nested.input_items[0], MessageOutputItem)

    def test_reasoning_items_are_filtered_from_input_items(self):
        """Verify ReasoningItem in new_items is filtered from input_items.

        Reasoning is summarized in the conversation transcript and should not be
        forwarded verbatim in nested handoff model input.
        """
        agent = _create_mock_agent()

        handoff_data = HandoffInputData(
            input_history=({"role": "user", "content": "Hello"},),
            pre_handoff_items=(),
            new_items=(
                _create_reasoning_item(agent),
                _create_handoff_call_item(agent),
                _create_handoff_output_item(agent),
            ),
        )

        nested = nest_handoff_history(handoff_data)

        assert nested.input_items is not None
        has_reasoning = any(isinstance(item, ReasoningItem) for item in nested.input_items)
        assert not has_reasoning, "ReasoningItem should be filtered from input_items"

        first_item = nested.input_history[0]
        assert isinstance(first_item, dict)
        summary = str(first_item.get("content", ""))
        assert "reasoning" in summary

    def test_summary_contains_filtered_items_as_text(self):
        """Verify the summary message contains the filtered tool items as text.

        This ensures observability - the items are not lost, just converted to text.
        """
        agent = _create_mock_agent()

        handoff_data = HandoffInputData(
            input_history=({"role": "user", "content": "Hello"},),
            pre_handoff_items=(
                _create_tool_call_item(agent),
                _create_tool_output_item(agent),
            ),
            new_items=(),
        )

        nested = nest_handoff_history(handoff_data)

        first_item = nested.input_history[0]
        assert isinstance(first_item, dict)
        summary = str(first_item.get("content", ""))

        # Summary should contain function_call reference
        assert "function_call" in summary or "get_weather" in summary, (
            "Summary should contain the tool call that was filtered"
        )

    def test_input_items_field_exists_after_nesting(self):
        """Verify the input_items field is populated after nest_handoff_history.

        This is the key field that separates model input from session history.
        """
        agent = _create_mock_agent()

        handoff_data = HandoffInputData(
            input_history=({"role": "user", "content": "Hello"},),
            pre_handoff_items=(),
            new_items=(_create_handoff_call_item(agent),),
        )

        nested = nest_handoff_history(handoff_data)

        assert nested.input_items is not None, (
            "input_items should be populated after nest_handoff_history"
        )

    def test_full_handoff_scenario_no_duplication(self):
        """Full end-to-end test of the handoff scenario from Issue #2171.

        Simulates: User -> Agent does tool call -> Agent hands off to next agent
        Verifies: Next agent receives summary only, no duplicate raw items.
        """
        agent = _create_mock_agent()

        # Full scenario: tool call in pre_handoff, handoff in new_items
        handoff_data = HandoffInputData(
            input_history=({"role": "user", "content": "What's the weather?"},),
            pre_handoff_items=(
                _create_tool_call_item(agent),  # function_call
                _create_tool_output_item(agent),  # function_call_output
            ),
            new_items=(
                _create_message_item(agent),  # assistant message
                _create_handoff_call_item(agent),  # function_call (handoff)
                _create_handoff_output_item(agent),  # function_call_output (handoff)
            ),
        )

        nested = nest_handoff_history(handoff_data)

        # Count what would be sent to the model
        total_model_items = (
            len(nested.input_history)  # Summary
            + len(nested.pre_handoff_items)  # Filtered pre-handoff
            + len(nested.input_items or [])  # Filtered new items
        )

        # Before fix: would have 6+ items (summary + raw tool items)
        # After fix: should have ~2 items (summary + message)
        assert total_model_items <= 3, (
            f"Model should receive at most 3 items (summary + messages), got {total_model_items}"
        )

        # Verify no raw function_call_output items in model input
        all_input_items = list(nested.pre_handoff_items) + list(nested.input_items or [])
        function_call_outputs = [
            item
            for item in all_input_items
            if isinstance(item, ToolCallOutputItem | HandoffOutputItem)
        ]
        assert len(function_call_outputs) == 0, (
            "No function_call_output items should be in model input"
        )


@pytest.mark.asyncio
async def test_to_input_list_normalized_uses_filtered_continuation_after_nested_handoff() -> None:
    triage_model = FakeModel()
    delegate_model = FakeModel()

    delegate = Agent(name="delegate", model=delegate_model)
    triage = Agent(name="triage", model=triage_model, handoffs=[delegate])

    triage_model.add_multiple_turn_outputs(
        [[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
    )
    delegate_model.add_multiple_turn_outputs(
        [
            [get_text_message("resolution")],
            [get_text_message("followup answer")],
        ]
    )

    result = await Runner.run(
        triage,
        input="user_question",
        run_config=RunConfig(nest_handoff_history=True),
    )

    preserve_all_input = result.to_input_list()
    normalized_input = result.to_input_list(mode="normalized")
    preserve_all_types = [
        item.get("type", "message") for item in preserve_all_input if isinstance(item, dict)
    ]
    normalized_types = [
        item.get("type", "message") for item in normalized_input if isinstance(item, dict)
    ]

    assert len(preserve_all_input) == 5
    assert "function_call" in preserve_all_types
    assert "function_call_output" in preserve_all_types
    assert len(normalized_input) == 3
    assert "function_call" not in normalized_types
    assert "function_call_output" not in normalized_types

    follow_up_input = normalized_input + [{"role": "user", "content": "follow up?"}]
    follow_up_result = await Runner.run(delegate, input=follow_up_input)

    assert follow_up_result.final_output == "followup answer"
    assert delegate_model.last_turn_args["input"] == follow_up_input


@pytest.mark.asyncio
async def test_to_input_list_normalized_keeps_delegate_tool_items_after_nested_handoff() -> None:
    async def lookup_weather(city: str) -> str:
        return f"weather:{city}"

    triage_model = FakeModel()
    delegate_model = FakeModel()

    delegate = Agent(
        name="delegate",
        model=delegate_model,
        tools=[function_tool(lookup_weather, name_override="lookup_weather")],
    )
    triage = Agent(name="triage", model=triage_model, handoffs=[delegate])

    triage_model.add_multiple_turn_outputs(
        [[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
    )
    delegate_model.add_multiple_turn_outputs(
        [
            [
                get_text_message("delegate preamble"),
                get_function_tool_call("lookup_weather", json.dumps({"city": "Tokyo"})),
            ],
            [get_text_message("resolution")],
        ]
    )

    result = await Runner.run(
        triage,
        input="user_question",
        run_config=RunConfig(nest_handoff_history=True),
    )

    preserve_all_input = result.to_input_list()
    normalized_input = result.to_input_list(mode="normalized")
    preserve_all_function_calls = [
        cast(dict[str, Any], item)
        for item in preserve_all_input
        if isinstance(item, dict) and item.get("type") == "function_call"
    ]
    preserve_all_function_outputs = [
        cast(dict[str, Any], item)
        for item in preserve_all_input
        if isinstance(item, dict) and item.get("type") == "function_call_output"
    ]
    function_calls = [
        cast(dict[str, Any], item)
        for item in normalized_input
        if isinstance(item, dict) and item.get("type") == "function_call"
    ]
    function_outputs = [
        cast(dict[str, Any], item)
        for item in normalized_input
        if isinstance(item, dict) and item.get("type") == "function_call_output"
    ]

    assert len(preserve_all_function_calls) == 2
    assert len(preserve_all_function_outputs) == 2
    assert len(function_calls) == 1
    assert function_calls[0]["name"] == "lookup_weather"
    assert len(function_outputs) == 1
    assert function_outputs[0]["output"] == "weather:Tokyo"


@pytest.mark.asyncio
async def test_to_input_list_normalized_uses_custom_filter_input_items() -> None:
    def keep_messages_only(data: HandoffInputData) -> HandoffInputData:
        return data.clone(
            input_items=tuple(
                item for item in data.new_items if isinstance(item, MessageOutputItem)
            )
        )

    triage_model = FakeModel()
    delegate_model = FakeModel()

    delegate = Agent(name="delegate", model=delegate_model)
    triage = Agent(
        name="triage",
        model=triage_model,
        handoffs=[handoff(delegate, input_filter=keep_messages_only)],
    )

    triage_model.add_multiple_turn_outputs(
        [[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
    )
    delegate_model.add_multiple_turn_outputs([[get_text_message("resolution")]])

    result = await Runner.run(triage, input="user_question")
    preserve_all_input = result.to_input_list()
    normalized_input = result.to_input_list(mode="normalized")
    preserve_all_types = [
        item.get("type", "message") for item in preserve_all_input if isinstance(item, dict)
    ]
    normalized_types = [
        item.get("type", "message") for item in normalized_input if isinstance(item, dict)
    ]

    assert len(preserve_all_input) == 5
    assert "function_call" in preserve_all_types
    assert "function_call_output" in preserve_all_types
    assert len(normalized_input) == 3
    assert "function_call" not in normalized_types
    assert "function_call_output" not in normalized_types
