"""Tests for RAGSearchTool - search knowledge base from sync context."""
from __future__ import annotations

import threading
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from agenai.agents.teaching_assistant.tools.rag_search import RAGSearchTool, _BackgroundLoop


def _reset_background_loop():
    """Reset BackgroundLoop singleton for test isolation."""
    with _BackgroundLoop._lock:
        if _BackgroundLoop._instance is not None:
            # Stop the background loop if running
            if _BackgroundLoop._instance._loop is not None:
                _BackgroundLoop._instance._loop.call_soon_threadsafe(
                    _BackgroundLoop._instance._loop.stop
                )
            if _BackgroundLoop._instance._thread is not None:
                _BackgroundLoop._instance._thread.join(timeout=1)
        _BackgroundLoop._instance = None


@dataclass
class MockSearchResult:
    """Mock search result for testing."""
    text: str
    title: str
    url: str
    score: float


class TestBackgroundLoopSingleton:
    """Tests for _BackgroundLoop singleton pattern."""

    def setup_method(self):
        _reset_background_loop()

    def teardown_method(self):
        _reset_background_loop()

    def test_singleton_returns_same_instance(self):
        loop1 = _BackgroundLoop()
        loop2 = _BackgroundLoop()
        assert loop1 is loop2

    def test_singleton_across_threads(self):
        """Verify thread-safe singleton - all threads get same instance."""
        instances = []
        errors = []

        def get_instance():
            try:
                instances.append(_BackgroundLoop())
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=get_instance) for _ in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        assert len(instances) == 10
        assert all(inst is instances[0] for inst in instances)


class TestBackgroundLoopEnsureStarted:
    """Tests for _BackgroundLoop.ensure_started()."""

    def setup_method(self):
        _reset_background_loop()

    def teardown_method(self):
        _reset_background_loop()

    def test_starts_thread(self):
        loop = _BackgroundLoop()
        loop.ensure_started()

        assert loop._thread is not None
        assert loop._thread.is_alive()

    def test_creates_event_loop(self):
        loop = _BackgroundLoop()
        loop.ensure_started()

        assert loop._loop is not None

    def test_idempotent(self):
        loop = _BackgroundLoop()
        loop.ensure_started()
        thread1 = loop._thread

        loop.ensure_started()
        thread2 = loop._thread

        assert thread1 is thread2


class TestBackgroundLoopRunCoroutine:
    """Tests for _BackgroundLoop.run_coroutine()."""

    def setup_method(self):
        _reset_background_loop()

    def teardown_method(self):
        _reset_background_loop()

    def test_runs_coroutine_and_returns_result(self):
        loop = _BackgroundLoop()

        async def simple_coro():
            return 42

        result = loop.run_coroutine(simple_coro())

        assert result == 42

    def test_runs_async_function(self):
        loop = _BackgroundLoop()

        async def async_add(a, b):
            return a + b

        result = loop.run_coroutine(async_add(3, 4))

        assert result == 7

    def test_propagates_exceptions(self):
        loop = _BackgroundLoop()

        async def failing_coro():
            raise ValueError("test error")

        with pytest.raises(ValueError, match="test error"):
            loop.run_coroutine(failing_coro())


class TestRAGSearchToolInit:
    """Tests for RAGSearchTool initialization."""

    def setup_method(self):
        _reset_background_loop()

    def teardown_method(self):
        _reset_background_loop()

    def test_stores_retriever(self):
        mock_retriever = MagicMock()
        tool = RAGSearchTool(retriever=mock_retriever)
        assert tool._retriever is mock_retriever

    def test_default_language(self):
        mock_retriever = MagicMock()
        tool = RAGSearchTool(retriever=mock_retriever)
        assert tool._default_language == "python"

    def test_custom_default_language(self):
        mock_retriever = MagicMock()
        tool = RAGSearchTool(retriever=mock_retriever, default_language="javascript")
        assert tool._default_language == "javascript"


