"""
Tests for the auto-discovery registration mechanism.

Verifies that discover_providers() calls register_llm with the correct
provider key and create_llm callable for each discovered provider.
"""

import importlib
import sys
import textwrap
import types
from unittest.mock import patch

import pytest

from local_deep_research.llm.providers.auto_discovery import (
    ProviderDiscovery,
)

AUTO_DISC = "local_deep_research.llm.providers.auto_discovery"


@pytest.fixture(autouse=True)
def reset_discovery():
    """Reset ProviderDiscovery singleton between tests."""
    ProviderDiscovery._instance = None
    ProviderDiscovery._providers = {}
    ProviderDiscovery._discovered = False
    yield
    ProviderDiscovery._instance = None
    ProviderDiscovery._providers = {}
    ProviderDiscovery._discovered = False


@pytest.fixture
def fake_implementations(tmp_path, monkeypatch):
    """Set up a fake implementations directory and redirect discovery to it.

    Returns a helper function to create provider .py files in the fake dir.
    Patches __file__ and importlib on the auto_discovery module so that
    discover_providers scans and imports from the temp directory.
    """
    impl_dir = tmp_path / "implementations"
    impl_dir.mkdir()
    (impl_dir / "__init__.py").write_text("")

    import local_deep_research.llm.providers.auto_discovery as ad_mod

    # Redirect Path(__file__).parent / "implementations" to our tmp dir
    monkeypatch.setattr(ad_mod, "__file__", str(tmp_path / "auto_discovery.py"))

    # Wrap importlib.import_module to load from our tmp dir for fake modules
    real_import = importlib.import_module
    added_modules = []

    def fake_import_module(name, package=None):
        # For our fake implementations, load directly from disk
        if (
            name.startswith(".implementations.")
            and package == "local_deep_research.llm.providers"
        ):
            module_stem = name.rsplit(".", 1)[-1]
            module_file = impl_dir / f"{module_stem}.py"
            if module_file.exists():
                full_name = f"local_deep_research.llm.providers.implementations.{module_stem}"
                spec = importlib.util.spec_from_file_location(
                    full_name, str(module_file)
                )
                mod = importlib.util.module_from_spec(spec)
                sys.modules[full_name] = mod
                added_modules.append(full_name)
                spec.loader.exec_module(mod)
                return mod
        return real_import(name, package)

    # Create a wrapper importlib module with our patched import_module
    fake_importlib = types.ModuleType("fake_importlib")
    fake_importlib.import_module = fake_import_module
    monkeypatch.setattr(ad_mod, "importlib", fake_importlib)

    def add_provider(filename, provider_code):
        (impl_dir / filename).write_text(textwrap.dedent(provider_code))

    yield add_provider

    # Clean up sys.modules
    for mod_name in added_modules:
        sys.modules.pop(mod_name, None)


PROVIDER_TEMPLATE = """\
from local_deep_research.llm.providers.base import BaseLLMProvider

class {name}Provider(BaseLLMProvider):
    provider_name = "{name}"
    provider_key = "{key}"

    @classmethod
    def create_llm(cls, model_name=None, temperature=0.7, **kw):
        pass

    @classmethod
    def is_available(cls, settings_snapshot=None):
        return True

    @classmethod
    def requires_auth_for_models(cls):
        return False
"""


# ---------------------------------------------------------------------------
# Registration tests
# ---------------------------------------------------------------------------


class TestDiscoverProviderRegistersWithRegistry:
    """Verify discover_providers calls register_llm correctly."""

    def test_register_llm_called_for_discovered_provider(
        self, fake_implementations
    ):
        """register_llm is called when a provider class is discovered."""
        fake_implementations(
            "testprov.py", PROVIDER_TEMPLATE.format(name="Test", key="TESTPROV")
        )

        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            providers = discovery.discover_providers()

        mock_register.assert_called_once()
        assert "TESTPROV" in providers

    def test_register_llm_uses_lowercase_provider_key(
        self, fake_implementations
    ):
        """register_llm is called with provider_key.lower() as the name."""
        fake_implementations(
            "myprov.py",
            PROVIDER_TEMPLATE.format(name="MyProv", key="MY_PROVIDER"),
        )

        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            discovery.discover_providers()

        registered_name = mock_register.call_args[0][0]
        assert registered_name == "my_provider"

    def test_register_llm_uses_create_llm_callable(self, fake_implementations):
        """register_llm receives the provider class's create_llm method."""
        fake_implementations(
            "testprov.py", PROVIDER_TEMPLATE.format(name="Test", key="TESTPROV")
        )

        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            providers = discovery.discover_providers()

        registered_callable = mock_register.call_args[0][1]
        provider_class = providers["TESTPROV"].provider_class
        assert registered_callable == provider_class.create_llm

    def test_multiple_providers_all_registered(self, fake_implementations):
        """Each discovered provider is registered separately."""
        fake_implementations(
            "alpha.py", PROVIDER_TEMPLATE.format(name="Alpha", key="ALPHA")
        )
        fake_implementations(
            "beta.py", PROVIDER_TEMPLATE.format(name="Beta", key="BETA")
        )

        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            discovery.discover_providers()

        assert mock_register.call_count == 2
        registered_names = {call[0][0] for call in mock_register.call_args_list}
        assert registered_names == {"alpha", "beta"}


