"""Tests for hybrid search module (FTS5 + vector RRF fusion)."""

from __future__ import annotations

import pytest

# ============================================================================
# Unit Tests for FTS5 Query Escaping
# ============================================================================


class TestEscapeFTS5Query:
    """Tests for _escape_fts5_query function."""

    def test_escape_simple_query(self) -> None:
        """Simple multi-word query should be wrapped in quotes."""
        from claude_engram.hybrid_search import _escape_fts5_query

        result = _escape_fts5_query("hello world")
        assert result == '"hello" "world"'

    def test_escape_empty_query(self) -> None:
        """Empty query should return empty string."""
        from claude_engram.hybrid_search import _escape_fts5_query

        result = _escape_fts5_query("")
        assert result == ""

    def test_escape_special_chars(self) -> None:
        """FTS5 operators should be escaped."""
        from claude_engram.hybrid_search import _escape_fts5_query

        # AND, OR, NOT should be treated as literals
        result = _escape_fts5_query("AND OR NOT")
        assert '"AND"' in result
        assert '"OR"' in result
        assert '"NOT"' in result

    def test_escape_single_word(self) -> None:
        """Single word query should be wrapped in quotes."""
        from claude_engram.hybrid_search import _escape_fts5_query

        result = _escape_fts5_query("decisions")
        assert result == '"decisions"'

    def test_escape_punctuation(self) -> None:
        """Punctuation should be handled gracefully."""
        from claude_engram.hybrid_search import _escape_fts5_query

        result = _escape_fts5_query("Phase 7.1")
        # Should split on whitespace and wrap each token
        assert '"Phase"' in result
        assert '"7.1"' in result


# ============================================================================
# Unit Tests for RRF Fusion
# ============================================================================


class TestFuseRRF:
    """Tests for _fuse_rrf function."""

    def test_rrf_both_retrievers_overlap(self) -> None:
        """Memory found by both retrievers should rank highest."""
        from claude_engram.hybrid_search import HybridSearchConfig, _fuse_rrf

        fts_results = [(1, 10.5), (2, 8.2)]
        vec_results = [(1, 0.15), (3, 0.25)]
        config = HybridSearchConfig()

        results = _fuse_rrf(fts_results, vec_results, config)

        # Memory 1 appears in both, should rank first
        assert results[0][0] == 1
        assert results[0][1] > 0  # RRF score > 0

    def test_rrf_disjoint_results(self) -> None:
        """Non-overlapping results should still be ranked."""
        from claude_engram.hybrid_search import HybridSearchConfig, _fuse_rrf

        fts_results = [(1, 10.5)]
        vec_results = [(2, 0.15)]
        config = HybridSearchConfig()

        results = _fuse_rrf(fts_results, vec_results, config)

        # Both should appear in results
        memory_ids = [r[0] for r in results]
        assert 1 in memory_ids
        assert 2 in memory_ids

    def test_rrf_empty_fts(self) -> None:
        """Empty FTS results should fall back to vector-only."""
        from claude_engram.hybrid_search import HybridSearchConfig, _fuse_rrf

        fts_results = []
        vec_results = [(1, 0.15), (2, 0.25)]
        config = HybridSearchConfig()

        results = _fuse_rrf(fts_results, vec_results, config)

        # Should return vector results
        assert len(results) == 2
        memory_ids = [r[0] for r in results]
        assert 1 in memory_ids
        assert 2 in memory_ids

    def test_rrf_empty_vec(self) -> None:
        """Empty vector results should fall back to FTS-only."""
        from claude_engram.hybrid_search import HybridSearchConfig, _fuse_rrf

        fts_results = [(1, 10.5), (2, 8.2)]
        vec_results = []
        config = HybridSearchConfig()

        results = _fuse_rrf(fts_results, vec_results, config)

        # Should return FTS results
        assert len(results) == 2
        memory_ids = [r[0] for r in results]
        assert 1 in memory_ids
        assert 2 in memory_ids

    def test_rrf_ordering(self) -> None:
        """Results should be sorted by RRF score descending."""
        from claude_engram.hybrid_search import HybridSearchConfig, _fuse_rrf

        # Memory 1 ranks high in both, memory 2 only in FTS, memory 3 only in vec
        fts_results = [(1, 15.0), (2, 10.0)]
        vec_results = [(1, 0.10), (3, 0.20)]
        config = HybridSearchConfig()

        results = _fuse_rrf(fts_results, vec_results, config)

        # Memory 1 (dual-match) should rank first
        assert results[0][0] == 1
        # RRF scores should be descending
        assert results[0][1] >= results[1][1] >= results[2][1]

    def test_rrf_weights(self) -> None:
        """Asymmetric weights should shift ranking."""
        from claude_engram.hybrid_search import HybridSearchConfig, _fuse_rrf

        fts_results = [(1, 10.0)]
        vec_results = [(2, 0.15)]

        # FTS-heavy weights
        config_fts = HybridSearchConfig(weight_fts=2.0, weight_vec=1.0)
        results_fts = _fuse_rrf(fts_results, vec_results, config_fts)

        # With higher FTS weight, memory 1 (FTS match) should rank higher
        assert results_fts[0][0] == 1
        assert results_fts[1][0] == 2

    def test_rrf_unranked_cap(self) -> None:
        """Unranked results should use candidate_k + 1 as fallback rank, not infinity."""
        from claude_engram.hybrid_search import HybridSearchConfig, _fuse_rrf

        # Memory 1 only in FTS, memory 2 only in vector
        fts_results = [(1, 10.0)]
        vec_results = [(2, 0.15)]
        config = HybridSearchConfig(candidate_k=50)

        results = _fuse_rrf(fts_results, vec_results, config)

        # Both should have finite scores (not infinite)
        assert all(r[1] < float("inf") for r in results)
        assert all(r[1] > 0 for r in results)


