"""
Tests for hybrid search (BM25 + vector + recency RRF) in Hippo.

Issue: #1188
"""

from __future__ import annotations

import math
import time
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock

import pytest
import pytest_asyncio

from cortex.hippo.config import HippoConfig
from cortex.hippo.db.connection import HippoDatabase
from cortex.hippo.retrieval import (
    HybridSearchResult,
    RetrievalEngine,
    pack_floats,
)
from cortex.hippo.store.conversation_store import ConversationStore
from cortex.hippo.store.summary_store import SummaryStore

# ═══════════════════════════════════════════════════════════════════════════
# Fixtures
# ═══════════════════════════════════════════════════════════════════════════

DIM = 4
OWNER = "test-owner"


def make_vec(values: list[float]) -> bytes:
    return pack_floats(values)


@pytest.fixture
def config() -> HippoConfig:
    return HippoConfig(
        enabled=True,
        db_path=":memory:",
        embedding_dim=DIM,
        vec_enabled=True,
        fresh_tail_count=4,
        max_tokens=10_000,
        hybrid_search_enabled=True,
        retrieval_bm25_weight=0.3,
        retrieval_vector_weight=0.5,
        retrieval_recency_weight=0.2,
        retrieval_recency_decay_hours=720.0,
    )


@pytest.fixture
def config_no_vec() -> HippoConfig:
    return HippoConfig(
        enabled=True,
        db_path=":memory:",
        embedding_dim=DIM,
        vec_enabled=False,
        fresh_tail_count=4,
        max_tokens=10_000,
        hybrid_search_enabled=True,
    )


@pytest_asyncio.fixture
async def db(config) -> HippoDatabase:
    database = HippoDatabase(config)
    await database.initialize()
    yield database
    await database.close()


@pytest_asyncio.fixture
async def db_no_vec(config_no_vec) -> HippoDatabase:
    database = HippoDatabase(config_no_vec)
    await database.initialize()
    yield database
    await database.close()


@pytest_asyncio.fixture
async def setup(db, config):
    """Return (engine, conv_store, sum_store, conv_id)."""
    conv_store = ConversationStore(db.conn)
    sum_store = SummaryStore(db.conn, vec_available=db.vec_available)
    engine = RetrievalEngine(
        conversation_store=conv_store,
        summary_store=sum_store,
        db=db,
        config=config,
    )
    conv = await conv_store.get_or_create_conversation(OWNER, "test-session")
    return engine, conv_store, sum_store, conv.conversation_id


@pytest_asyncio.fixture
async def setup_no_vec(db_no_vec, config_no_vec):
    """Return (engine, conv_store, sum_store, conv_id) without vec."""
    conv_store = ConversationStore(db_no_vec.conn)
    sum_store = SummaryStore(db_no_vec.conn, vec_available=False)
    engine = RetrievalEngine(
        conversation_store=conv_store,
        summary_store=sum_store,
        db=db_no_vec,
        config=config_no_vec,
    )
    conv = await conv_store.get_or_create_conversation(OWNER, "test-session")
    return engine, conv_store, sum_store, conv.conversation_id


async def _create_summary(sum_store, conv_id, content, embedding_vec=None):
    """Helper: create a summary and optionally set embedding."""
    summary = await sum_store.create_summary(
        conversation_id=conv_id,
        owner_id=OWNER,
        kind="leaf",
        depth=0,
        content=content,
        token_count=len(content) // 4,
    )
    if embedding_vec is not None:
        blob = make_vec(embedding_vec)
        await sum_store.update_summary_embedding(summary.summary_id, OWNER, blob)
    return summary


# ═══════════════════════════════════════════════════════════════════════════
# Tests
# ═══════════════════════════════════════════════════════════════════════════