class TestDiscoverProviderFiltering:
    """Verify which classes are picked up and which are skipped."""

    def test_skips_underscore_prefixed_files(self, fake_implementations):
        """Files starting with _ (like __init__.py) are skipped."""
        fake_implementations(
            "_helpers.py", PROVIDER_TEMPLATE.format(name="Hidden", key="HIDDEN")
        )

        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            providers = discovery.discover_providers()

        mock_register.assert_not_called()
        assert "HIDDEN" not in providers

    def test_skips_non_provider_classes(self, fake_implementations):
        """Classes not ending with 'Provider' are ignored."""
        fake_implementations(
            "helper.py",
            """\
            class SomeHelper:
                provider_name = "helper"
            """,
        )

        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            providers = discovery.discover_providers()

        mock_register.assert_not_called()
        assert len(providers) == 0

    def test_skips_classes_without_provider_name(self, fake_implementations):
        """Provider subclasses without provider_name are ignored."""
        fake_implementations(
            "empty.py",
            textwrap.dedent("""\
            from local_deep_research.llm.providers.base import BaseLLMProvider

            class EmptyProvider(BaseLLMProvider):
                pass
            """),
        )

        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            discovery.discover_providers()

        mock_register.assert_not_called()


class TestDiscoverProviderErrorHandling:
    """Verify error handling during discovery."""

    def test_import_error_does_not_crash_discovery(self, fake_implementations):
        """A failing module import is logged but doesn't stop discovery."""
        fake_implementations(
            "broken.py",
            "raise ImportError('intentionally broken')\n",
        )
        fake_implementations(
            "good.py", PROVIDER_TEMPLATE.format(name="Good", key="GOOD")
        )

        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            result = discovery.discover_providers()

        assert discovery._discovered is True
        assert "GOOD" in result
        mock_register.assert_called_once()

    def test_missing_implementations_dir_returns_empty(self, monkeypatch):
        """Missing implementations directory returns empty dict."""
        import local_deep_research.llm.providers.auto_discovery as ad_mod

        monkeypatch.setattr(
            ad_mod, "__file__", "/nonexistent/path/auto_discovery.py"
        )

        with patch(f"{AUTO_DISC}.register_llm"):
            discovery = ProviderDiscovery()
            result = discovery.discover_providers()

        assert result == {}


class TestDiscoverProviderIntegration:
    """Integration tests with real provider implementations."""

    def test_all_real_providers_are_registered(self):
        """All discovered providers get registered in the llm_registry."""
        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            providers = discovery.discover_providers()

            assert mock_register.call_count == len(providers)

            registered_names = {
                call[0][0] for call in mock_register.call_args_list
            }
            expected_names = {key.lower() for key in providers}
            assert registered_names == expected_names

    def test_registered_callables_are_create_llm_methods(self):
        """Each registered callable is the provider class's create_llm."""
        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            providers = discovery.discover_providers()

            for call in mock_register.call_args_list:
                name, factory = call[0]
                provider_info = providers[name.upper()]
                assert factory == provider_info.provider_class.create_llm

    def test_known_providers_registered_with_expected_keys(self):
        """Well-known providers register with expected keys."""
        expected_keys = {"ollama", "openai", "anthropic", "google"}

        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            discovery.discover_providers()

            registered_names = {
                call[0][0] for call in mock_register.call_args_list
            }
            assert expected_keys.issubset(registered_names), (
                f"Missing: {expected_keys - registered_names}"
            )

    def test_provider_key_case_consistency(self):
        """Provider keys in _providers are uppercase, registered names lowercase."""
        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            providers = discovery.discover_providers()

            for key in providers:
                assert key == key.upper(), f"Provider key {key!r} not uppercase"

            for call in mock_register.call_args_list:
                name = call[0][0]
                assert name == name.lower(), (
                    f"Registered name {name!r} not lowercase"
                )

    def test_openai_endpoint_registers_as_lowercase(self):
        """OPENAI_ENDPOINT provider registers as 'openai_endpoint'."""
        with patch(f"{AUTO_DISC}.register_llm") as mock_register:
            discovery = ProviderDiscovery()
            providers = discovery.discover_providers()

            if "OPENAI_ENDPOINT" in providers:
                registered_names = {
                    call[0][0] for call in mock_register.call_args_list
                }
                assert "openai_endpoint" in registered_names
