"""
Tests for embeddings/providers/implementations/openai.py

Tests cover:
- OpenAIEmbeddingsProvider.create_embeddings()
- OpenAIEmbeddingsProvider.is_available()
- OpenAIEmbeddingsProvider.get_available_models()
- Class attributes and metadata
"""

import pytest
from unittest.mock import patch, MagicMock


class TestOpenAIEmbeddingsProviderMetadata:
    """Tests for OpenAIEmbeddingsProvider class metadata."""

    def test_provider_name(self):
        """Test provider name is set correctly."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        assert OpenAIEmbeddingsProvider.provider_name == "OpenAI"

    def test_provider_key(self):
        """Test provider key is set correctly."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        assert OpenAIEmbeddingsProvider.provider_key == "OPENAI"

    def test_requires_api_key(self):
        """Provider does not strictly require an API key.

        Cloud OpenAI does need one, but OpenAI-compatible local servers
        (LM Studio, vLLM, llama.cpp) don't — the runtime path in
        ``is_available`` / ``create_embeddings`` enforces the rule when
        a base_url is *not* set. The class-level flag therefore stays
        ``False`` (inherited from BaseEmbeddingProvider) so any future
        UI consumer doesn't show a misleading "API key required" badge
        for keyless local-server users.
        """
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        assert OpenAIEmbeddingsProvider.requires_api_key is False

    def test_supports_local(self):
        """Test that OpenAI does not support local."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        assert OpenAIEmbeddingsProvider.supports_local is False

    def test_default_model(self):
        """Test default model is set."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        assert (
            OpenAIEmbeddingsProvider.default_model == "text-embedding-3-small"
        )


class TestOpenAIEmbeddingsProviderCreateEmbeddings:
    """Tests for OpenAIEmbeddingsProvider.create_embeddings method."""

    def test_create_embeddings_with_api_key(self):
        """Test creating embeddings with API key provided."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        mock_embeddings = MagicMock()

        # Mock get_setting_from_snapshot to return None for other settings
        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            return_value=None,
        ):
            with patch(
                "langchain_openai.OpenAIEmbeddings",
                return_value=mock_embeddings,
            ) as mock_class:
                result = OpenAIEmbeddingsProvider.create_embeddings(
                    model="text-embedding-3-small",
                    api_key="test-api-key",
                )

                assert result is mock_embeddings
                mock_class.assert_called_once()
                call_kwargs = mock_class.call_args[1]
                assert call_kwargs["model"] == "text-embedding-3-small"
                assert call_kwargs["openai_api_key"] == "test-api-key"
                # No base_url → LangChain default ctx-check is preserved.
                assert "check_embedding_ctx_length" not in call_kwargs

    def test_create_embeddings_missing_api_key_raises(self):
        """Test that missing API key raises ValueError."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            return_value=None,
        ):
            with pytest.raises(ValueError, match="API key not configured"):
                OpenAIEmbeddingsProvider.create_embeddings()

    def test_create_embeddings_with_settings_snapshot(self):
        """Test creating embeddings with settings snapshot."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        mock_embeddings = MagicMock()
        settings = {"embeddings.openai.api_key": "snapshot-key"}

        def mock_get_setting(key, default=None, settings_snapshot=None):
            if key == "embeddings.openai.api_key":
                return "snapshot-key"
            return default

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            side_effect=mock_get_setting,
        ):
            with patch(
                "langchain_openai.OpenAIEmbeddings",
                return_value=mock_embeddings,
            ):
                result = OpenAIEmbeddingsProvider.create_embeddings(
                    settings_snapshot=settings
                )

                assert result is mock_embeddings

    def test_create_embeddings_with_base_url(self):
        """Test creating embeddings with custom base URL."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        mock_embeddings = MagicMock()

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            return_value=None,
        ):
            with patch(
                "langchain_openai.OpenAIEmbeddings",
                return_value=mock_embeddings,
            ) as mock_class:
                OpenAIEmbeddingsProvider.create_embeddings(
                    api_key="test-key",
                    base_url="https://custom.openai.com",
                )

                call_kwargs = mock_class.call_args[1]
                assert (
                    call_kwargs["openai_api_base"]
                    == "https://custom.openai.com"
                )
                assert call_kwargs["check_embedding_ctx_length"] is False

    def test_create_embeddings_with_official_openai_base_url_keeps_ctx_check(
        self,
    ):
        """Explicit base_url pointing at api.openai.com must NOT disable the
        client-side context-length check; that guard is only meant to be
        skipped for non-OpenAI hosts (LM Studio, vLLM, llama.cpp, etc.)."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        mock_embeddings = MagicMock()

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            return_value=None,
        ):
            with patch(
                "langchain_openai.OpenAIEmbeddings",
                return_value=mock_embeddings,
            ) as mock_class:
                OpenAIEmbeddingsProvider.create_embeddings(
                    api_key="test-key",
                    base_url="https://api.openai.com/v1",
                )

                call_kwargs = mock_class.call_args[1]
                assert (
                    call_kwargs["openai_api_base"]
                    == "https://api.openai.com/v1"
                )
                assert "check_embedding_ctx_length" not in call_kwargs

    def test_create_embeddings_with_schemeless_openai_base_url_keeps_ctx_check(
        self,
    ):
        """A scheme-less base_url like ``api.openai.com`` must still be
        recognized as the real OpenAI endpoint. ``urlparse`` on a bare
        host returns ``hostname=None``, so without URL normalization the
        ctx-length guard would silently be dropped for the cloud API."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        mock_embeddings = MagicMock()

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            return_value=None,
        ):
            with patch(
                "langchain_openai.OpenAIEmbeddings",
                return_value=mock_embeddings,
            ) as mock_class:
                OpenAIEmbeddingsProvider.create_embeddings(
                    api_key="test-key",
                    base_url="api.openai.com",
                )

                call_kwargs = mock_class.call_args[1]
                # normalize_url prepends https:// for external hosts.
                assert (
                    call_kwargs["openai_api_base"] == "https://api.openai.com"
                )
                assert "check_embedding_ctx_length" not in call_kwargs

    def test_create_embeddings_with_dimensions(self):
        """Test creating embeddings with custom dimensions for v3 model."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        mock_embeddings = MagicMock()

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            return_value=None,
        ):
            with patch(
                "langchain_openai.OpenAIEmbeddings",
                return_value=mock_embeddings,
            ) as mock_class:
                OpenAIEmbeddingsProvider.create_embeddings(
                    model="text-embedding-3-small",
                    api_key="test-key",
                    dimensions=256,
                )

                call_kwargs = mock_class.call_args[1]
                assert call_kwargs["dimensions"] == 256

    def test_create_embeddings_dimensions_ignored_for_non_v3_model(self):
        """Test that dimensions are ignored for non-v3 models."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        mock_embeddings = MagicMock()

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            return_value=None,
        ):
            with patch(
                "langchain_openai.OpenAIEmbeddings",
                return_value=mock_embeddings,
            ) as mock_class:
                OpenAIEmbeddingsProvider.create_embeddings(
                    model="text-embedding-ada-002",
                    api_key="test-key",
                    dimensions=256,
                )

                call_kwargs = mock_class.call_args[1]
                assert "dimensions" not in call_kwargs


