from unittest.mock import MagicMock, patch

import pytest
from langchain_openai import ChatOpenAI
from lfx.components.openai.openai_chat_model import OpenAIModelComponent

from tests.api_keys import get_openai_api_key, has_api_key
from tests.base import ComponentTestBaseWithoutClient


class TestOpenAIModelComponent(ComponentTestBaseWithoutClient):
    @pytest.fixture
    def component_class(self):
        return OpenAIModelComponent

    @pytest.fixture
    def default_kwargs(self):
        return {
            "max_tokens": 1000,
            "model_kwargs": {},
            "json_mode": False,
            "model_name": "gpt-4.1-nano",
            "openai_api_base": "https://api.openai.com/v1",
            "api_key": "test-api-key",
            "temperature": 0.1,
            "seed": 1,
            "max_retries": 5,
            "timeout": 700,
        }

    @pytest.fixture
    def file_names_mapping(self):
        # Provide an empty list or the actual mapping if versioned files exist
        return []

    @patch("lfx.components.openai.openai_chat_model.ChatOpenAI")
    async def test_build_model(self, mock_chat_openai, component_class, default_kwargs):
        mock_instance = MagicMock()
        mock_chat_openai.return_value = mock_instance
        component = component_class(**default_kwargs)
        model = component.build_model()

        mock_chat_openai.assert_called_once_with(
            api_key="test-api-key",
            model_name="gpt-4.1-nano",
            max_tokens=1000,
            model_kwargs={},
            base_url="https://api.openai.com/v1",
            seed=1,
            max_retries=5,
            timeout=700,
            temperature=0.1,
            stream_usage=True,
        )
        assert model == mock_instance

    @patch("lfx.components.openai.openai_chat_model.ChatOpenAI")
    async def test_build_model_reasoning_model(self, mock_chat_openai, component_class, default_kwargs):
        mock_instance = MagicMock()
        mock_chat_openai.return_value = mock_instance
        default_kwargs["model_name"] = "o1"
        component = component_class(**default_kwargs)
        model = component.build_model()

        # For reasoning models, temperature and seed should be excluded
        mock_chat_openai.assert_called_once_with(
            api_key="test-api-key",
            model_name="o1",
            max_tokens=1000,
            model_kwargs={},
            base_url="https://api.openai.com/v1",
            max_retries=5,
            timeout=700,
            stream_usage=True,
        )
        assert model == mock_instance

        # Verify that temperature and seed are not in the parameters
        _args, kwargs = mock_chat_openai.call_args
        assert "temperature" not in kwargs
        assert "seed" not in kwargs

    @patch("lfx.components.openai.openai_chat_model.ChatOpenAI")
    async def test_build_model_with_json_mode(self, mock_chat_openai, component_class, default_kwargs):
        mock_instance = MagicMock()
        mock_bound_instance = MagicMock()
        mock_instance.bind.return_value = mock_bound_instance
        mock_chat_openai.return_value = mock_instance

        default_kwargs["json_mode"] = True
        component = component_class(**default_kwargs)
        model = component.build_model()

        mock_chat_openai.assert_called_once()
        mock_instance.bind.assert_called_once_with(response_format={"type": "json_object"})
        assert model == mock_bound_instance

    @patch("lfx.components.openai.openai_chat_model.ChatOpenAI")
    async def test_build_model_no_api_key(self, mock_chat_openai, component_class, default_kwargs):
        mock_instance = MagicMock()
        mock_chat_openai.return_value = mock_instance
        default_kwargs["api_key"] = None
        component = component_class(**default_kwargs)
        component.build_model()

        # When api_key is None, it should be passed as None to ChatOpenAI
        _args, kwargs = mock_chat_openai.call_args
        assert kwargs["api_key"] is None

    @patch("lfx.components.openai.openai_chat_model.ChatOpenAI")
    async def test_build_model_max_tokens_zero(self, mock_chat_openai, component_class, default_kwargs):
        mock_instance = MagicMock()
        mock_chat_openai.return_value = mock_instance
        default_kwargs["max_tokens"] = 0
        component = component_class(**default_kwargs)
        component.build_model()

        # When max_tokens is 0, it should be passed as None to ChatOpenAI
        _args, kwargs = mock_chat_openai.call_args
        assert kwargs["max_tokens"] is None

    async def test_get_exception_message_bad_request_error(self, component_class, default_kwargs):
        component_class(**default_kwargs)

        # Create a mock BadRequestError with a body attribute
        mock_error = MagicMock()
        mock_error.body = {"message": "test error message"}

        # Test the method directly by patching the import
        with patch("openai.BadRequestError", mock_error.__class__):
            # Manually call isinstance to avoid mocking it
            if hasattr(mock_error, "body"):
                message = mock_error.body.get("message")
                assert message == "test error message"

    async def test_get_exception_message_no_openai_import(self, component_class, default_kwargs):
        component = component_class(**default_kwargs)

        # Test when openai module is not available
        with patch.dict("sys.modules", {"openai": None}), patch("builtins.__import__", side_effect=ImportError):
            message = component._get_exception_message(Exception("test"))
            assert message is None

    async def test_get_exception_message_other_exception(self, component_class, default_kwargs):
        component = component_class(**default_kwargs)

        # Create a regular exception (not BadRequestError)
        regular_exception = ValueError("test error")

        # Create a simple mock for BadRequestError that the exception won't match
        class MockBadRequestError:
            pass

        with patch("openai.BadRequestError", MockBadRequestError):
            message = component._get_exception_message(regular_exception)
            assert message is None

    async def test_should_return_helpful_message_when_openai_returns_model_not_found(
        self, component_class, default_kwargs
    ):
        """Bug: NotFoundError(model_not_found) falls through _get_exception_message and returns None.

        Before fix: only BadRequestError was handled, so NotFoundError (HTTP 404) with
        code=model_not_found left the raw verbose OpenAI error to bubble up as
        ComponentBuildError without actionable context.

        Expected: _get_exception_message recognizes openai.NotFoundError with
        code=model_not_found and returns a helpful message that references the model
        name and directs the user to check account/tier access.
        """
        import httpx
        from openai import NotFoundError

        default_kwargs["model_name"] = "gpt-nonexistent"
        component = component_class(**default_kwargs)

        request = httpx.Request("POST", "https://api.openai.com/v1/chat/completions")
        response = httpx.Response(status_code=404, request=request)
        error = NotFoundError(
            message="The model `gpt-nonexistent` does not exist or you do not have access to it.",
            response=response,
            body={
                "code": "model_not_found",
                "message": "The model `gpt-nonexistent` does not exist or you do not have access to it.",
                "type": "invalid_request_error",
                "param": None,
            },
        )

        message = component._get_exception_message(error)

        assert message is not None, "Expected a helpful message for NotFoundError(model_not_found), got None"
        assert "gpt-nonexistent" in message, f"Expected model name in message, got: {message!r}"
        assert any(keyword in message.lower() for keyword in ("tier", "access")), (
            f"Expected message to mention tier/access, got: {message!r}"
        )

    async def test_should_not_expose_fictional_gpt53_ids_in_openai_model_name_options(self):
        """Bug: fictional gpt-5.3 ids in the OpenAI dropdown cause 404 at runtime.

        gpt-5.3 and gpt-5.3-instant were listed as selectable model options but do
        not exist as OpenAI API model IDs. Only gpt-5.3-chat-latest and gpt-5.3-codex
        exist in the OpenAI API for the 5.3 family. The bare gpt-5.3 id and
        gpt-5.3-instant (a ChatGPT product name, not an API model id) must not be
        exposed as selectable options.
        """
        from lfx.base.models.openai_constants import (
            OPENAI_CHAT_MODEL_NAMES,
            OPENAI_REASONING_MODEL_NAMES,
        )

        selectable_ids = set(OPENAI_CHAT_MODEL_NAMES) | set(OPENAI_REASONING_MODEL_NAMES)
        fictional_ids = {"gpt-5.3", "gpt-5.3-instant"}
        leaked = fictional_ids & selectable_ids

        assert not leaked, (
            f"Fictional OpenAI model IDs exposed in the OpenAI component dropdown: {sorted(leaked)}. "
            f"These IDs are not real OpenAI API models and trigger 404 model_not_found at runtime."
        )

    async def test_update_build_config_reasoning_model(self, component_class, default_kwargs):
        component = component_class(**default_kwargs)
        build_config = {
            "temperature": {"show": True},
            "seed": {"show": True},
        }

        # Test with reasoning model
        updated_config = component.update_build_config(build_config, "o1", "model_name")
        assert updated_config["temperature"]["show"] is False
        assert updated_config["seed"]["show"] is False

        # Test with regular model
        updated_config = component.update_build_config(build_config, "gpt-4", "model_name")
        assert updated_config["temperature"]["show"] is True
        assert updated_config["seed"]["show"] is True

    @pytest.mark.skipif(not has_api_key("OPENAI_API_KEY"), reason="OPENAI_API_KEY is not set or is empty")
    def test_build_model_integration(self):
        component = OpenAIModelComponent()
        try:
            component.api_key = get_openai_api_key()
        except ValueError:
            component.api_key = None
        component.model_name = "gpt-4.1-nano"
        component.temperature = 0.2
        component.max_tokens = 1000
        component.seed = 42
        component.max_retries = 3
        component.timeout = 600
        component.openai_api_base = "https://api.openai.com/v1"

        model = component.build_model()
        assert isinstance(model, ChatOpenAI)
        assert model.model_name == "gpt-4.1-nano"
        assert model.openai_api_base == "https://api.openai.com/v1"

    @pytest.mark.skipif(not has_api_key("OPENAI_API_KEY"), reason="OPENAI_API_KEY is not set or is empty")
    def test_build_model_integration_reasoning(self):
        component = OpenAIModelComponent()
        component.api_key = get_openai_api_key()
        component.model_name = "o1"
        component.temperature = 0.2  # This should be ignored for reasoning models
        component.max_tokens = 1000
        component.seed = 42  # This should be ignored for reasoning models
        component.max_retries = 3
        component.timeout = 600
        component.openai_api_base = "https://api.openai.com/v1"

        model = component.build_model()
        assert isinstance(model, ChatOpenAI)
        assert model.model_name == "o1"
        assert model.openai_api_base == "https://api.openai.com/v1"