# ============================================================================
# Unit Tests for has_fts5_table
# ============================================================================


class TestHasFTS5Table:
    """Tests for has_fts5_table function."""

    def test_fts5_table_exists_after_migration(self, tmp_path) -> None:
        """After migration, has_fts5_table should return True."""
        import sqlite3

        from claude_engram.hybrid_search import has_fts5_table
        from claude_engram.migration import migrate_schema

        db_path = tmp_path / "test_v10.db"
        conn = sqlite3.connect(str(db_path))
        conn.executescript("""
            CREATE TABLE memories (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                content TEXT NOT NULL,
                category TEXT DEFAULT 'general',
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL,
                access_count INTEGER DEFAULT 0
            );
            PRAGMA user_version = 9;
        """)
        conn.commit()

        # Before migration
        assert not has_fts5_table(conn)

        # After migration
        migrate_schema(conn)
        assert has_fts5_table(conn)

    def test_fts5_table_missing_pre_migration(self, tmp_path) -> None:
        """Pre-v10 database should return False."""
        import sqlite3

        from claude_engram.hybrid_search import has_fts5_table

        db_path = tmp_path / "test_v9.db"
        conn = sqlite3.connect(str(db_path))
        conn.executescript("""
            CREATE TABLE memories (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                content TEXT NOT NULL,
                category TEXT DEFAULT 'general'
            );
            PRAGMA user_version = 9;
        """)
        conn.commit()

        assert not has_fts5_table(conn)


# ============================================================================
# Integration Tests — MemoryStorage with real sqlite-vec
# ============================================================================


class TestHybridSearchIntegration:
    """Integration tests requiring MemoryStorage with real sqlite-vec."""

    @pytest.fixture()
    def storage(self, tmp_path):
        """Create a MemoryStorage instance with a fresh database."""
        from claude_engram.storage import MemoryStorage

        db_path = tmp_path / "test.db"
        storage = MemoryStorage(db_path=db_path)
        yield storage
        storage.close()

    def test_hybrid_search_finds_keyword_match(self, storage) -> None:
        """Store memory with specific keywords, verify hybrid finds it."""
        storage.store("Phase 7.1 decision about auth tokens", category="decision")
        results = storage.search("Phase 7.1", search_mode="hybrid")
        assert len(results) > 0
        assert any("Phase 7.1" in r["content"] for r in results)

    def test_hybrid_search_finds_semantic_match(self, storage) -> None:
        """Semantic search still works through hybrid mode."""
        storage.store("authentication strategy for API gateway", category="decision")
        results = storage.search("login approach", search_mode="hybrid")
        assert len(results) > 0

    def test_hybrid_search_boosts_dual_match(self, storage) -> None:
        """Memory matching both keyword AND semantic should rank higher."""
        storage.store("Python debugging with breakpoints and pdb", category="lesson")
        storage.store("JavaScript frontend React components", category="lesson")
        results = storage.search("Python debugging", search_mode="hybrid")
        assert len(results) >= 1
        # The Python debugging memory should be ranked first
        assert "Python" in results[0]["content"] or "debug" in results[0]["content"].lower()

    def test_hybrid_search_respects_proj_tag(self, storage) -> None:
        """Project-scoped search only returns matching project memories."""
        storage.store("global memory about cats", category="general")
        storage.store(
            "project-specific memory about dogs",
            category="general",
            proj_tag="test-project",
        )
        results = storage.search(
            "animals",
            search_mode="hybrid",
            proj_tag="test-project",
            include_global=False,
        )
        # Should only find the project-specific memory
        for r in results:
            if "content" in r:
                assert "dogs" in r["content"] or r.get("proj_tag") == "test-project"

    def test_search_mode_vector_unchanged(self, storage) -> None:
        """Default search_mode='vector' produces results (backward compat)."""
        storage.store("test memory for vector search", category="general")
        results = storage.search("test memory", search_mode="vector")
        assert len(results) > 0

    def test_search_mode_keyword_only(self, storage) -> None:
        """search_mode='keyword' works without embedding."""
        storage.store("unique-keyword-12345 in this memory", category="general")
        results = storage.search("unique-keyword-12345", search_mode="keyword")
        assert len(results) > 0
        assert any("unique-keyword-12345" in r["content"] for r in results)

    def test_fallback_to_vector_when_no_fts5(self, storage) -> None:
        """Pre-v10 DB silently falls back to vector-only."""
        # Drop FTS5 tables and triggers to simulate pre-v10 schema
        conn = storage._get_conn()
        conn.execute("DROP TABLE IF EXISTS memory_fts")
        conn.execute("DROP TRIGGER IF EXISTS memories_ai_fts")
        conn.execute("DROP TRIGGER IF EXISTS memories_ad_fts")
        conn.execute("DROP TRIGGER IF EXISTS memories_au_fts")
        storage.store("fallback test memory", category="general")
        # Should not raise, should fall back to vector
        results = storage.search("fallback test", search_mode="hybrid")
        assert isinstance(results, list)