class TestOpenAIEmbeddingsProviderIsAvailable:
    """Tests for OpenAIEmbeddingsProvider.is_available method."""

    def test_is_available_with_api_key(self):
        """Test that provider is available when API key is set."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            return_value="test-api-key",
        ):
            assert OpenAIEmbeddingsProvider.is_available() is True

    def test_is_available_without_api_key(self):
        """Test that provider is not available without API key."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            return_value=None,
        ):
            assert OpenAIEmbeddingsProvider.is_available() is False

    def test_is_available_with_empty_api_key(self):
        """Test that provider is not available with empty API key."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            return_value="",
        ):
            assert OpenAIEmbeddingsProvider.is_available() is False

    def test_is_available_exception_returns_false(self):
        """Test that exception during availability check returns False."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            side_effect=Exception("Settings error"),
        ):
            assert OpenAIEmbeddingsProvider.is_available() is False


class TestOpenAIEmbeddingsProviderGetAvailableModels:
    """Tests for OpenAIEmbeddingsProvider.get_available_models method."""

    def test_get_available_models_success(self):
        """Test getting available models from OpenAI API.

        The provider returns every model the endpoint reports — there's
        no reliable signal in /v1/models for "is this an embedding
        model?", so guessing from the name is left to the user.
        """
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        mock_model1 = MagicMock()
        mock_model1.id = "text-embedding-3-small"
        mock_model2 = MagicMock()
        mock_model2.id = "text-embedding-3-large"
        mock_model3 = MagicMock()
        mock_model3.id = "gpt-4"

        mock_response = MagicMock()
        mock_response.data = [mock_model1, mock_model2, mock_model3]

        mock_client = MagicMock()
        mock_client.models.list.return_value = mock_response

        def _settings(key, default=None, settings_snapshot=None):
            if key == "embeddings.openai.api_key":
                return "test-api-key"
            return default

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            side_effect=_settings,
        ):
            with patch(
                "openai.OpenAI",
                return_value=mock_client,
            ):
                models = OpenAIEmbeddingsProvider.get_available_models()

                assert [m["value"] for m in models] == [
                    "text-embedding-3-small",
                    "text-embedding-3-large",
                    "gpt-4",
                ]
                # No name-based tagging — the endpoint can't be trusted
                # to identify embedding models for us.
                assert all("is_embedding" not in m for m in models)

    def test_get_available_models_no_api_key(self):
        """Test getting models returns empty list when no API key."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            return_value=None,
        ):
            models = OpenAIEmbeddingsProvider.get_available_models()
            assert models == []

    def test_get_available_models_api_error(self):
        """Test getting models returns empty list on API error."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            return_value="test-api-key",
        ):
            with patch(
                "openai.OpenAI",
                side_effect=Exception("API error"),
            ):
                models = OpenAIEmbeddingsProvider.get_available_models()
                assert models == []


