# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0

import asyncio
import os
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from openai import AsyncOpenAI, AsyncStream, OpenAIError
from openai.types.chat import (
    ChatCompletion,
    ChatCompletionChunk,
    ChatCompletionMessage,
    ChatCompletionMessageFunctionToolCall,
    chat_completion_chunk,
)
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_message_function_tool_call import Function
from openai.types.completion_usage import CompletionTokensDetails, CompletionUsage, PromptTokensDetails

from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage, StreamingChunk, ToolCall
from haystack.tools import Tool
from haystack.utils.auth import Secret


@pytest.fixture
def chat_messages():
    return [
        ChatMessage.from_system("You are a helpful assistant"),
        ChatMessage.from_user("What's the capital of France"),
    ]


@pytest.fixture
def mock_chat_completion_chunk_with_tools(openai_mock_stream_async):
    """
    Mock the OpenAI API completion chunk response and reuse it for tests
    """

    with patch(
        "openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock
    ) as mock_chat_completion_create:
        completion = ChatCompletionChunk(
            id="foo",
            model="gpt-4",
            object="chat.completion.chunk",
            choices=[
                chat_completion_chunk.Choice(
                    finish_reason="tool_calls",
                    logprobs=None,
                    index=0,
                    delta=chat_completion_chunk.ChoiceDelta(
                        role="assistant",
                        tool_calls=[
                            chat_completion_chunk.ChoiceDeltaToolCall(
                                index=0,
                                id="123",
                                type="function",
                                function=chat_completion_chunk.ChoiceDeltaToolCallFunction(
                                    name="weather", arguments='{"city": "Paris"}'
                                ),
                            )
                        ],
                    ),
                )
            ],
            created=int(datetime.now().timestamp()),
            usage=None,
        )
        mock_chat_completion_create.return_value = openai_mock_stream_async(completion)
        yield mock_chat_completion_create


