import inspect
import json
import logging
from typing import Any

import pytest
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from pydantic import BaseModel

from agents import (
    Agent,
    Handoff,
    HandoffInputData,
    MessageOutputItem,
    ModelBehaviorError,
    RunContextWrapper,
    UserError,
    handoff,
)
from agents.run_internal.run_loop import get_handoffs


def message_item(content: str, agent: Agent[Any]) -> MessageOutputItem:
    return MessageOutputItem(
        agent=agent,
        raw_item=ResponseOutputMessage(
            id="123",
            status="completed",
            role="assistant",
            type="message",
            content=[
                ResponseOutputText(text=content, type="output_text", annotations=[], logprobs=[])
            ],
        ),
    )


def get_len(data: HandoffInputData) -> int:
    input_len = len(data.input_history) if isinstance(data.input_history, tuple) else 1
    pre_handoff_len = len(data.pre_handoff_items)
    new_items_len = len(data.new_items)
    return input_len + pre_handoff_len + new_items_len


@pytest.mark.asyncio
async def test_single_handoff_setup():
    agent_1 = Agent(name="test_1")
    agent_2 = Agent(name="test_2", handoffs=[agent_1])

    assert not agent_1.handoffs
    assert agent_2.handoffs == [agent_1]

    assert not (await get_handoffs(agent_1, RunContextWrapper(agent_1)))

    handoff_objects = await get_handoffs(agent_2, RunContextWrapper(agent_2))
    assert len(handoff_objects) == 1
    obj = handoff_objects[0]
    assert obj.tool_name == Handoff.default_tool_name(agent_1)
    assert obj.tool_description == Handoff.default_tool_description(agent_1)
    assert obj.agent_name == agent_1.name


@pytest.mark.asyncio
async def test_multiple_handoffs_setup():
    agent_1 = Agent(name="test_1")
    agent_2 = Agent(name="test_2")
    agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2])

    assert agent_3.handoffs == [agent_1, agent_2]
    assert not agent_1.handoffs
    assert not agent_2.handoffs

    handoff_objects = await get_handoffs(agent_3, RunContextWrapper(agent_3))
    assert len(handoff_objects) == 2
    assert handoff_objects[0].tool_name == Handoff.default_tool_name(agent_1)
    assert handoff_objects[1].tool_name == Handoff.default_tool_name(agent_2)

    assert handoff_objects[0].tool_description == Handoff.default_tool_description(agent_1)
    assert handoff_objects[1].tool_description == Handoff.default_tool_description(agent_2)

    assert handoff_objects[0].agent_name == agent_1.name
    assert handoff_objects[1].agent_name == agent_2.name


def test_default_handoff_tool_name_allows_whitespace_without_warning(
    caplog: pytest.LogCaptureFixture,
):
    agent = Agent(name="Refund agent")

    with caplog.at_level(logging.WARNING):
        tool_name = Handoff.default_tool_name(agent)

    assert tool_name == "transfer_to_refund_agent"
    assert not caplog.records


def test_default_handoff_tool_name_warns_for_non_whitespace_invalid_characters(
    caplog: pytest.LogCaptureFixture,
):
    agent = Agent(name="Refund/agent")

    with caplog.at_level(logging.WARNING):
        tool_name = Handoff.default_tool_name(agent)

    assert tool_name == "transfer_to_refund_agent"
    assert len(caplog.records) == 1
    assert "contains invalid characters for function calling" in caplog.records[0].message


@pytest.mark.asyncio
async def test_custom_handoff_setup():
    agent_1 = Agent(name="test_1")
    agent_2 = Agent(name="test_2")
    agent_3 = Agent(
        name="test_3",
        handoffs=[
            agent_1,
            handoff(
                agent_2,
                tool_name_override="custom_tool_name",
                tool_description_override="custom tool description",
            ),
        ],
    )

    assert len(agent_3.handoffs) == 2
    assert not agent_1.handoffs
    assert not agent_2.handoffs

    handoff_objects = await get_handoffs(agent_3, RunContextWrapper(agent_3))
    assert len(handoff_objects) == 2

    first_handoff = handoff_objects[0]
    assert isinstance(first_handoff, Handoff)
    assert first_handoff.tool_name == Handoff.default_tool_name(agent_1)
    assert first_handoff.tool_description == Handoff.default_tool_description(agent_1)
    assert first_handoff.agent_name == agent_1.name

    second_handoff = handoff_objects[1]
    assert isinstance(second_handoff, Handoff)
    assert second_handoff.tool_name == "custom_tool_name"
    assert second_handoff.tool_description == "custom tool description"
    assert second_handoff.agent_name == agent_2.name