class TestOpenAIEmbeddingsProviderCompatibleEndpoint:
    """Regression tests for issue #3883.

    OpenAI-compatible local servers (LM Studio, vLLM, llama.cpp) speak
    the same wire protocol as the OpenAI API but typically run on
    ``http://localhost:<port>/v1`` and do not require an API key. The
    provider must:

      1. report ``is_available() == True`` when the user has set a
         ``base_url`` even with no API key (so the UI lists it);
      2. accept a missing API key at ``create_embeddings`` time as long
         as a base_url is set, falling back to a placeholder so the
         OpenAI client request still goes out;
      3. still raise the configuration error when *neither* an API key
         nor a base_url is set (so a blank install doesn't silently
         hit an unconfigured endpoint).
    """

    @staticmethod
    def _settings_mock(api_key, base_url):
        """Return a side_effect callable for get_setting_from_snapshot.

        The provider reads four ``embeddings.openai.*`` keys; this
        helper routes each to a per-test value and returns ``default``
        for anything else.
        """

        values = {
            "embeddings.openai.api_key": api_key,
            "embeddings.openai.base_url": base_url,
        }

        def _side_effect(key, default=None, settings_snapshot=None):
            if key in values:
                return values[key]
            return default

        return _side_effect

    def test_is_available_with_base_url_only(self):
        """Provider must be advertised when only base_url is set.

        Red on master, green on branch — this is the headline
        regression for issue #3883.
        """
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            side_effect=self._settings_mock(
                api_key=None, base_url="http://localhost:1234/v1"
            ),
        ):
            assert OpenAIEmbeddingsProvider.is_available() is True

    def test_is_available_with_blank_base_url_and_no_key(self):
        """Provider stays hidden on a blank install."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            side_effect=self._settings_mock(api_key="", base_url=""),
        ):
            assert OpenAIEmbeddingsProvider.is_available() is False

    def test_is_available_whitespace_only_settings_still_unavailable(self):
        """Whitespace-only values should not count as configured."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            side_effect=self._settings_mock(api_key="   ", base_url="\t \n"),
        ):
            assert OpenAIEmbeddingsProvider.is_available() is False

    def test_create_embeddings_base_url_only_uses_placeholder_key(self):
        """No API key + base_url set → use placeholder, pass base_url through."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            side_effect=self._settings_mock(
                api_key=None,
                base_url="http://host.docker.internal:1234/v1",
            ),
        ):
            with patch(
                "langchain_openai.OpenAIEmbeddings",
                return_value=MagicMock(),
            ) as mock_class:
                OpenAIEmbeddingsProvider.create_embeddings(
                    model="nomic-embed-text-v1.5",
                )

                call_kwargs = mock_class.call_args[1]
                assert (
                    call_kwargs["openai_api_key"]
                    == OpenAIEmbeddingsProvider._PLACEHOLDER_API_KEY
                )
                assert (
                    call_kwargs["openai_api_base"]
                    == "http://host.docker.internal:1234/v1"
                )
                assert call_kwargs["check_embedding_ctx_length"] is False

    def test_create_embeddings_error_message_mentions_base_url(self):
        """When both api_key and base_url are missing, the error must
        tell the user about the local-server fallback path so they
        don't go hunting for a key they don't have."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            side_effect=self._settings_mock(api_key=None, base_url=None),
        ):
            with pytest.raises(ValueError, match="base_url"):
                OpenAIEmbeddingsProvider.create_embeddings()

    def test_get_available_models_uses_base_url_when_no_key(self):
        """Model discovery on a keyless local server must route through
        the configured base_url instead of api.openai.com."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        fake_client = MagicMock()
        fake_client.models.list.return_value = MagicMock(data=[])

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            side_effect=self._settings_mock(
                api_key=None, base_url="http://localhost:1234/v1"
            ),
        ):
            with patch(
                "openai.OpenAI", return_value=fake_client
            ) as mock_openai:
                OpenAIEmbeddingsProvider.get_available_models()

                kwargs = mock_openai.call_args[1]
                assert kwargs["base_url"] == "http://localhost:1234/v1"
                assert (
                    kwargs["api_key"]
                    == OpenAIEmbeddingsProvider._PLACEHOLDER_API_KEY
                )


class TestOpenAIEmbeddingsProviderLMStudioModelDiscovery:
    """Regression tests for issue #4195.

    LM Studio (and other OpenAI-compatible local servers) returns every
    loaded model from ``/v1/models``, both embedding models and LLMs,
    without a reliable ``type`` field to distinguish them. The previous
    ``"embedding" in id`` filter dropped real embedding models whose
    names use the shorter ``embed`` token (``nomic-embed-text-v1.5``)
    and so the embedding-model dropdown ended up empty.

    The fix: stop guessing from the name. Show every model the endpoint
    returns and let the user pick the one they actually loaded.
    """

    @staticmethod
    def _settings_mock(api_key, base_url):
        values = {
            "embeddings.openai.api_key": api_key,
            "embeddings.openai.base_url": base_url,
        }

        def _side_effect(key, default=None, settings_snapshot=None):
            if key in values:
                return values[key]
            return default

        return _side_effect

    def _fake_models_response(self, ids):
        models = []
        for model_id in ids:
            m = MagicMock()
            m.id = model_id
            models.append(m)
        response = MagicMock()
        response.data = models
        return response

    def test_lmstudio_nomic_embed_text_is_listed(self):
        """The headline regression for #4195: a Nomic embedding model
        loaded in LM Studio must appear in the dropdown — its name
        doesn't contain the literal ``embedding`` token but it is the
        embedding model the user wants to use."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        fake_client = MagicMock()
        fake_client.models.list.return_value = self._fake_models_response(
            ["nomic-embed-text-v1.5"]
        )

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            side_effect=self._settings_mock(
                api_key=None, base_url="http://localhost:1234/v1"
            ),
        ):
            with patch("openai.OpenAI", return_value=fake_client):
                models = OpenAIEmbeddingsProvider.get_available_models()

        assert [m["value"] for m in models] == ["nomic-embed-text-v1.5"]

    def test_lmstudio_returns_every_loaded_model_unfiltered(self):
        """Every loaded model — embedding or LLM, well-known name or
        custom fine-tune — is returned. We don't pre-filter the list."""
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        ids = [
            "llama-3.1-8b-instruct",
            "nomic-embed-text-v1.5",
            "qwen3-embedding-8b",
            "bge-large-en-v1.5",
            "some-custom-finetune",
        ]
        fake_client = MagicMock()
        fake_client.models.list.return_value = self._fake_models_response(ids)

        with patch(
            "local_deep_research.embeddings.providers.implementations.openai.get_setting_from_snapshot",
            side_effect=self._settings_mock(
                api_key=None, base_url="http://localhost:1234/v1"
            ),
        ):
            with patch("openai.OpenAI", return_value=fake_client):
                models = OpenAIEmbeddingsProvider.get_available_models()

        # Order preserved, nothing dropped, no name-based flags added.
        assert [m["value"] for m in models] == ids
        assert all("is_embedding" not in m for m in models)


