"""
Tests for Story 4.04 — BinduHydrator._scatter_qdrant_task + _embed_query

BB1: Qdrant returns 5 results (3 above 0.7, 2 below) → returns only 3
BB2: Empty collection → returns []
BB3: top_k=1 with 3 above threshold → returns only 1
WB1: Qdrant search called with score_threshold=0.7
WB2: Collection name is "aiva_conversations"
WB3: Each result dict has all 4 required keys
WB4: _embed_query produces 768-dim vector
WB5: On Qdrant exception → returns [] (not raises)
Package: No SQLite, importable

All Qdrant calls are mocked — zero real I/O.
"""
import sys
import importlib
import types

import pytest
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch, call


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_hit(chunk_text: str, score: float, conversation_id: str, timestamp: str):
    """Build a mock Qdrant ScoredPoint-like object."""
    hit = MagicMock()
    hit.score = score
    hit.payload = {
        "chunk_text": chunk_text,
        "score": score,
        "conversation_id": conversation_id,
        "timestamp": timestamp,
    }
    return hit


def _make_hydrator(qdrant_client=None):
    """Instantiate BinduHydrator with minimal mock clients."""
    from core.hydrators.bindu_hydrator import BinduHydrator

    redis_mock = AsyncMock()
    redis_mock.setex = AsyncMock(return_value=True)
    redis_mock.get = AsyncMock(return_value=None)

    return BinduHydrator(
        redis_client=redis_mock,
        postgres_client=None,
        qdrant_client=qdrant_client,
    )


# ---------------------------------------------------------------------------
# Package / no-SQLite guards
# ---------------------------------------------------------------------------

class TestPackageGuards:
    def test_no_sqlite_import(self):
        """sqlite3 must not be imported anywhere in bindu_hydrator."""
        import core.hydrators.bindu_hydrator as mod
        import inspect
        source = inspect.getsource(mod)
        assert "import sqlite3" not in source, "sqlite3 is BANNED — found in bindu_hydrator"

    def test_module_importable(self):
        """bindu_hydrator must import without errors."""
        import core.hydrators.bindu_hydrator  # noqa: F401  (import as side-effect test)


# ---------------------------------------------------------------------------
# BB1: Qdrant returns 5 hits (3 above 0.7, 2 below) → only 3 returned
# ---------------------------------------------------------------------------

class TestBB1AboveThresholdFilter:
    @pytest.mark.asyncio
    async def test_returns_only_hits_above_threshold(self):
        """
        Qdrant search is mocked to return 5 results: 3 with score > 0.7 and
        2 with score <= 0.7. Because Qdrant applies score_threshold server-side,
        we simulate it by having the mock return only the 3 qualifying hits
        (matching real Qdrant behaviour where the server filters before returning).
        """
        qualifying_hits = [
            _make_hit("chunk A", 0.95, "conv-1", "2026-01-01T00:00:00Z"),
            _make_hit("chunk B", 0.80, "conv-2", "2026-01-02T00:00:00Z"),
            _make_hit("chunk C", 0.72, "conv-3", "2026-01-03T00:00:00Z"),
        ]

        qdrant_mock = AsyncMock()
        qdrant_mock.search = AsyncMock(return_value=qualifying_hits)

        hydrator = _make_hydrator(qdrant_client=qdrant_mock)
        results = await hydrator._scatter_qdrant_task("what did we decide about pricing?")

        assert len(results) == 3, f"Expected 3 results, got {len(results)}"
        scores = [r["score"] for r in results]
        assert all(s > 0.7 for s in scores), f"Some scores are at or below threshold: {scores}"


# ---------------------------------------------------------------------------
# BB2: Empty collection → returns []
# ---------------------------------------------------------------------------

class TestBB2EmptyCollection:
    @pytest.mark.asyncio
    async def test_empty_collection_returns_empty_list(self):
        """Qdrant returns [] → _scatter_qdrant_task returns []."""
        qdrant_mock = AsyncMock()
        qdrant_mock.search = AsyncMock(return_value=[])

        hydrator = _make_hydrator(qdrant_client=qdrant_mock)
        results = await hydrator._scatter_qdrant_task("orphan query")

        assert results == [], f"Expected [], got {results!r}"


# ---------------------------------------------------------------------------
# BB3: top_k=1 with 3 above threshold → returns only 1
# ---------------------------------------------------------------------------

class TestBB3TopKLimit:
    @pytest.mark.asyncio
    async def test_top_k_1_returns_single_result(self):
        """
        top_k=1: Qdrant is called with limit=1 and returns 1 hit.
        The method must return a list of exactly 1 item.
        """
        single_hit = [
            _make_hit("best chunk", 0.99, "conv-99", "2026-01-10T12:00:00Z"),
        ]

        qdrant_mock = AsyncMock()
        qdrant_mock.search = AsyncMock(return_value=single_hit)

        hydrator = _make_hydrator(qdrant_client=qdrant_mock)
        results = await hydrator._scatter_qdrant_task("urgent query", top_k=1)

        assert len(results) == 1, f"Expected 1 result, got {len(results)}"
        assert results[0]["chunk_text"] == "best chunk"


# ---------------------------------------------------------------------------
# WB1: Qdrant search called with score_threshold=0.7
# ---------------------------------------------------------------------------