class Foo(BaseModel):
    bar: str


@pytest.mark.asyncio
async def test_handoff_input_type():
    async def _on_handoff(ctx: RunContextWrapper[Any], input: Foo):
        pass

    agent = Agent(name="test")
    obj = handoff(agent, input_type=Foo, on_handoff=_on_handoff)
    for key, value in Foo.model_json_schema().items():
        assert obj.input_json_schema[key] == value

    # Invalid JSON should raise an error
    with pytest.raises(ModelBehaviorError):
        await obj.on_invoke_handoff(RunContextWrapper(agent), "not json")

    # Empty JSON should raise an error
    with pytest.raises(ModelBehaviorError):
        await obj.on_invoke_handoff(RunContextWrapper(agent), "")

    # Valid JSON should call the on_handoff function
    invoked = await obj.on_invoke_handoff(
        RunContextWrapper(agent), Foo(bar="baz").model_dump_json()
    )
    assert invoked == agent


@pytest.mark.asyncio
async def test_on_handoff_called():
    was_called = False

    async def _on_handoff(ctx: RunContextWrapper[Any], input: Foo):
        nonlocal was_called
        was_called = True

    agent = Agent(name="test")
    obj = handoff(agent, input_type=Foo, on_handoff=_on_handoff)
    for key, value in Foo.model_json_schema().items():
        assert obj.input_json_schema[key] == value

    invoked = await obj.on_invoke_handoff(
        RunContextWrapper(agent), Foo(bar="baz").model_dump_json()
    )
    assert invoked == agent

    assert was_called, "on_handoff should have been called"


@pytest.mark.asyncio
async def test_on_handoff_without_input_called():
    was_called = False

    def _on_handoff(ctx: RunContextWrapper[Any]):
        nonlocal was_called
        was_called = True

    agent = Agent(name="test")
    obj = handoff(agent, on_handoff=_on_handoff)

    invoked = await obj.on_invoke_handoff(RunContextWrapper(agent), "")
    assert invoked == agent

    assert was_called, "on_handoff should have been called"


@pytest.mark.asyncio
async def test_async_on_handoff_without_input_called():
    was_called = False

    async def _on_handoff(ctx: RunContextWrapper[Any]):
        nonlocal was_called
        was_called = True

    agent = Agent(name="test")
    obj = handoff(agent, on_handoff=_on_handoff)

    invoked = await obj.on_invoke_handoff(RunContextWrapper(agent), "")
    assert invoked == agent

    assert was_called, "on_handoff should have been called"


@pytest.mark.asyncio
async def test_callable_class_with_async_dunder_call_is_awaited():
    """Callable instances whose ``__call__`` is async must be awaited.

    ``inspect.iscoroutinefunction`` returns ``False`` for the instance itself, so the
    previous implementation invoked it without awaiting and silently dropped the
    coroutine.
    """

    class WithInput:
        def __init__(self) -> None:
            self.calls: list[Foo] = []

        async def __call__(self, ctx: RunContextWrapper[Any], input: Foo) -> None:
            self.calls.append(input)

    class NoInput:
        def __init__(self) -> None:
            self.calls = 0

        async def __call__(self, ctx: RunContextWrapper[Any]) -> None:
            self.calls += 1

    agent = Agent(name="test")

    with_input_cb = WithInput()
    obj_with = handoff(agent, input_type=Foo, on_handoff=with_input_cb)
    await obj_with.on_invoke_handoff(RunContextWrapper(agent), Foo(bar="baz").model_dump_json())
    assert with_input_cb.calls == [Foo(bar="baz")]

    no_input_cb = NoInput()
    obj_no = handoff(agent, on_handoff=no_input_cb)
    await obj_no.on_invoke_handoff(RunContextWrapper(agent), "")
    assert no_input_cb.calls == 1


@pytest.mark.asyncio
async def test_invalid_on_handoff_raises_error():
    was_called = False

    async def _on_handoff(ctx: RunContextWrapper[Any], blah: str):
        nonlocal was_called
        was_called = True  # pragma: no cover

    agent = Agent(name="test")

    with pytest.raises(UserError):
        # Purposely ignoring the type error here to simulate invalid input
        handoff(agent, on_handoff=_on_handoff)  # type: ignore