class TestOpenAIEmbeddingsSettingsRegistration:
    """The settings file shipped for issue #3883 must register the
    keys the UI needs to surface the embeddings form, and the OpenAI
    provider must be wired up by ``_get_provider_classes`` regardless
    of API-key presence so it can be selected for keyless local
    servers."""

    def test_openai_embeddings_settings_file_registers_all_keys(self):
        """``settings_openai_embeddings.json`` must declare the four
        ``embeddings.openai.*`` keys that the provider reads at
        runtime (api_key, base_url, model, dimensions)."""
        import json
        from pathlib import Path
        import local_deep_research.defaults as defaults_pkg

        defaults_dir = Path(defaults_pkg.__file__).parent
        path = defaults_dir / "settings_openai_embeddings.json"
        assert path.exists(), (
            "Issue #3883: ship settings_openai_embeddings.json so the "
            "UI can surface the embeddings configuration form."
        )
        with open(path) as f:
            data = json.load(f)

        required = {
            "embeddings.openai.api_key",
            "embeddings.openai.base_url",
            "embeddings.openai.model",
            "embeddings.openai.dimensions",
        }
        missing = required - set(data.keys())
        assert not missing, f"settings file missing keys: {missing}"

        base_url_meta = data["embeddings.openai.base_url"]
        assert base_url_meta["category"] == "embeddings"
        assert base_url_meta["editable"] is True
        # The description is what tells users *why* this matters for
        # local servers — guard against silent removal.
        desc_lower = base_url_meta["description"].lower()
        assert any(
            tag in desc_lower for tag in ("lm studio", "vllm", "llama.cpp")
        ), "base_url description should mention an OpenAI-compatible server"

    def test_openai_in_provider_classes_dict(self):
        """The 'openai' key must remain wired to the provider class so
        the embeddings dispatch path can resolve it."""
        from local_deep_research.embeddings.embeddings_config import (
            _get_provider_classes,
        )
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        classes = _get_provider_classes()
        assert classes.get("openai") is OpenAIEmbeddingsProvider