class TestWB1ScoreThreshold:
    @pytest.mark.asyncio
    async def test_search_called_with_correct_score_threshold(self):
        """_scatter_qdrant_task must pass score_threshold=0.7 to qdrant.search()."""
        qdrant_mock = AsyncMock()
        qdrant_mock.search = AsyncMock(return_value=[])

        hydrator = _make_hydrator(qdrant_client=qdrant_mock)
        await hydrator._scatter_qdrant_task("any query")

        qdrant_mock.search.assert_called_once()
        _, kwargs = qdrant_mock.search.call_args
        assert kwargs.get("score_threshold") == 0.7, (
            f"Expected score_threshold=0.7, got {kwargs.get('score_threshold')!r}"
        )


# ---------------------------------------------------------------------------
# WB2: Collection name is "aiva_conversations"
# ---------------------------------------------------------------------------

class TestWB2CollectionName:
    @pytest.mark.asyncio
    async def test_search_uses_correct_collection_name(self):
        """_scatter_qdrant_task must search the 'aiva_conversations' collection."""
        qdrant_mock = AsyncMock()
        qdrant_mock.search = AsyncMock(return_value=[])

        hydrator = _make_hydrator(qdrant_client=qdrant_mock)
        await hydrator._scatter_qdrant_task("any query")

        qdrant_mock.search.assert_called_once()
        _, kwargs = qdrant_mock.search.call_args
        assert kwargs.get("collection_name") == "aiva_conversations", (
            f"Wrong collection name: {kwargs.get('collection_name')!r}"
        )


# ---------------------------------------------------------------------------
# WB3: Each result dict has all 4 required keys
# ---------------------------------------------------------------------------

class TestWB3ResultShape:
    REQUIRED_KEYS = {"chunk_text", "score", "conversation_id", "timestamp"}

    @pytest.mark.asyncio
    async def test_result_dicts_have_all_required_keys(self):
        """Every item in the returned list must contain exactly the 4 required keys."""
        hits = [
            _make_hit("text 1", 0.91, "c-001", "2026-02-01T08:00:00Z"),
            _make_hit("text 2", 0.85, "c-002", "2026-02-02T09:00:00Z"),
        ]

        qdrant_mock = AsyncMock()
        qdrant_mock.search = AsyncMock(return_value=hits)

        hydrator = _make_hydrator(qdrant_client=qdrant_mock)
        results = await hydrator._scatter_qdrant_task("shape test")

        assert len(results) == 2
        for i, result in enumerate(results):
            missing = self.REQUIRED_KEYS - result.keys()
            assert not missing, f"Result[{i}] missing keys: {missing}"
            assert isinstance(result["chunk_text"], str)
            assert isinstance(result["score"], float)
            assert isinstance(result["conversation_id"], str)
            assert isinstance(result["timestamp"], str)


# ---------------------------------------------------------------------------
# WB4: _embed_query produces 768-dim vector
# ---------------------------------------------------------------------------

class TestWB4EmbedQuery:
    def test_embed_query_returns_768_dimensions(self):
        """_embed_query must return a list of exactly 768 floats."""
        hydrator = _make_hydrator()
        vector = hydrator._embed_query("test embedding query")

        assert isinstance(vector, list), f"Expected list, got {type(vector).__name__}"
        assert len(vector) == 768, f"Expected 768 dims, got {len(vector)}"
        assert all(isinstance(v, float) for v in vector), "All elements must be float"

    def test_embed_query_is_deterministic(self):
        """Same input must always produce the same vector."""
        hydrator = _make_hydrator()
        query = "deterministic test"
        v1 = hydrator._embed_query(query)
        v2 = hydrator._embed_query(query)
        assert v1 == v2, "_embed_query is not deterministic"

    def test_embed_query_different_inputs_differ(self):
        """Different queries should produce different vectors."""
        hydrator = _make_hydrator()
        v1 = hydrator._embed_query("query alpha")
        v2 = hydrator._embed_query("query beta")
        assert v1 != v2, "Different inputs must produce different vectors"

    def test_embed_query_values_in_range(self):
        """All values must be in the range [-1.0, 1.0]."""
        hydrator = _make_hydrator()
        vector = hydrator._embed_query("range test")
        out_of_range = [v for v in vector if not (-1.0 <= v <= 1.0)]
        assert not out_of_range, f"Values out of [-1.0, 1.0]: {out_of_range[:5]}"


# ---------------------------------------------------------------------------
# WB5: On Qdrant exception → returns [] (not raises)
# ---------------------------------------------------------------------------

class TestWB5ExceptionHandling:
    @pytest.mark.asyncio
    async def test_qdrant_exception_returns_empty_list(self):
        """If qdrant.search() raises, _scatter_qdrant_task must return [] (non-fatal)."""
        qdrant_mock = AsyncMock()
        qdrant_mock.search = AsyncMock(side_effect=RuntimeError("Qdrant connection refused"))

        hydrator = _make_hydrator(qdrant_client=qdrant_mock)

        # Must NOT raise — must return []
        results = await hydrator._scatter_qdrant_task("crash test")
        assert results == [], f"Expected [], got {results!r}"

    @pytest.mark.asyncio
    async def test_none_qdrant_client_returns_empty_list(self):
        """If no Qdrant client is configured, must return [] gracefully."""
        hydrator = _make_hydrator(qdrant_client=None)
        results = await hydrator._scatter_qdrant_task("no client")
        assert results == [], f"Expected [], got {results!r}"