class TestHybridSearch:
    """Tests for hybrid_search() method."""

    @pytest.mark.asyncio
    async def test_hybrid_returns_results(self, setup):
        """Basic hybrid search returns results."""
        engine, _, sum_store, conv_id = setup
        await _create_summary(sum_store, conv_id, "sqlite database migration guide", [1, 0, 0, 0])
        await _create_summary(sum_store, conv_id, "python async patterns", [0, 1, 0, 0])

        results = await engine.hybrid_search(
            query="sqlite",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
        )
        assert len(results) > 0
        assert all(isinstance(r, HybridSearchResult) for r in results)

    @pytest.mark.asyncio
    async def test_hybrid_combines_bm25_and_vector(self, setup):
        """Results from both BM25 and vector sources appear."""
        engine, _, sum_store, conv_id = setup
        # Keyword match only (different embedding)
        await _create_summary(sum_store, conv_id, "sqlite migration steps", [0, 0, 1, 0])
        # Vector match only (no keyword overlap, similar embedding)
        await _create_summary(sum_store, conv_id, "database relational storage", [0.9, 0.1, 0, 0])

        results = await engine.hybrid_search(
            query="sqlite",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
            limit=10,
        )
        summary_ids = {r.summary_id for r in results}
        assert len(summary_ids) >= 2, "Should include both keyword and vector matches"

    @pytest.mark.asyncio
    async def test_hybrid_rrf_scoring(self, setup):
        """RRF scores are computed correctly."""
        engine, _, sum_store, conv_id = setup
        # This summary should rank #1 in both BM25 and vector
        s1 = await _create_summary(sum_store, conv_id, "sqlite database performance", [1, 0, 0, 0])
        # This should rank lower
        await _create_summary(sum_store, conv_id, "python web framework", [0, 0, 0, 1])

        results = await engine.hybrid_search(
            query="sqlite database",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
        )
        assert len(results) >= 1
        # First result should be the one matching both signals
        assert results[0].summary_id == s1.summary_id
        assert results[0].score > 0

    @pytest.mark.asyncio
    async def test_hybrid_recency_boost(self, setup):
        """Newer results score higher with recency boost."""
        engine, _, sum_store, conv_id = setup
        # Create two similar summaries
        s1 = await _create_summary(sum_store, conv_id, "sqlite performance tuning", [1, 0, 0, 0])
        s2 = await _create_summary(sum_store, conv_id, "sqlite performance tips", [0.99, 0.01, 0, 0])

        results = await engine.hybrid_search(
            query="sqlite performance",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
        )
        assert len(results) >= 2
        # Both should have recency scores
        for r in results:
            assert r.recency_score is not None
            assert r.recency_score > 0

    @pytest.mark.asyncio
    async def test_hybrid_no_recency_when_zero_weight(self, setup):
        """Recency has no effect when weight is 0."""
        engine, _, sum_store, conv_id = setup
        await _create_summary(sum_store, conv_id, "sqlite data", [1, 0, 0, 0])

        results_with_recency = await engine.hybrid_search(
            query="sqlite",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
            recency_weight=0.5,
        )
        results_no_recency = await engine.hybrid_search(
            query="sqlite",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
            recency_weight=0.0,
        )
        assert len(results_with_recency) > 0
        assert len(results_no_recency) > 0
        # With zero recency weight, score should be pure RRF
        # The scores differ because recency_weight=0 means final = rrf * (1 + 0 * recency) = rrf
        # But with weight, final = rrf * (1 + w * recency)

    @pytest.mark.asyncio
    async def test_hybrid_keyword_match_surfaces(self, setup):
        """Exact keyword match appears in results even with low vector similarity."""
        engine, _, sum_store, conv_id = setup
        # Keyword match with orthogonal embedding
        s1 = await _create_summary(sum_store, conv_id, "sqlite vec extension guide", [0, 0, 0, 1])
        # No keyword match
        await _create_summary(sum_store, conv_id, "python async IO patterns", [0.8, 0, 0, 0.2])

        results = await engine.hybrid_search(
            query="sqlite vec",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
        )
        result_ids = {r.summary_id for r in results}
        assert s1.summary_id in result_ids

    @pytest.mark.asyncio
    async def test_hybrid_semantic_match_surfaces(self, setup):
        """Semantically similar result (no keyword overlap) appears."""
        engine, _, sum_store, conv_id = setup
        # No keyword match but close vector
        s1 = await _create_summary(sum_store, conv_id, "relational data storage engine", [0.95, 0.05, 0, 0])
        # Neither keyword nor vector
        await _create_summary(sum_store, conv_id, "cooking recipes collection", [0, 0, 1, 0])

        results = await engine.hybrid_search(
            query="xyznonexistent",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
        )
        # The semantic match should appear via vector search
        result_ids = {r.summary_id for r in results}
        assert s1.summary_id in result_ids

    @pytest.mark.asyncio
    async def test_hybrid_deduplication(self, setup):
        """Same summary from both BM25 and vector is not duplicated."""
        engine, _, sum_store, conv_id = setup
        # This will match both BM25 ("sqlite") and vector (close embedding)
        s1 = await _create_summary(sum_store, conv_id, "sqlite database engine", [1, 0, 0, 0])

        results = await engine.hybrid_search(
            query="sqlite",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
        )
        ids = [r.summary_id for r in results]
        assert ids.count(s1.summary_id) == 1, "Should not duplicate"

    @pytest.mark.asyncio
    async def test_hybrid_respects_conversation_filter(self, setup):
        """conversation_id filtering works in hybrid search."""
        engine, conv_store, sum_store, conv_id = setup
        # Create another conversation
        conv2 = await conv_store.get_or_create_conversation(OWNER, "other-session")
        conv2_id = conv2.conversation_id

        await _create_summary(sum_store, conv_id, "sqlite in conv1", [1, 0, 0, 0])
        await _create_summary(sum_store, conv2_id, "sqlite in conv2", [1, 0, 0, 0])

        results = await engine.hybrid_search(
            query="sqlite",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
            conversation_id=conv_id,
        )
        for r in results:
            assert r.conversation_id == conv_id

    @pytest.mark.asyncio
    async def test_hybrid_respects_owner_filter(self, setup):
        """owner_id filtering works — no cross-tenant leaks."""
        engine, conv_store, sum_store, conv_id = setup
        await _create_summary(sum_store, conv_id, "sqlite owner test", [1, 0, 0, 0])

        results = await engine.hybrid_search(
            query="sqlite",
            query_embedding=[1, 0, 0, 0],
            owner_id="other-owner",
        )
        assert len(results) == 0

    @pytest.mark.asyncio
    async def test_hybrid_config_weights(self, setup):
        """Custom weights affect ranking."""
        engine, _, sum_store, conv_id = setup
        # BM25-only match
        s_keyword = await _create_summary(sum_store, conv_id, "sqlite exact keyword match", [0, 0, 0, 1])
        # Vector-only match
        s_vector = await _create_summary(sum_store, conv_id, "relational data storage", [0.99, 0.01, 0, 0])

        # Heavy BM25 weight — keyword match should rank higher
        results_bm25_heavy = await engine.hybrid_search(
            query="sqlite",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
            bm25_weight=0.9,
            vector_weight=0.1,
            recency_weight=0.0,
        )
        # Heavy vector weight — vector match should rank higher
        results_vec_heavy = await engine.hybrid_search(
            query="sqlite",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
            bm25_weight=0.1,
            vector_weight=0.9,
            recency_weight=0.0,
        )
        if len(results_bm25_heavy) >= 2 and len(results_vec_heavy) >= 2:
            # With bm25 heavy, the keyword match should score higher
            bm25_keyword_score = next(
                (r.score for r in results_bm25_heavy if r.summary_id == s_keyword.summary_id), 0
            )
            bm25_vector_score = next(
                (r.score for r in results_bm25_heavy if r.summary_id == s_vector.summary_id), 0
            )
            # With bm25 heavy, keyword should dominate
            assert bm25_keyword_score >= bm25_vector_score

    @pytest.mark.asyncio
    async def test_hybrid_fallback_when_no_vec(self, setup_no_vec):
        """Works with only BM25 when vec is unavailable."""
        engine, _, sum_store, conv_id = setup_no_vec
        await _create_summary(sum_store, conv_id, "sqlite fallback test", None)

        results = await engine.hybrid_search(
            query="sqlite",
            query_embedding=[],
            owner_id=OWNER,
        )
        assert len(results) >= 1
        assert results[0].content == "sqlite fallback test"

    @pytest.mark.asyncio
    async def test_hybrid_empty_query(self, setup):
        """Empty or very short queries still work gracefully."""
        engine, _, sum_store, conv_id = setup
        await _create_summary(sum_store, conv_id, "some content", [1, 0, 0, 0])

        # Single-char query may be stripped by FTS sanitizer, should still work
        results = await engine.hybrid_search(
            query="x",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
        )
        # Should not crash — may return results via vector
        assert isinstance(results, list)

    @pytest.mark.asyncio
    async def test_hybrid_limit_respected(self, setup):
        """Limit parameter caps the number of results."""
        engine, _, sum_store, conv_id = setup
        for i in range(10):
            await _create_summary(sum_store, conv_id, f"sqlite item {i}", [1, 0, 0, 0])

        results = await engine.hybrid_search(
            query="sqlite",
            query_embedding=[1, 0, 0, 0],
            owner_id=OWNER,
            limit=3,
        )
        assert len(results) <= 3