@pytest.fixture
def tools():
    tool_parameters = {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
    tool = Tool(
        name="weather",
        description="useful to determine the weather in a given location",
        parameters=tool_parameters,
        function=lambda x: x,
    )
    return [tool]


class TestOpenAIChatGeneratorAsync:
    def test_init_should_also_create_async_client_with_same_args(self, monkeypatch):
        monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
        component = OpenAIChatGenerator(
            api_key=Secret.from_token("test-api-key"),
            api_base_url="test-base-url",
            organization="test-organization",
            timeout=30,
            max_retries=5,
        )

        assert isinstance(component.async_client, AsyncOpenAI)
        assert component.async_client.api_key == "test-api-key"
        assert component.async_client.organization == "test-organization"
        assert component.async_client.base_url == "test-base-url/"
        assert component.async_client.timeout == 30
        assert component.async_client.max_retries == 5

    @pytest.mark.asyncio
    async def test_run_async(self, chat_messages, openai_mock_async_chat_completion):
        component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
        response = await component.run_async(chat_messages)

        # check that the component returns the correct ChatMessage response
        assert isinstance(response, dict)
        assert "replies" in response
        assert isinstance(response["replies"], list)
        assert len(response["replies"]) == 1
        assert [isinstance(reply, ChatMessage) for reply in response["replies"]]

    @pytest.mark.asyncio
    async def test_run_with_params_async(self, chat_messages, openai_mock_async_chat_completion):
        component = OpenAIChatGenerator(
            api_key=Secret.from_token("test-api-key"),
            generation_kwargs={"max_completion_tokens": 10, "temperature": 0.5},
        )
        response = await component.run_async(chat_messages)

        # check that the component calls the OpenAI API with the correct parameters
        _, kwargs = openai_mock_async_chat_completion.call_args
        assert kwargs["max_completion_tokens"] == 10
        assert kwargs["temperature"] == 0.5

        # check that the tools are not passed to the OpenAI API (the generator is initialized without tools)
        assert "tools" not in kwargs

        # check that the component returns the correct response
        assert isinstance(response, dict)
        assert "replies" in response
        assert isinstance(response["replies"], list)
        assert len(response["replies"]) == 1
        assert [isinstance(reply, ChatMessage) for reply in response["replies"]]

    @pytest.mark.asyncio
    async def test_run_with_params_streaming_async(self, chat_messages, openai_mock_async_chat_completion_chunk):
        streaming_callback_called = False

        async def streaming_callback(chunk: StreamingChunk) -> None:
            nonlocal streaming_callback_called
            streaming_callback_called = True

        component = OpenAIChatGenerator(
            api_key=Secret.from_token("test-api-key"), streaming_callback=streaming_callback
        )
        response = await component.run_async(chat_messages)

        # check we called the streaming callback
        assert streaming_callback_called

        # check that the component still returns the correct response
        assert isinstance(response, dict)
        assert "replies" in response
        assert isinstance(response["replies"], list)
        assert len(response["replies"]) == 1
        assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
        assert "Hello" in response["replies"][0].text  # see openai_mock_chat_completion_chunk

    @pytest.mark.asyncio
    async def test_run_with_streaming_callback_in_run_method_async(
        self, chat_messages, openai_mock_async_chat_completion_chunk
    ):
        streaming_callback_called = False

        async def streaming_callback(chunk: StreamingChunk) -> None:
            nonlocal streaming_callback_called
            streaming_callback_called = True

        component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
        response = await component.run_async(chat_messages, streaming_callback=streaming_callback)

        # check we called the streaming callback
        assert streaming_callback_called

        # check that the component still returns the correct response
        assert isinstance(response, dict)
        assert "replies" in response
        assert isinstance(response["replies"], list)
        assert len(response["replies"]) == 1
        assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
        assert "Hello" in response["replies"][0].text  # see openai_mock_chat_completion_chunk

    @pytest.mark.asyncio
    async def test_run_with_tools_async(self, tools):
        with patch(
            "openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock
        ) as mock_chat_completion_create:
            completion = ChatCompletion(
                id="foo",
                model="gpt-4",
                object="chat.completion",
                choices=[
                    Choice(
                        finish_reason="tool_calls",
                        logprobs=None,
                        index=0,
                        message=ChatCompletionMessage(
                            role="assistant",
                            tool_calls=[
                                ChatCompletionMessageFunctionToolCall(
                                    id="123",
                                    type="function",
                                    function=Function(name="weather", arguments='{"city": "Paris"}'),
                                )
                            ],
                        ),
                    )
                ],
                created=int(datetime.now().timestamp()),
                usage=CompletionUsage(
                    completion_tokens=40,
                    prompt_tokens=57,
                    total_tokens=97,
                    completion_tokens_details=CompletionTokensDetails(
                        accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=0, rejected_prediction_tokens=0
                    ),
                    prompt_tokens_details=PromptTokensDetails(audio_tokens=0, cached_tokens=0),
                ),
            )

            mock_chat_completion_create.return_value = completion

            component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"), tools=tools, tools_strict=True)
            response = await component.run_async([ChatMessage.from_user("What's the weather like in Paris?")])

        # ensure that the tools are passed to the OpenAI API
        function_spec = {**tools[0].tool_spec}
        function_spec["strict"] = True
        function_spec["parameters"]["additionalProperties"] = False
        assert mock_chat_completion_create.call_args[1]["tools"] == [{"type": "function", "function": function_spec}]

        assert len(response["replies"]) == 1
        message = response["replies"][0]

        assert not message.texts
        assert not message.text

        assert message.tool_calls
        tool_call = message.tool_call
        assert isinstance(tool_call, ToolCall)
        assert tool_call.tool_name == "weather"
        assert tool_call.arguments == {"city": "Paris"}
        assert message.meta["finish_reason"] == "tool_calls"
        assert message.meta["usage"]["completion_tokens"] == 40

    @pytest.mark.asyncio
    async def test_run_with_tools_streaming_async(self, mock_chat_completion_chunk_with_tools, tools):
        streaming_callback_called = False

        async def streaming_callback(chunk: StreamingChunk) -> None:
            nonlocal streaming_callback_called
            streaming_callback_called = True

        component = OpenAIChatGenerator(
            api_key=Secret.from_token("test-api-key"), streaming_callback=streaming_callback
        )
        chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")]
        response = await component.run_async(chat_messages, tools=tools)

        # check we called the streaming callback
        assert streaming_callback_called

        # check that the component still returns the correct response
        assert isinstance(response, dict)
        assert "replies" in response
        assert isinstance(response["replies"], list)
        assert len(response["replies"]) == 1
        assert [isinstance(reply, ChatMessage) for reply in response["replies"]]

        message = response["replies"][0]

        assert message.tool_calls
        tool_call = message.tool_call
        assert isinstance(tool_call, ToolCall)
        assert tool_call.tool_name == "weather"
        assert tool_call.arguments == {"city": "Paris"}
        assert message.meta["finish_reason"] == "tool_calls"

    @pytest.mark.asyncio
    async def test_async_stream_closes_on_cancellation(self, monkeypatch):
        monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
        generator = OpenAIChatGenerator(
            api_key=Secret.from_token("test-api-key"),
            api_base_url="test-base-url",
            organization="test-organization",
            timeout=30,
            max_retries=5,
        )

        # mocked the async stream that will be passed to the _handle_async_stream_response() method
        mock_stream = AsyncMock(spec=AsyncStream)
        mock_stream.close = AsyncMock()

        async def mock_chunk_generator():
            for i in range(100):
                yield MagicMock(
                    choices=[
                        MagicMock(
                            index=0,
                            delta=MagicMock(content=f"chunk{i}", role=None, tool_calls=None),
                            finish_reason=None,
                            logprobs=None,
                        )
                    ],
                    model="gpt-4",
                    usage=None,
                )
                await asyncio.sleep(0.01)  # delay between chunks

        mock_stream.__aiter__ = lambda self: mock_chunk_generator()

        received_chunks = []

        async def test_callback(chunk: StreamingChunk):
            received_chunks.append(chunk)

        # the task that will be cancelled
        task = asyncio.create_task(generator._handle_async_stream_response(mock_stream, test_callback))

        # trigger the task, process a few chunks, then cancel
        await asyncio.sleep(0.05)
        task.cancel()

        with pytest.raises(asyncio.CancelledError):
            await task

        mock_stream.close.assert_awaited_once()

        # we received some chunks before cancellation but not all of them
        assert len(received_chunks) > 0
        assert len(received_chunks) < 100

    @pytest.mark.skipif(
        not os.environ.get("OPENAI_API_KEY", None),
        reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
    )
    @pytest.mark.integration
    @pytest.mark.asyncio
    async def test_run_async_cancellation_integration(self):
        generator = OpenAIChatGenerator(model="gpt-4.1-nano")
        messages = [ChatMessage.from_user("Write me an essay about the history of jazz music, at least 500 words.")]
        received_chunks = []

        async def streaming_callback(chunk: StreamingChunk):
            received_chunks.append(chunk)

        task = asyncio.create_task(generator.run_async(messages=messages, streaming_callback=streaming_callback))

        await asyncio.sleep(2.0)
        task.cancel()

        with pytest.raises(asyncio.CancelledError):
            await task

        assert len(received_chunks) > 0
        assert len(received_chunks) < 500

    @pytest.mark.skipif(
        not os.environ.get("OPENAI_API_KEY", None),
        reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
    )
    @pytest.mark.integration
    @pytest.mark.asyncio
    async def test_live_run_async(self):
        chat_messages = [ChatMessage.from_user("What's the capital of France")]
        component = OpenAIChatGenerator(generation_kwargs={"n": 1})
        results = await component.run_async(chat_messages)
        assert len(results["replies"]) == 1
        message: ChatMessage = results["replies"][0]
        assert "Paris" in message.text
        assert message.meta["model"]
        assert message.meta["finish_reason"] == "stop"

    @pytest.mark.asyncio
    async def test_run_with_wrong_model_async(self):
        mock_client = MagicMock()
        mock_client.chat.completions.create.side_effect = OpenAIError("Invalid model name")

        generator = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"), model="something-obviously-wrong")

        generator.client = mock_client

        with pytest.raises(OpenAIError):
            await generator.run_async([ChatMessage.from_user("irrelevant")])

    @pytest.mark.skipif(
        not os.environ.get("OPENAI_API_KEY", None),
        reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
    )
    @pytest.mark.integration
    @pytest.mark.asyncio
    async def test_live_run_streaming_async(self):
        counter = 0
        responses = ""

        async def callback(chunk: StreamingChunk):
            nonlocal counter
            nonlocal responses
            counter += 1
            responses += chunk.content if chunk.content else ""

        component = OpenAIChatGenerator(
            streaming_callback=callback, generation_kwargs={"stream_options": {"include_usage": True}}
        )
        results = await component.run_async([ChatMessage.from_user("What's the capital of France?")])

        assert len(results["replies"]) == 1
        message: ChatMessage = results["replies"][0]
        assert "Paris" in message.text

        assert message.meta["model"]
        assert message.meta["finish_reason"] == "stop"

        assert counter > 1
        assert "Paris" in responses

        # check that the completion_start_time is set and valid ISO format
        assert "completion_start_time" in message.meta
        assert datetime.fromisoformat(message.meta["completion_start_time"]) <= datetime.now()

        assert isinstance(message.meta["usage"], dict)
        assert message.meta["usage"]["prompt_tokens"] > 0
        assert message.meta["usage"]["completion_tokens"] > 0
        assert message.meta["usage"]["total_tokens"] > 0

    @pytest.mark.skipif(
        not os.environ.get("OPENAI_API_KEY", None),
        reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
    )
    @pytest.mark.integration
    @pytest.mark.asyncio
    async def test_live_run_with_tools_async(self, tools):
        chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")]
        component = OpenAIChatGenerator(tools=tools)
        results = await component.run_async(chat_messages)
        assert len(results["replies"]) == 1
        message = results["replies"][0]

        assert not message.texts
        assert not message.text
        assert message.tool_calls
        tool_call = message.tool_call
        assert isinstance(tool_call, ToolCall)
        assert tool_call.tool_name == "weather"
        # Check that Paris is in the city argument (case-insensitive, allowing for variations like "Paris, France")
        assert "paris" in tool_call.arguments["city"].lower()
        assert message.meta["finish_reason"] == "tool_calls"

    @pytest.mark.asyncio
    async def test_run_with_wrapped_stream_simulation_async(self, chat_messages, openai_mock_stream_async):
        streaming_callback_called = False

        async def streaming_callback(chunk: StreamingChunk) -> None:
            nonlocal streaming_callback_called
            streaming_callback_called = True
            assert isinstance(chunk, StreamingChunk)

        chunk = ChatCompletionChunk(
            id="id",
            model="gpt-4",
            object="chat.completion.chunk",
            choices=[chat_completion_chunk.Choice(index=0, delta=chat_completion_chunk.ChoiceDelta(content="Hello"))],
            created=int(datetime.now().timestamp()),
        )

        # Here we wrap the OpenAI async stream in an AsyncMock
        # This is to simulate the behavior of some tools like Weave (https://github.com/wandb/weave)
        # which wrap the OpenAI async stream in their own stream
        wrapped_openai_async_stream = AsyncMock()
        wrapped_openai_async_stream.__aiter__.return_value = iter([chunk])

        component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))

        # Patch the async client's create method
        with patch.object(
            component.async_client.chat.completions,
            "create",
            return_value=wrapped_openai_async_stream,
            new_callable=AsyncMock,
        ) as mock_create:
            response = await component.run_async(chat_messages, streaming_callback=streaming_callback)

            mock_create.assert_called_once()
            assert streaming_callback_called
            assert "replies" in response
            assert "Hello" in response["replies"][0].text