class TestRAGSearchToolProperties:
    """Tests for RAGSearchTool properties."""

    def setup_method(self):
        _reset_background_loop()

    def teardown_method(self):
        _reset_background_loop()

    def test_name(self):
        mock_retriever = MagicMock()
        tool = RAGSearchTool(retriever=mock_retriever)
        assert tool.name == "search_docs"

    def test_description_not_empty(self):
        mock_retriever = MagicMock()
        tool = RAGSearchTool(retriever=mock_retriever)
        assert len(tool.description) > 0

    def test_parameters_has_query(self):
        mock_retriever = MagicMock()
        tool = RAGSearchTool(retriever=mock_retriever)
        params = tool.parameters
        assert "query" in params["properties"]
        assert "query" in params["required"]

    def test_parameters_has_language(self):
        mock_retriever = MagicMock()
        tool = RAGSearchTool(retriever=mock_retriever)
        params = tool.parameters
        assert "language" in params["properties"]
        assert params["properties"]["language"]["enum"] == ["python", "javascript"]

    def test_parameters_has_num_results(self):
        mock_retriever = MagicMock()
        tool = RAGSearchTool(retriever=mock_retriever)
        params = tool.parameters
        assert "num_results" in params["properties"]


class TestRAGSearchToolExecute:
    """Tests for RAGSearchTool.execute()."""

    def setup_method(self):
        _reset_background_loop()

    def teardown_method(self):
        _reset_background_loop()

    @pytest.fixture
    def mock_retriever(self):
        retriever = MagicMock()
        retriever.search = AsyncMock(return_value=[
            MockSearchResult(
                text="Python is a programming language.",
                title="Python Intro",
                url="https://w3schools.com/python/intro.asp",
                score=0.9,
            )
        ])
        return retriever

    @pytest.fixture
    def tool_with_mocked_session(self, mock_retriever):
        tool = RAGSearchTool(retriever=mock_retriever)

        # Mock the session maker
        mock_session = AsyncMock()
        mock_session.__aenter__.return_value = mock_session
        mock_session.__aexit__.return_value = None

        mock_session_maker = MagicMock(return_value=mock_session)

        with patch.object(tool._bg_loop, "get_session_maker", return_value=mock_session_maker):
            yield tool

    def test_execute_returns_tool_result(self, tool_with_mocked_session):
        result = tool_with_mocked_session.execute(query="what is python")

        assert hasattr(result, "success")
        assert hasattr(result, "output")

    def test_execute_success_with_results(self, tool_with_mocked_session):
        result = tool_with_mocked_session.execute(query="what is python")

        assert result.success is True
        assert "Python Intro" in result.output

    def test_execute_includes_url(self, tool_with_mocked_session):
        result = tool_with_mocked_session.execute(query="what is python")

        assert "https://w3schools.com/python/intro.asp" in result.output

    def test_execute_includes_score(self, tool_with_mocked_session):
        result = tool_with_mocked_session.execute(query="what is python")

        assert "90%" in result.output  # 0.9 formatted as percentage

    def test_execute_no_results(self, mock_retriever):
        mock_retriever.search = AsyncMock(return_value=[])
        tool = RAGSearchTool(retriever=mock_retriever)

        mock_session = AsyncMock()
        mock_session.__aenter__.return_value = mock_session
        mock_session.__aexit__.return_value = None
        mock_session_maker = MagicMock(return_value=mock_session)

        with patch.object(tool._bg_loop, "get_session_maker", return_value=mock_session_maker):
            result = tool.execute(query="nonexistent topic")

        assert result.success is True
        assert "No documentation found" in result.output

    def test_execute_uses_default_language(self, mock_retriever):
        tool = RAGSearchTool(retriever=mock_retriever, default_language="python")

        mock_session = AsyncMock()
        mock_session.__aenter__.return_value = mock_session
        mock_session.__aexit__.return_value = None
        mock_session_maker = MagicMock(return_value=mock_session)

        with patch.object(tool._bg_loop, "get_session_maker", return_value=mock_session_maker):
            tool.execute(query="test")

        mock_retriever.search.assert_called_once()
        call_kwargs = mock_retriever.search.call_args[1]
        assert call_kwargs["language"] == "python"

    def test_execute_override_language(self, mock_retriever):
        tool = RAGSearchTool(retriever=mock_retriever, default_language="python")

        mock_session = AsyncMock()
        mock_session.__aenter__.return_value = mock_session
        mock_session.__aexit__.return_value = None
        mock_session_maker = MagicMock(return_value=mock_session)

        with patch.object(tool._bg_loop, "get_session_maker", return_value=mock_session_maker):
            tool.execute(query="test", language="javascript")

        call_kwargs = mock_retriever.search.call_args[1]
        assert call_kwargs["language"] == "javascript"

    def test_execute_clamps_num_results_min(self, mock_retriever):
        tool = RAGSearchTool(retriever=mock_retriever)

        mock_session = AsyncMock()
        mock_session.__aenter__.return_value = mock_session
        mock_session.__aexit__.return_value = None
        mock_session_maker = MagicMock(return_value=mock_session)

        with patch.object(tool._bg_loop, "get_session_maker", return_value=mock_session_maker):
            tool.execute(query="test", num_results=0)

        call_kwargs = mock_retriever.search.call_args[1]
        assert call_kwargs["top_k"] == 1  # Clamped to minimum

    def test_execute_clamps_num_results_max(self, mock_retriever):
        tool = RAGSearchTool(retriever=mock_retriever)

        mock_session = AsyncMock()
        mock_session.__aenter__.return_value = mock_session
        mock_session.__aexit__.return_value = None
        mock_session_maker = MagicMock(return_value=mock_session)

        with patch.object(tool._bg_loop, "get_session_maker", return_value=mock_session_maker):
            tool.execute(query="test", num_results=100)

        call_kwargs = mock_retriever.search.call_args[1]
        assert call_kwargs["top_k"] == 5  # Clamped to maximum

    def test_execute_truncates_long_text(self, mock_retriever):
        long_text = "A" * 700
        mock_retriever.search = AsyncMock(return_value=[
            MockSearchResult(
                text=long_text,
                title="Title",
                url="http://example.com",
                score=0.8,
            )
        ])
        tool = RAGSearchTool(retriever=mock_retriever)

        mock_session = AsyncMock()
        mock_session.__aenter__.return_value = mock_session
        mock_session.__aexit__.return_value = None
        mock_session_maker = MagicMock(return_value=mock_session)

        with patch.object(tool._bg_loop, "get_session_maker", return_value=mock_session_maker):
            result = tool.execute(query="test")

        # Should be truncated with ...
        assert "..." in result.output
        assert "A" * 600 in result.output
        assert "A" * 700 not in result.output

    def test_execute_handles_exception(self, mock_retriever):
        mock_retriever.search = AsyncMock(side_effect=RuntimeError("DB error"))
        tool = RAGSearchTool(retriever=mock_retriever)

        mock_session = AsyncMock()
        mock_session.__aenter__.return_value = mock_session
        mock_session.__aexit__.return_value = None
        mock_session_maker = MagicMock(return_value=mock_session)

        with patch.object(tool._bg_loop, "get_session_maker", return_value=mock_session_maker):
            result = tool.execute(query="test")

        assert result.success is False
        # Error message is in the error field
        assert "Search failed" in result.error or "DB error" in result.error

    def test_execute_multiple_results(self, mock_retriever):
        mock_retriever.search = AsyncMock(return_value=[
            MockSearchResult("Text 1", "Title 1", "url1", 0.9),
            MockSearchResult("Text 2", "Title 2", "url2", 0.8),
            MockSearchResult("Text 3", "Title 3", "url3", 0.7),
        ])
        tool = RAGSearchTool(retriever=mock_retriever)

        mock_session = AsyncMock()
        mock_session.__aenter__.return_value = mock_session
        mock_session.__aexit__.return_value = None
        mock_session_maker = MagicMock(return_value=mock_session)

        with patch.object(tool._bg_loop, "get_session_maker", return_value=mock_session_maker):
            result = tool.execute(query="test")

        assert "Title 1" in result.output
        assert "Title 2" in result.output
        assert "Title 3" in result.output
        assert "1." in result.output
        assert "2." in result.output
        assert "3." in result.output