def test_input_type_without_on_handoff_raises_error():
    """Providing input_type without on_handoff should raise an error."""

    class MyInput(BaseModel):
        reason: str

    agent = Agent(name="test")

    with pytest.raises(UserError, match="You must provide on_handoff when input_type is provided"):
        handoff(agent, input_type=MyInput)  # type: ignore


def test_non_callable_on_handoff_with_input_type_raises_error():
    """Providing a non-callable on_handoff with input_type should raise an error."""

    class MyInput(BaseModel):
        reason: str

    agent = Agent(name="test")

    with pytest.raises(UserError, match="on_handoff must be callable"):
        handoff(agent, on_handoff="not_a_function", input_type=MyInput)  # type: ignore


def test_handoff_input_data():
    agent = Agent(name="test")

    data = HandoffInputData(
        input_history="",
        pre_handoff_items=(),
        new_items=(),
        run_context=RunContextWrapper(context=()),
    )
    assert get_len(data) == 1

    data = HandoffInputData(
        input_history=({"role": "user", "content": "foo"},),
        pre_handoff_items=(),
        new_items=(),
        run_context=RunContextWrapper(context=()),
    )
    assert get_len(data) == 1

    data = HandoffInputData(
        input_history=(
            {"role": "user", "content": "foo"},
            {"role": "assistant", "content": "bar"},
        ),
        pre_handoff_items=(),
        new_items=(),
        run_context=RunContextWrapper(context=()),
    )
    assert get_len(data) == 2

    data = HandoffInputData(
        input_history=({"role": "user", "content": "foo"},),
        pre_handoff_items=(
            message_item("foo", agent),
            message_item("foo2", agent),
        ),
        new_items=(
            message_item("bar", agent),
            message_item("baz", agent),
        ),
        run_context=RunContextWrapper(context=()),
    )
    assert get_len(data) == 5

    data = HandoffInputData(
        input_history=(
            {"role": "user", "content": "foo"},
            {"role": "assistant", "content": "bar"},
        ),
        pre_handoff_items=(message_item("baz", agent),),
        new_items=(
            message_item("baz", agent),
            message_item("qux", agent),
        ),
        run_context=RunContextWrapper(context=()),
    )

    assert get_len(data) == 5


def test_handoff_input_schema_is_strict():
    agent = Agent(name="test")
    obj = handoff(agent, input_type=Foo, on_handoff=lambda ctx, input: None)
    for key, value in Foo.model_json_schema().items():
        assert obj.input_json_schema[key] == value

    assert obj.strict_json_schema, "Input schema should be strict"

    assert (
        "additionalProperties" in obj.input_json_schema
        and not obj.input_json_schema["additionalProperties"]
    ), "Input schema should be strict and have additionalProperties=False"


def test_get_transfer_message_is_valid_json() -> None:
    agent = Agent(name="foo")
    obj = handoff(agent)
    transfer = obj.get_transfer_message(agent)
    assert json.loads(transfer) == {"assistant": agent.name}


def test_handoff_is_enabled_bool():
    """Test that handoff respects is_enabled boolean parameter."""
    agent = Agent(name="test")

    # Test enabled handoff (default)
    handoff_enabled = handoff(agent)
    assert handoff_enabled.is_enabled is True

    # Test explicitly enabled handoff
    handoff_explicit_enabled = handoff(agent, is_enabled=True)
    assert handoff_explicit_enabled.is_enabled is True

    # Test disabled handoff
    handoff_disabled = handoff(agent, is_enabled=False)
    assert handoff_disabled.is_enabled is False


@pytest.mark.asyncio
async def test_handoff_is_enabled_callable():
    """Test that handoff respects is_enabled callable parameter."""
    agent = Agent(name="test")

    # Test callable that returns True
    def always_enabled(ctx: RunContextWrapper[Any], agent: Agent[Any]) -> bool:
        return True

    handoff_callable_enabled = handoff(agent, is_enabled=always_enabled)
    assert callable(handoff_callable_enabled.is_enabled)
    result = handoff_callable_enabled.is_enabled(RunContextWrapper(agent), agent)
    assert inspect.isawaitable(result)
    result = await result
    assert result is True

    # Test callable that returns False
    def always_disabled(ctx: RunContextWrapper[Any], agent: Agent[Any]) -> bool:
        return False

    handoff_callable_disabled = handoff(agent, is_enabled=always_disabled)
    assert callable(handoff_callable_disabled.is_enabled)
    result = handoff_callable_disabled.is_enabled(RunContextWrapper(agent), agent)
    assert inspect.isawaitable(result)
    result = await result
    assert result is False

    # Test async callable
    async def async_enabled(ctx: RunContextWrapper[Any], agent: Agent[Any]) -> bool:
        return True

    handoff_async_enabled = handoff(agent, is_enabled=async_enabled)
    assert callable(handoff_async_enabled.is_enabled)
    result = await handoff_async_enabled.is_enabled(RunContextWrapper(agent), agent)  # type: ignore
    assert result is True


