# Copyright (c) Microsoft. All rights reserved.

"""Tests for AG-UI event converter."""

import logging
from typing import Any, cast

import pytest
from agent_framework import ChatResponse

from agent_framework_ag_ui._event_converters import AGUIEventConverter


class TestAGUIEventConverter:
    """Test suite for AGUIEventConverter."""

    def test_run_started_event(self) -> None:
        """Test conversion of RUN_STARTED event."""
        converter = AGUIEventConverter()
        event = {
            "type": "RUN_STARTED",
            "threadId": "thread_123",
            "runId": "run_456",
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.additional_properties is not None
        assert update.additional_properties is not None
        assert update.role == "assistant"
        assert update.additional_properties["thread_id"] == "thread_123"
        assert update.additional_properties["run_id"] == "run_456"
        assert converter.thread_id == "thread_123"
        assert converter.run_id == "run_456"

    def test_text_message_start_event(self) -> None:
        """Test conversion of TEXT_MESSAGE_START event."""
        converter = AGUIEventConverter()
        event = {
            "type": "TEXT_MESSAGE_START",
            "messageId": "msg_789",
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.role == "assistant"
        assert update.message_id == "msg_789"
        assert converter.current_message_id == "msg_789"

    def test_text_message_content_event(self) -> None:
        """Test conversion of TEXT_MESSAGE_CONTENT event."""
        converter = AGUIEventConverter()
        event = {
            "type": "TEXT_MESSAGE_CONTENT",
            "messageId": "msg_1",
            "delta": "Hello",
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.role == "assistant"
        assert update.message_id == "msg_1"
        assert len(update.contents) == 1
        assert update.contents[0].text == "Hello"

    def test_text_message_streaming(self) -> None:
        """Test streaming text across multiple TEXT_MESSAGE_CONTENT events."""
        converter = AGUIEventConverter()
        events = [
            {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Hello"},
            {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": " world"},
            {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "!"},
        ]

        updates = cast(list[Any], [converter.convert_event(event) for event in events])

        assert all(update is not None for update in updates)
        assert all(update.message_id == "msg_1" for update in updates)
        assert updates[0].contents[0].text == "Hello"
        assert updates[1].contents[0].text == " world"
        assert updates[2].contents[0].text == "!"

    def test_text_message_end_event(self) -> None:
        """Test conversion of TEXT_MESSAGE_END event."""
        converter = AGUIEventConverter()
        event = {
            "type": "TEXT_MESSAGE_END",
            "messageId": "msg_1",
        }

        update = converter.convert_event(event)

        assert update is None

    def test_tool_call_start_event(self) -> None:
        """Test conversion of TOOL_CALL_START event."""
        converter = AGUIEventConverter()
        event = {
            "type": "TOOL_CALL_START",
            "toolCallId": "call_123",
            "toolName": "get_weather",
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.role == "assistant"
        assert len(update.contents) == 1
        assert update.contents[0].call_id == "call_123"
        assert update.contents[0].name == "get_weather"
        assert update.contents[0].arguments == ""
        assert converter.current_tool_call_id == "call_123"
        assert converter.current_tool_name == "get_weather"

    def test_tool_call_start_with_tool_call_name(self) -> None:
        """Ensure TOOL_CALL_START with toolCallName still sets the tool name."""
        converter = AGUIEventConverter()
        event = {
            "type": "TOOL_CALL_START",
            "toolCallId": "call_abc",
            "toolCallName": "get_weather",
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.contents[0].name == "get_weather"
        assert converter.current_tool_name == "get_weather"

    def test_tool_call_start_with_tool_call_name_snake_case(self) -> None:
        """Support tool_call_name snake_case field for backwards compatibility."""
        converter = AGUIEventConverter()
        event = {
            "type": "TOOL_CALL_START",
            "toolCallId": "call_snake",
            "tool_call_name": "get_weather",
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.contents[0].name == "get_weather"
        assert converter.current_tool_name == "get_weather"

    def test_tool_call_args_streaming(self) -> None:
        """Test streaming tool arguments across multiple TOOL_CALL_ARGS events."""
        converter = AGUIEventConverter()
        converter.current_tool_call_id = "call_123"
        converter.current_tool_name = "search"

        events = [
            {"type": "TOOL_CALL_ARGS", "delta": '{"query": "'},
            {"type": "TOOL_CALL_ARGS", "delta": 'latest news"}'},
        ]

        updates = cast(list[Any], [converter.convert_event(event) for event in events])

        assert all(update is not None for update in updates)
        assert updates[0].contents[0].arguments == '{"query": "'
        assert updates[1].contents[0].arguments == 'latest news"}'
        assert converter.accumulated_tool_args == '{"query": "latest news"}'

    def test_tool_call_end_event(self) -> None:
        """Test conversion of TOOL_CALL_END event."""
        converter = AGUIEventConverter()
        converter.accumulated_tool_args = '{"location": "Seattle"}'

        event = {
            "type": "TOOL_CALL_END",
            "toolCallId": "call_123",
        }

        update = converter.convert_event(event)

        assert update is None
        assert converter.accumulated_tool_args == ""

    def test_tool_call_result_event(self) -> None:
        """Test conversion of TOOL_CALL_RESULT event."""
        converter = AGUIEventConverter()
        event = {
            "type": "TOOL_CALL_RESULT",
            "toolCallId": "call_123",
            "result": {"temperature": 22, "condition": "sunny"},
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.role == "tool"
        assert len(update.contents) == 1
        assert update.contents[0].call_id == "call_123"
        assert update.contents[0].result == '{"temperature": 22, "condition": "sunny"}'

    def test_run_finished_event(self) -> None:
        """Test conversion of RUN_FINISHED event."""
        converter = AGUIEventConverter()
        converter.thread_id = "thread_123"
        converter.run_id = "run_456"

        event = {
            "type": "RUN_FINISHED",
            "threadId": "thread_123",
            "runId": "run_456",
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.role == "assistant"
        assert update.finish_reason == "stop"
        assert update.additional_properties["thread_id"] == "thread_123"  # type: ignore[index]  # pyrefly: ignore[unsupported-operation]  # ty: ignore[not-subscriptable]
        assert update.additional_properties["run_id"] == "run_456"  # type: ignore[index]  # pyrefly: ignore[unsupported-operation]  # ty: ignore[not-subscriptable]

    def test_run_finished_event_with_interrupt(self) -> None:
        """RUN_FINISHED interrupt metadata is preserved in additional_properties."""
        converter = AGUIEventConverter()
        converter.thread_id = "thread_123"
        converter.run_id = "run_456"

        event = {
            "type": "RUN_FINISHED",
            "threadId": "thread_123",
            "runId": "run_456",
            "interrupt": [{"id": "req_1", "value": {"question": "Continue?"}}],
            "result": {"status": "paused"},
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.additional_properties is not None
        assert update.additional_properties["interrupt"] == [{"id": "req_1", "value": {"question": "Continue?"}}]
        assert update.additional_properties["result"] == {"status": "paused"}

    def test_run_finished_event_with_canonical_interrupt_outcome(self) -> None:
        """RUN_FINISHED outcome.interrupts metadata is preserved in additional_properties."""
        converter = AGUIEventConverter()
        converter.thread_id = "thread_123"
        converter.run_id = "run_456"

        outcome = {
            "type": "interrupt",
            "interrupts": [
                {
                    "id": "req_1",
                    "reason": "input_required",
                    "message": "Choose a value",
                    "responseSchema": {"type": "string"},
                    "metadata": {"agent_framework": {"request_type": "str"}},
                }
            ],
        }
        event = {
            "type": "RUN_FINISHED",
            "threadId": "thread_123",
            "runId": "run_456",
            "outcome": outcome,
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.additional_properties is not None
        assert update.additional_properties["outcome"] == outcome
        assert update.additional_properties["interrupts"] == outcome["interrupts"]

    def test_run_finished_event_with_success_outcome_preserves_normal_completion(self) -> None:
        """Non-interrupt RUN_FINISHED outcome metadata stays a normal stop update."""
        converter = AGUIEventConverter()
        converter.thread_id = "thread_123"
        converter.run_id = "run_456"

        event = {
            "type": "RUN_FINISHED",
            "threadId": "thread_123",
            "runId": "run_456",
            "outcome": {"type": "success"},
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.finish_reason == "stop"
        assert update.additional_properties is not None
        assert update.additional_properties["outcome"] == {"type": "success"}
        assert "interrupts" not in update.additional_properties

    def test_run_finished_event_with_non_dict_outcome_preserves_and_warns(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Malformed non-object outcome metadata is preserved but logged."""
        converter = AGUIEventConverter()
        converter.thread_id = "thread_123"
        converter.run_id = "run_456"

        event = {
            "type": "RUN_FINISHED",
            "threadId": "thread_123",
            "runId": "run_456",
            "outcome": "malformed",
        }

        with caplog.at_level(logging.WARNING, logger="agent_framework_ag_ui._event_converters"):
            update = converter.convert_event(event)

        assert update is not None
        assert update.additional_properties is not None
        assert update.additional_properties["outcome"] == "malformed"
        assert "interrupts" not in update.additional_properties
        assert "RUN_FINISHED outcome should be an object" in caplog.text

    def test_run_error_event(self) -> None:
        """Test conversion of RUN_ERROR event."""
        converter = AGUIEventConverter()
        converter.thread_id = "thread_123"
        converter.run_id = "run_456"

        event = {
            "type": "RUN_ERROR",
            "message": "Connection timeout",
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.role == "assistant"
        assert update.finish_reason == "content_filter"
        assert len(update.contents) == 1
        assert update.contents[0].message == "Connection timeout"
        assert update.contents[0].error_code == "RUN_ERROR"

    def test_unknown_event_type(self) -> None:
        """Test handling of unknown event types."""
        converter = AGUIEventConverter()
        event = {
            "type": "UNKNOWN_EVENT",
            "data": "some data",
        }

        update = converter.convert_event(event)

        assert update is None

    def test_custom_event_conversion(self) -> None:
        """CUSTOM events are converted to update metadata."""
        converter = AGUIEventConverter()
        event = {
            "type": "CUSTOM",
            "name": "progress",
            "value": {"percent": 10},
        }

        update = converter.convert_event(event)

        assert update is not None
        assert update.additional_properties is not None
        assert update.additional_properties["ag_ui_custom_event"]["name"] == "progress"
        assert update.additional_properties["ag_ui_custom_event"]["value"] == {"percent": 10}
        assert update.additional_properties["ag_ui_custom_event"]["raw_type"] == "CUSTOM"

    def test_custom_event_alias_conversion(self) -> None:
        """CUSTOM_EVENT/custom_event aliases map to CUSTOM behavior."""
        converter = AGUIEventConverter()
        events = [
            {"type": "CUSTOM_EVENT", "name": "alias_upper", "value": {"v": 1}},
            {"type": "custom_event", "name": "alias_lower", "value": {"v": 2}},
        ]

        updates = cast(list[Any], [converter.convert_event(event) for event in events])

        assert updates[0] is not None
        assert updates[1] is not None
        assert updates[0].additional_properties["ag_ui_custom_event"]["raw_type"] == "CUSTOM_EVENT"
        assert updates[1].additional_properties["ag_ui_custom_event"]["raw_type"] == "custom_event"

    def test_full_conversation_flow(self) -> None:
        """Test complete conversation flow with multiple event types."""
        converter = AGUIEventConverter()

        events = [
            {"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
            {"type": "TEXT_MESSAGE_START", "messageId": "msg_1"},
            {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "I'll check"},
            {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": " the weather."},
            {"type": "TEXT_MESSAGE_END", "messageId": "msg_1"},
            {"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "get_weather"},
            {"type": "TOOL_CALL_ARGS", "delta": '{"location": "Seattle"}'},
            {"type": "TOOL_CALL_END", "toolCallId": "call_1"},
            {"type": "TOOL_CALL_RESULT", "toolCallId": "call_1", "result": "Sunny, 72°F"},
            {"type": "TEXT_MESSAGE_START", "messageId": "msg_2"},
            {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_2", "delta": "It's sunny!"},
            {"type": "TEXT_MESSAGE_END", "messageId": "msg_2"},
            {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
        ]

        updates = cast(list[Any], [converter.convert_event(event) for event in events])
        non_none_updates = [u for u in updates if u is not None]

        assert len(non_none_updates) == 10
        assert converter.thread_id == "thread_1"
        assert converter.run_id == "run_1"

    def test_multiple_tool_calls(self) -> None:
        """Test handling multiple tool calls in sequence."""
        converter = AGUIEventConverter()

        events = [
            {"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "search"},
            {"type": "TOOL_CALL_ARGS", "delta": '{"query": "weather"}'},
            {"type": "TOOL_CALL_END", "toolCallId": "call_1"},
            {"type": "TOOL_CALL_START", "toolCallId": "call_2", "toolName": "fetch"},
            {"type": "TOOL_CALL_ARGS", "delta": '{"url": "http://api.weather.com"}'},
            {"type": "TOOL_CALL_END", "toolCallId": "call_2"},
        ]

        updates = cast(list[Any], [converter.convert_event(event) for event in events])
        non_none_updates = [u for u in updates if u is not None]

        assert len(non_none_updates) == 4
        assert non_none_updates[0].contents[0].name == "search"
        assert non_none_updates[2].contents[0].name == "fetch"

    def test_tool_call_args_must_match_current_tool_call_id(self) -> None:
        """TOOL_CALL_ARGS for another call must not be rebound to the current tool."""
        converter = AGUIEventConverter()

        events = [
            {"type": "TOOL_CALL_START", "toolCallId": "safe", "toolName": "safe_tool"},
            {"type": "TOOL_CALL_START", "toolCallId": "danger", "toolName": "danger_tool"},
            {"type": "TOOL_CALL_ARGS", "toolCallId": "safe", "delta": '{"amount": 100}'},
            {"type": "TOOL_CALL_END", "toolCallId": "safe"},
            {"type": "TOOL_CALL_END", "toolCallId": "danger"},
        ]

        updates = [update for event in events if (update := converter.convert_event(event)) is not None]
        response = ChatResponse.from_updates(updates)
        function_calls = [
            (content.call_id, content.name, content.arguments)
            for message in response.messages
            for content in message.contents
            if content.type == "function_call"
        ]

        assert ("danger", "danger_tool", "") in function_calls
        assert ("danger", "danger_tool", '{"amount": 100}') not in function_calls
        assert all(call[2] != '{"amount": 100}' for call in function_calls)

    def test_tool_call_end_must_match_current_tool_call_id(self) -> None:
        """TOOL_CALL_END for another call must not clear the current call state."""
        converter = AGUIEventConverter()

        converter.convert_event({"type": "TOOL_CALL_START", "toolCallId": "danger", "toolName": "danger_tool"})
        converter.convert_event({"type": "TOOL_CALL_ARGS", "toolCallId": "danger", "delta": '{"amount":'})
        update = converter.convert_event({"type": "TOOL_CALL_END", "toolCallId": "safe"})

        assert update is None
        assert converter.current_tool_call_id == "danger"
        assert converter.current_tool_name == "danger_tool"
        assert converter.accumulated_tool_args == '{"amount":'

        converter.convert_event({"type": "TOOL_CALL_END", "toolCallId": "danger"})

        assert converter.current_tool_call_id is None
        assert converter.current_tool_name is None
        assert converter.accumulated_tool_args == ""