@pytest.mark.asyncio
async def test_handoff_is_enabled_filtering_integration():
    """Integration test that disabled handoffs are filtered out by the runner."""

    # Set up agents
    agent_1 = Agent(name="agent_1")
    agent_2 = Agent(name="agent_2")
    agent_3 = Agent(name="agent_3")

    # Create main agent with mixed enabled/disabled handoffs
    main_agent = Agent(
        name="main_agent",
        handoffs=[
            handoff(agent_1, is_enabled=True),  # enabled
            handoff(agent_2, is_enabled=False),  # disabled
            handoff(agent_3, is_enabled=lambda ctx, agent: True),  # enabled callable
        ],
    )

    context_wrapper = RunContextWrapper(main_agent)

    # Get filtered handoffs using the runner's method
    filtered_handoffs = await get_handoffs(main_agent, context_wrapper)

    # Should only have 2 handoffs (agent_1 and agent_3), agent_2 should be filtered out
    assert len(filtered_handoffs) == 2

    # Check that the correct agents are present
    agent_names = {h.agent_name for h in filtered_handoffs}
    assert agent_names == {"agent_1", "agent_3"}
    assert "agent_2" not in agent_names


@pytest.mark.asyncio
async def test_handoff_is_enabled_sync_callable_false_filters_handoff():
    target_agent = Agent(name="target")
    main_agent = Agent(
        name="main",
        handoffs=[handoff(target_agent, is_enabled=lambda ctx, agent: False)],
    )

    filtered_handoffs = await get_handoffs(main_agent, RunContextWrapper(main_agent))

    assert filtered_handoffs == []


@pytest.mark.asyncio
async def test_handoff_direct_sync_is_enabled_callable_filters_handoff():
    async def invoke_handoff(ctx: RunContextWrapper[Any], input_json: str) -> Agent[Any]:
        _ = (ctx, input_json)
        return Agent(name="target")

    handoff_obj = Handoff(
        tool_name="transfer_to_target",
        tool_description="Transfer to target.",
        input_json_schema={},
        on_invoke_handoff=invoke_handoff,
        agent_name="target",
        is_enabled=lambda ctx, agent: False,
    )
    main_agent = Agent(name="main", handoffs=[handoff_obj])

    filtered_handoffs = await get_handoffs(main_agent, RunContextWrapper(main_agent))

    assert filtered_handoffs == []


class StrictInput(BaseModel):
    name: str
    age: int


@pytest.mark.asyncio
async def test_handoff_strict_json_rejects_type_coercion():
    """With strict_json_schema=True (default), string input for an int field must raise
    ModelBehaviorError instead of being silently coerced."""

    async def _on_handoff(ctx: RunContextWrapper[Any], input: StrictInput):
        pass  # pragma: no cover

    agent = Agent(name="test")
    obj = handoff(agent, input_type=StrictInput, on_handoff=_on_handoff)

    # strict_json_schema defaults to True
    assert obj.strict_json_schema is True

    # age is a string "25" — strict mode should reject this
    malformed_json = '{"name": "Alice", "age": "25"}'
    with pytest.raises(ModelBehaviorError, match="Invalid JSON"):
        await obj.on_invoke_handoff(RunContextWrapper(agent), malformed_json)

    # Correctly typed input should still be accepted
    valid_json = '{"name": "Alice", "age": 25}'
    result = await obj.on_invoke_handoff(RunContextWrapper(agent), valid_json)
    assert result == agent


@pytest.mark.asyncio
async def test_handoff_lenient_json_allows_type_coercion():
    """Without strict validation, Pydantic's default lenient mode silently coerces
    string input for an int field — verifying backward compatibility."""
    from pydantic import TypeAdapter

    from agents.util._json import validate_json

    type_adapter = TypeAdapter(StrictInput)

    # age is a string "25" — lenient mode should coerce it to int 25
    malformed_json = '{"name": "Alice", "age": "25"}'
    result = validate_json(malformed_json, type_adapter, partial=False)
    assert result.name == "Alice"
    assert result.age == 25
    assert isinstance(result.age, int)
