#!/usr/bin/env python3
"""Tests for aipea_offline_knowledge.py - Offline Knowledge Base.

Tests cover:
- KnowledgeDomain and StorageTier enums
- KnowledgeNode and KnowledgeSearchResult dataclasses
- OfflineKnowledgeBase CRUD operations
- Search functionality with domain filtering
- Storage statistics and pruning
"""

from __future__ import annotations

import os
import tempfile
import time
from collections.abc import Generator
from datetime import UTC, datetime
from typing import Any

import pytest

from aipea.knowledge import (
    KnowledgeDomain,
    KnowledgeNode,
    KnowledgeSearchResult,
    OfflineKnowledgeBase,
    StorageTier,
)

# =============================================================================
# ENUM TESTS
# =============================================================================


class TestKnowledgeDomain:
    """Tests for KnowledgeDomain enum."""

    def test_all_domains_exist(self) -> None:
        """Test that all expected domains are defined."""
        assert KnowledgeDomain.MILITARY.value == "military"
        assert KnowledgeDomain.TECHNICAL.value == "technical"
        assert KnowledgeDomain.MEDICAL.value == "medical"
        assert KnowledgeDomain.INTELLIGENCE.value == "intelligence"
        assert KnowledgeDomain.LOGISTICS.value == "logistics"
        assert KnowledgeDomain.COMMUNICATIONS.value == "communications"
        assert KnowledgeDomain.CYBERSECURITY.value == "cybersecurity"
        assert KnowledgeDomain.ENGINEERING.value == "engineering"
        assert KnowledgeDomain.GENERAL.value == "general"

    def test_domain_count(self) -> None:
        """Test that the expected number of domains exists."""
        assert len(KnowledgeDomain) == 9


class TestStorageTier:
    """Tests for StorageTier enum."""

    def test_ultra_compact_tier(self) -> None:
        """Test ultra compact tier properties."""
        tier = StorageTier.ULTRA_COMPACT
        assert tier.tier_name == "ultra_compact"
        assert tier.capacity_bytes == 1_000_000_000  # 1GB

    def test_compact_tier(self) -> None:
        """Test compact tier properties."""
        tier = StorageTier.COMPACT
        assert tier.tier_name == "compact"
        assert tier.capacity_bytes == 5_000_000_000  # 5GB

    def test_standard_tier(self) -> None:
        """Test standard tier properties."""
        tier = StorageTier.STANDARD
        assert tier.tier_name == "standard"
        assert tier.capacity_bytes == 20_000_000_000  # 20GB

    def test_extended_tier(self) -> None:
        """Test extended tier properties."""
        tier = StorageTier.EXTENDED
        assert tier.tier_name == "extended"
        assert tier.capacity_bytes == 100_000_000_000  # 100GB


# =============================================================================
# DATACLASS TESTS
# =============================================================================


class TestKnowledgeNode:
    """Tests for KnowledgeNode dataclass."""

    def test_creation(self) -> None:
        """Test creating a knowledge node."""
        now = datetime.now(UTC)
        node = KnowledgeNode(
            id="abc123",
            domain=KnowledgeDomain.MILITARY,
            content="Test content",
            relevance_score=0.8,
            security_classification="SECRET",
            created_at=now,
            access_count=5,
        )
        assert node.id == "abc123"
        assert node.domain == KnowledgeDomain.MILITARY
        assert node.content == "Test content"
        assert node.relevance_score == 0.8
        assert node.security_classification == "SECRET"
        assert node.access_count == 5

    def test_default_access_count(self) -> None:
        """Test that access_count defaults to 0."""
        node = KnowledgeNode(
            id="test",
            domain=KnowledgeDomain.GENERAL,
            content="Test",
            relevance_score=0.5,
            security_classification="UNCLASSIFIED",
            created_at=datetime.now(UTC),
        )
        assert node.access_count == 0


class TestKnowledgeSearchResult:
    """Tests for KnowledgeSearchResult dataclass."""

    def test_creation_with_defaults(self) -> None:
        """Test creating a search result with default values."""
        result = KnowledgeSearchResult(
            nodes=[],
            query="test query",
        )
        assert result.nodes == []
        assert result.query == "test query"
        assert result.domain_filter is None
        assert result.total_matches == 0

    def test_creation_with_all_fields(self) -> None:
        """Test creating a search result with all fields."""
        nodes = [
            KnowledgeNode(
                id="node1",
                domain=KnowledgeDomain.MEDICAL,
                content="Medical content",
                relevance_score=0.9,
                security_classification="UNCLASSIFIED",
                created_at=datetime.now(UTC),
            )
        ]
        result = KnowledgeSearchResult(
            nodes=nodes,
            query="medical help",
            domain_filter=KnowledgeDomain.MEDICAL,
            total_matches=10,
        )
        assert len(result.nodes) == 1
        assert result.domain_filter == KnowledgeDomain.MEDICAL
        assert result.total_matches == 10


# =============================================================================
# OFFLINE KNOWLEDGE BASE TESTS
# =============================================================================


class TestOfflineKnowledgeBase:
    """Tests for OfflineKnowledgeBase class."""

    @pytest.fixture
    def temp_db(self) -> Generator[str, None, None]:
        """Create a temporary database file."""
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db_path = f.name
        yield db_path
        # Cleanup
        if os.path.exists(db_path):
            os.remove(db_path)

    @pytest.fixture
    def kb(self, temp_db: str) -> Generator[OfflineKnowledgeBase, None, None]:
        """Create a knowledge base with proper cleanup."""
        knowledge_base = OfflineKnowledgeBase(temp_db, StorageTier.COMPACT)
        yield knowledge_base
        knowledge_base.close()

    def test_initialization(self, temp_db: str) -> None:
        """Test knowledge base initialization."""
        with OfflineKnowledgeBase(temp_db, StorageTier.COMPACT) as kb:
            assert kb.db_path.name == os.path.basename(temp_db)
            assert kb.tier == StorageTier.COMPACT

    @pytest.mark.asyncio
    async def test_add_and_get_knowledge(self, kb: OfflineKnowledgeBase) -> None:
        """Test adding and retrieving knowledge."""
        node_id = await kb.add_knowledge(
            content="Test knowledge content for retrieval",
            domain=KnowledgeDomain.TECHNICAL,
            classification="UNCLASSIFIED",
            relevance_score=0.75,
        )

        assert len(node_id) == 16  # First 16 chars of SHA256

        # Retrieve by ID
        node = await kb.get_by_id(node_id)
        assert node is not None
        assert node.content == "Test knowledge content for retrieval"
        assert node.domain == KnowledgeDomain.TECHNICAL
        assert node.relevance_score == 0.75

    @pytest.mark.asyncio
    async def test_add_empty_content_raises(self, kb: OfflineKnowledgeBase) -> None:
        """Test that empty content raises ValueError."""
        with pytest.raises(ValueError, match="Content cannot be empty"):
            await kb.add_knowledge(
                content="",
                domain=KnowledgeDomain.GENERAL,
            )

    @pytest.mark.asyncio
    async def test_add_invalid_relevance_raises(self, kb: OfflineKnowledgeBase) -> None:
        """Test that invalid relevance score raises ValueError."""
        with pytest.raises(ValueError, match="relevance_score must be between"):
            await kb.add_knowledge(
                content="Test",
                domain=KnowledgeDomain.GENERAL,
                relevance_score=1.5,
            )

    @pytest.mark.asyncio
    async def test_search_without_domain(self, kb: OfflineKnowledgeBase) -> None:
        """Test searching without domain filter."""
        await kb.add_knowledge("Content A", KnowledgeDomain.MILITARY, relevance_score=0.9)
        await kb.add_knowledge("Content B", KnowledgeDomain.MEDICAL, relevance_score=0.8)
        await kb.add_knowledge("Content C", KnowledgeDomain.TECHNICAL, relevance_score=0.7)

        result = await kb.search("test query", limit=10)
        assert len(result.nodes) == 3
        # Should be sorted by relevance
        assert result.nodes[0].relevance_score >= result.nodes[1].relevance_score

    @pytest.mark.asyncio
    async def test_search_with_domain_filter(self, kb: OfflineKnowledgeBase) -> None:
        """Test searching with domain filter."""
        await kb.add_knowledge("Military info", KnowledgeDomain.MILITARY, relevance_score=0.9)
        await kb.add_knowledge("Medical info", KnowledgeDomain.MEDICAL, relevance_score=0.8)

        result = await kb.search("query", domain=KnowledgeDomain.MILITARY)
        assert len(result.nodes) == 1
        assert result.nodes[0].domain == KnowledgeDomain.MILITARY
        assert result.domain_filter == KnowledgeDomain.MILITARY

    @pytest.mark.asyncio
    async def test_get_nonexistent_node(self, kb: OfflineKnowledgeBase) -> None:
        """Test getting a node that doesn't exist."""
        node = await kb.get_by_id("nonexistent123")
        assert node is None

    @pytest.mark.asyncio
    async def test_update_relevance(self, kb: OfflineKnowledgeBase) -> None:
        """Test updating relevance score."""
        node_id = await kb.add_knowledge(
            "Test content",
            KnowledgeDomain.GENERAL,
            relevance_score=0.5,
        )

        success = await kb.update_relevance(node_id, 0.9)
        assert success is True

        node = await kb.get_by_id(node_id)
        assert node is not None
        assert node.relevance_score == 0.9

    @pytest.mark.asyncio
    async def test_update_relevance_invalid_score_raises(self, kb: OfflineKnowledgeBase) -> None:
        """Test that invalid new_score raises ValueError."""
        node_id = await kb.add_knowledge("Test", KnowledgeDomain.GENERAL)

        with pytest.raises(ValueError, match="new_score must be between"):
            await kb.update_relevance(node_id, -0.5)

    @pytest.mark.asyncio
    async def test_delete_node(self, kb: OfflineKnowledgeBase) -> None:
        """Test deleting a node."""
        node_id = await kb.add_knowledge("To delete", KnowledgeDomain.GENERAL)

        success = await kb.delete_node(node_id)
        assert success is True

        node = await kb.get_by_id(node_id)
        assert node is None

    @pytest.mark.asyncio
    async def test_delete_nonexistent_node(self, kb: OfflineKnowledgeBase) -> None:
        """Test deleting a nonexistent node returns False."""
        success = await kb.delete_node("nonexistent")
        assert success is False

    @pytest.mark.asyncio
    async def test_get_node_count(self, kb: OfflineKnowledgeBase) -> None:
        """Test getting node count."""
        assert await kb.get_node_count() == 0

        await kb.add_knowledge("Content 1", KnowledgeDomain.GENERAL)
        await kb.add_knowledge("Content 2", KnowledgeDomain.MILITARY)

        assert await kb.get_node_count() == 2

    @pytest.mark.asyncio
    async def test_get_storage_stats(self, kb: OfflineKnowledgeBase) -> None:
        """Test getting storage statistics."""
        await kb.add_knowledge("Test content", KnowledgeDomain.GENERAL)

        stats = await kb.get_storage_stats()
        assert stats["node_count"] == 1
        assert stats["db_size_bytes"] > 0
        assert stats["capacity_bytes"] == StorageTier.COMPACT.capacity_bytes
        assert 0 <= stats["utilization_percent"] <= 100

    @pytest.mark.asyncio
    async def test_get_domains_summary(self, kb: OfflineKnowledgeBase) -> None:
        """Test getting domains summary."""
        await kb.add_knowledge("Military 1", KnowledgeDomain.MILITARY)
        await kb.add_knowledge("Military 2", KnowledgeDomain.MILITARY)
        await kb.add_knowledge("Medical 1", KnowledgeDomain.MEDICAL)

        summary = await kb.get_domains_summary()
        assert summary.get("military") == 2
        assert summary.get("medical") == 1

    @pytest.mark.asyncio
    async def test_prune_low_relevance(self, kb: OfflineKnowledgeBase) -> None:
        """Test pruning low-relevance nodes."""
        await kb.add_knowledge("High relevance", KnowledgeDomain.GENERAL, relevance_score=0.9)
        await kb.add_knowledge("Low relevance", KnowledgeDomain.GENERAL, relevance_score=0.05)

        assert await kb.get_node_count() == 2

        deleted = await kb.prune_low_relevance(threshold=0.1)
        assert deleted == 1
        assert await kb.get_node_count() == 1

    @pytest.mark.asyncio
    async def test_prune_invalid_threshold_raises(self, kb: OfflineKnowledgeBase) -> None:
        """Test that invalid threshold raises ValueError."""
        with pytest.raises(ValueError, match="threshold must be between"):
            await kb.prune_low_relevance(threshold=1.5)

    @pytest.mark.asyncio
    async def test_prune_non_positive_max_delete_raises(self, kb: OfflineKnowledgeBase) -> None:
        """Test that non-positive max_delete raises ValueError and preserves data."""
        await kb.add_knowledge("Low relevance A", KnowledgeDomain.GENERAL, relevance_score=0.05)
        await kb.add_knowledge("Low relevance B", KnowledgeDomain.GENERAL, relevance_score=0.04)

        before_count = await kb.get_node_count()
        with pytest.raises(ValueError, match="max_delete must be greater than 0"):
            await kb.prune_low_relevance(threshold=0.1, max_delete=-1)
        assert await kb.get_node_count() == before_count

    @pytest.mark.asyncio
    async def test_access_count_increments_on_search(self, kb: OfflineKnowledgeBase) -> None:
        """Test that access count is incremented on search."""
        node_id = await kb.add_knowledge("Searchable", KnowledgeDomain.GENERAL, relevance_score=0.9)

        # Initial access count
        node = await kb.get_by_id(node_id)
        assert node is not None
        initial_count = node.access_count

        # Search should increment access count
        await kb.search("query")  # returns KnowledgeSearchResult

        node = await kb.get_by_id(node_id)
        assert node is not None
        assert node.access_count > initial_count

    @pytest.mark.asyncio
    async def test_concurrent_add_and_search(self, kb: OfflineKnowledgeBase) -> None:
        """Test that concurrent operations are thread-safe.

        Bug fix: SQLite with check_same_thread=False needs locking.
        """
        import asyncio

        async def add_knowledge(index: int) -> str:
            """Add knowledge node."""
            return await kb.add_knowledge(
                f"Concurrent content {index}",
                KnowledgeDomain.TECHNICAL,
                relevance_score=0.5 + index * 0.01,
            )

        async def search_knowledge() -> KnowledgeSearchResult:
            """Search knowledge base."""
            return await kb.search("concurrent")

        # Run concurrent operations
        add_tasks = [add_knowledge(i) for i in range(10)]
        search_tasks = [search_knowledge() for _ in range(5)]

        # Execute all tasks concurrently - should not raise errors
        all_tasks = add_tasks + search_tasks
        results = await asyncio.gather(*all_tasks, return_exceptions=True)

        # Verify no exceptions occurred
        for result in results:
            assert not isinstance(result, Exception), f"Concurrent operation failed: {result}"

        # Verify all nodes were added
        count = await kb.get_node_count()
        assert count >= 10

    @pytest.mark.asyncio
    async def test_failed_decompression_skips_access_update(self, temp_db: str) -> None:
        """Test that failed decompressions don't increment access count.

        Bug fix: access_count should only update for successfully retrieved nodes.
        """
        import sqlite3

        # Create knowledge base and add a valid node
        kb = OfflineKnowledgeBase(temp_db, StorageTier.COMPACT)
        try:
            valid_id = await kb.add_knowledge(
                "Valid content", KnowledgeDomain.GENERAL, relevance_score=0.9
            )

            # Manually corrupt a node's compressed content
            corrupt_id = "corrupt_node123"
            conn = sqlite3.connect(temp_db)
            conn.execute(
                """
                INSERT INTO knowledge_nodes
                (id, domain, content_hash, compressed_content, relevance_score,
                 security_classification, access_count)
                VALUES (?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    corrupt_id,
                    "general",
                    "fake_hash",
                    b"not_valid_zlib_data",  # Invalid compressed data
                    0.95,  # High relevance to ensure it's included in search
                    "UNCLASSIFIED",
                    0,
                ),
            )
            conn.commit()
            conn.close()

            # Search should return the valid node but skip the corrupt one
            search_result = await kb.search("query", limit=10)

            # Valid node should be in results
            assert any(n.id == valid_id for n in search_result.nodes)
            # Corrupt node should NOT be in results
            assert not any(n.id == corrupt_id for n in search_result.nodes)

            # Check that corrupt node's access_count was NOT incremented
            conn = sqlite3.connect(temp_db)
            cursor = conn.execute(
                "SELECT access_count FROM knowledge_nodes WHERE id = ?",
                (corrupt_id,),
            )
            row = cursor.fetchone()
            conn.close()
            assert row is not None
            assert row[0] == 0, "Corrupt node's access_count should not be incremented"

        finally:
            kb.close()


# =============================================================================
# BUG-HUNT REGRESSION TESTS
# =============================================================================


class TestAddKnowledgePreservesAccessCount:
    """Regression: re-adding same content must not reset access_count to 0."""

    @pytest.mark.asyncio
    async def test_upsert_preserves_access_count(self) -> None:
        """Adding the same content twice should preserve access_count from first insert."""
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db_path = f.name
        try:
            kb = OfflineKnowledgeBase(db_path, StorageTier.COMPACT)

            # Add initial content
            node_id = await kb.add_knowledge(
                content="Test content for upsert",
                domain=KnowledgeDomain.GENERAL,
            )

            # Simulate access_count being incremented (manually set to 5)
            with kb._with_db_lock() as conn:
                conn.execute(
                    "UPDATE knowledge_nodes SET access_count = 5 WHERE id = ?",
                    (node_id,),
                )
                conn.commit()

            # Re-add the same content (same hash -> same id)
            node_id_2 = await kb.add_knowledge(
                content="Test content for upsert",
                domain=KnowledgeDomain.GENERAL,
            )

            assert node_id == node_id_2, "Same content should produce same node ID"

            # Verify access_count is preserved (not reset to 0)
            with kb._with_db_lock() as conn:
                cursor = conn.execute(
                    "SELECT access_count FROM knowledge_nodes WHERE id = ?",
                    (node_id,),
                )
                row = cursor.fetchone()

            assert row is not None
            assert row[0] == 5, f"access_count should be preserved (5), got {row[0]}"

            kb.close()
        finally:
            if os.path.exists(db_path):
                os.remove(db_path)


class TestAddKnowledgeLastAccessedPreservation:
    """Regression: re-inserting same content must not bump last_accessed."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_readd_does_not_update_last_accessed(self) -> None:
        """ON CONFLICT should preserve the original last_accessed timestamp."""
        db_path = os.path.join(tempfile.mkdtemp(), "test_last_accessed.db")
        try:
            kb = OfflineKnowledgeBase(db_path=db_path, tier=StorageTier.STANDARD)

            # Insert content
            node_id = await kb.add_knowledge("reusable fact", KnowledgeDomain.GENERAL)

            # Record last_accessed
            with kb._with_db_lock() as conn:
                row = conn.execute(
                    "SELECT last_accessed FROM knowledge_nodes WHERE id = ?",
                    (node_id,),
                ).fetchone()
            original_last_accessed = row[0]

            # Re-insert same content (triggers ON CONFLICT)
            time.sleep(0.05)  # ensure clock moves forward
            node_id2 = await kb.add_knowledge("reusable fact", KnowledgeDomain.GENERAL)
            assert node_id2 == node_id  # same hash -> same ID

            # Verify last_accessed was NOT bumped
            with kb._with_db_lock() as conn:
                row = conn.execute(
                    "SELECT last_accessed FROM knowledge_nodes WHERE id = ?",
                    (node_id,),
                ).fetchone()

            assert row[0] == original_last_accessed, (
                f"last_accessed should not change on re-insert: "
                f"was {original_last_accessed}, now {row[0]}"
            )

            kb.close()
        finally:
            if os.path.exists(db_path):
                os.remove(db_path)


# =============================================================================
# WAVE 4 REGRESSION TESTS
# =============================================================================


class TestWave4InvalidDomainRecovery:
    """Regression tests for ValueError recovery on invalid domain strings."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_by_id_handles_invalid_domain(self, tmp_path: Any) -> None:
        """get_by_id should return None for rows with invalid domain values."""
        import sqlite3
        import zlib

        db_path = tmp_path / "test_invalid_domain.db"
        kb = OfflineKnowledgeBase(db_path=db_path)

        # Insert a row with an invalid domain directly into SQLite
        conn = sqlite3.connect(str(db_path))
        conn.row_factory = sqlite3.Row
        compressed = zlib.compress(b"test content")
        now = datetime.now(UTC).isoformat()
        conn.execute(
            """INSERT INTO knowledge_nodes
               (id, domain, content_hash, compressed_content, relevance_score,
                security_classification, created_at, last_accessed, access_count)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            (
                "invalid-1",
                "nonexistent_domain",
                "abc123",
                compressed,
                0.5,
                "unclassified",
                now,
                now,
                0,
            ),
        )
        conn.commit()
        conn.close()

        # get_by_id should handle ValueError gracefully, not crash
        result = await kb.get_by_id("invalid-1")
        assert result is None

        kb.close()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_handles_invalid_domain_in_results(self, tmp_path: Any) -> None:
        """search should skip rows with invalid domain values."""
        import sqlite3
        import zlib

        db_path = tmp_path / "test_invalid_domain_search.db"
        kb = OfflineKnowledgeBase(db_path=db_path)

        # Insert a valid row and an invalid-domain row
        conn = sqlite3.connect(str(db_path))
        conn.row_factory = sqlite3.Row
        now = datetime.now(UTC).isoformat()
        for node_id, domain, content in [
            ("valid-1", "general", "valid content about AI"),
            ("invalid-1", "bad_domain", "some content about AI"),
        ]:
            compressed = zlib.compress(content.encode())
            content_hash = node_id
            conn.execute(
                """INSERT INTO knowledge_nodes
                   (id, domain, content_hash, compressed_content, relevance_score,
                    security_classification, created_at, last_accessed, access_count)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (node_id, domain, content_hash, compressed, 0.5, "unclassified", now, now, 0),
            )
        conn.commit()
        conn.close()

        # search should not crash; it may return the valid row
        search_result = await kb.search("AI")
        # Should not raise ValueError
        assert isinstance(search_result, KnowledgeSearchResult)

        kb.close()


class TestSearchLimitValidation:
    """Regression tests for search() limit parameter validation."""

    @pytest.fixture
    def temp_db(self) -> Generator[str, None, None]:
        """Create a temporary database file."""
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db_path = f.name
        yield db_path
        if os.path.exists(db_path):
            os.remove(db_path)

    @pytest.mark.asyncio
    @pytest.mark.unit
    async def test_negative_limit_clamped_to_one(self, temp_db: str) -> None:
        """Negative limit must not return all rows via SQLite LIMIT -1."""
        with OfflineKnowledgeBase(temp_db, StorageTier.COMPACT) as kb:
            await kb.add_knowledge("node one", KnowledgeDomain.GENERAL, relevance_score=0.8)
            await kb.add_knowledge("node two", KnowledgeDomain.GENERAL, relevance_score=0.5)
            result = await kb.search("test", limit=-1)
            assert len(result.nodes) == 1, "limit=-1 should be clamped to 1, not return all rows"

    @pytest.mark.asyncio
    @pytest.mark.unit
    async def test_zero_limit_clamped_to_one(self, temp_db: str) -> None:
        """Zero limit must return at least 1 result."""
        with OfflineKnowledgeBase(temp_db, StorageTier.COMPACT) as kb:
            await kb.add_knowledge("node one", KnowledgeDomain.GENERAL, relevance_score=0.8)
            result = await kb.search("test", limit=0)
            assert len(result.nodes) == 1, "limit=0 should be clamped to 1"


# =============================================================================
# SEMANTIC SEARCH (BM25) TESTS
# =============================================================================


class TestSearchSemantic:
    """Tests for OfflineKnowledgeBase.search_semantic() — BM25-ranked FTS5 search."""

    @pytest.fixture
    def temp_db(self) -> Generator[str, None, None]:
        import contextlib

        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db_path = f.name
        yield db_path
        with contextlib.suppress(OSError):
            os.unlink(db_path)

    @pytest.mark.asyncio
    @pytest.mark.unit
    async def test_semantic_returns_ranked_results(self, temp_db: str) -> None:
        """search_semantic returns results ranked by BM25."""
        with OfflineKnowledgeBase(temp_db, StorageTier.STANDARD) as kb:
            await kb.add_knowledge(
                "The attention mechanism in transformers",
                KnowledgeDomain.TECHNICAL,
            )
            await kb.add_knowledge(
                "Neural network architectures overview",
                KnowledgeDomain.TECHNICAL,
            )
            result = await kb.search_semantic("attention mechanism")
            assert result.total_matches >= 1
            assert any("attention" in n.content.lower() for n in result.nodes)

    @pytest.mark.asyncio
    @pytest.mark.unit
    async def test_semantic_empty_query_returns_empty(self, temp_db: str) -> None:
        """search_semantic with empty query returns empty results."""
        with OfflineKnowledgeBase(temp_db, StorageTier.STANDARD) as kb:
            await kb.add_knowledge("Some content", KnowledgeDomain.GENERAL)
            result = await kb.search_semantic("")
            assert result.nodes == []
            assert result.total_matches == 0

    @pytest.mark.asyncio
    @pytest.mark.unit
    async def test_semantic_no_matches_returns_empty(self, temp_db: str) -> None:
        """search_semantic with unmatched query returns empty results."""
        with OfflineKnowledgeBase(temp_db, StorageTier.STANDARD) as kb:
            await kb.add_knowledge("Python web frameworks", KnowledgeDomain.TECHNICAL)
            result = await kb.search_semantic("quantum entanglement physics")
            assert result.nodes == []

    @pytest.mark.asyncio
    @pytest.mark.unit
    async def test_semantic_respects_top_k(self, temp_db: str) -> None:
        """search_semantic respects the top_k limit."""
        with OfflineKnowledgeBase(temp_db, StorageTier.STANDARD) as kb:
            for i in range(5):
                await kb.add_knowledge(
                    f"Machine learning technique number {i}",
                    KnowledgeDomain.TECHNICAL,
                )
            result = await kb.search_semantic("machine learning", top_k=2)
            assert len(result.nodes) <= 2

    @pytest.mark.asyncio
    @pytest.mark.unit
    async def test_semantic_does_not_fallback(self, temp_db: str) -> None:
        """search_semantic does NOT fall back to relevance_score ordering."""
        with OfflineKnowledgeBase(temp_db, StorageTier.STANDARD) as kb:
            # Add content that won't match FTS
            await kb.add_knowledge(
                "Completely unrelated content about gardening",
                KnowledgeDomain.GENERAL,
                relevance_score=1.0,
            )
            # Search for something that won't match FTS
            result = await kb.search_semantic("xyzzy nonexistent")
            # Even though there's high-relevance content, semantic search returns empty
            assert result.nodes == []

    @pytest.mark.asyncio
    @pytest.mark.unit
    async def test_semantic_result_type(self, temp_db: str) -> None:
        """search_semantic returns KnowledgeSearchResult."""
        with OfflineKnowledgeBase(temp_db, StorageTier.STANDARD) as kb:
            await kb.add_knowledge("Test content", KnowledgeDomain.GENERAL)
            result = await kb.search_semantic("test")
            assert isinstance(result, KnowledgeSearchResult)


class TestFTSCleanupOnDelete:
    """Regression tests: FTS index must be cleaned up when nodes are deleted."""

    @pytest.fixture
    def temp_db(self) -> Generator[str, None, None]:
        import contextlib

        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db_path = f.name
        yield db_path
        with contextlib.suppress(OSError):
            os.unlink(db_path)

    @pytest.mark.asyncio
    @pytest.mark.unit
    async def test_delete_node_cleans_fts(self, temp_db: str) -> None:
        """Deleting a node must also remove its FTS entry."""
        with OfflineKnowledgeBase(temp_db, StorageTier.STANDARD) as kb:
            await kb.add_knowledge("Unique quantum entanglement content", KnowledgeDomain.TECHNICAL)
            # Verify it's searchable
            result = await kb.search_semantic("quantum entanglement")
            assert result.total_matches >= 1
            node_id = result.nodes[0].id

            # Delete the node
            deleted = await kb.delete_node(node_id)
            assert deleted is True

            # FTS should no longer find the deleted content
            result_after = await kb.search_semantic("quantum entanglement")
            assert result_after.total_matches == 0

            # Verify FTS row count matches node count
            with kb._with_db_lock() as conn:
                node_count = conn.execute("SELECT COUNT(*) FROM knowledge_nodes").fetchone()[0]
                fts_count = conn.execute("SELECT COUNT(*) FROM knowledge_fts").fetchone()[0]
                assert fts_count == node_count

    @pytest.mark.asyncio
    @pytest.mark.unit
    async def test_prune_cleans_fts(self, temp_db: str) -> None:
        """Pruning low-relevance nodes must also remove their FTS entries."""
        with OfflineKnowledgeBase(temp_db, StorageTier.STANDARD) as kb:
            # Add a low-relevance node
            await kb.add_knowledge(
                "Low relevance pruning test content",
                KnowledgeDomain.GENERAL,
                relevance_score=0.1,
            )
            # Add a high-relevance node
            await kb.add_knowledge(
                "High relevance keeper content",
                KnowledgeDomain.GENERAL,
                relevance_score=0.9,
            )

            # Prune nodes with relevance < 0.5
            pruned = await kb.prune_low_relevance(threshold=0.5, max_delete=10)
            assert pruned == 1

            # FTS should no longer find the pruned content
            result = await kb.search_semantic("pruning test")
            assert result.total_matches == 0

            # High-relevance content should still be searchable
            result2 = await kb.search_semantic("keeper content")
            assert result2.total_matches >= 1

            # Verify FTS row count matches node count
            with kb._with_db_lock() as conn:
                node_count = conn.execute("SELECT COUNT(*) FROM knowledge_nodes").fetchone()[0]
                fts_count = conn.execute("SELECT COUNT(*) FROM knowledge_fts").fetchone()[0]
                assert fts_count == node_count


class TestSemanticSearchAccessTracking:
    """Regression test: semantic search must update access counts like regular search."""

    @pytest.fixture
    def temp_db(self) -> Generator[str, None, None]:
        import contextlib

        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db_path = f.name
        yield db_path
        with contextlib.suppress(OSError):
            os.unlink(db_path)

    @pytest.mark.asyncio
    @pytest.mark.unit
    async def test_semantic_search_updates_access_count(self, temp_db: str) -> None:
        """search_semantic must increment access_count for retrieved nodes."""
        with OfflineKnowledgeBase(temp_db, StorageTier.STANDARD) as kb:
            await kb.add_knowledge("Unique neural network content", KnowledgeDomain.TECHNICAL)

            # Search twice via semantic search
            result1 = await kb.search_semantic("neural network")
            assert result1.total_matches >= 1
            node_id = result1.nodes[0].id

            result2 = await kb.search_semantic("neural network")
            assert result2.total_matches >= 1

            # Verify access count was incremented
            with kb._with_db_lock() as conn:
                row = conn.execute(
                    "SELECT access_count FROM knowledge_nodes WHERE id = ?", (node_id,)
                ).fetchone()
                assert row is not None
                assert row[0] >= 2, f"Expected access_count >= 2, got {row[0]}"


# =============================================================================
# REGRESSION TESTS (bug-hunt wave 14)
# =============================================================================


class TestFtsSyncBothDirections:
    """Regression: _sync_fts_index only rebuilt when fts_count < node_count."""

    @pytest.mark.unit
    async def test_sync_triggers_on_orphan_fts_entries(self, tmp_path: Any) -> None:
        """FTS rebuild should trigger when fts_count > node_count."""
        db_path = str(tmp_path / "fts_sync_test.db")
        kb = OfflineKnowledgeBase(db_path, StorageTier.COMPACT)
        try:
            await kb.add_knowledge("Test content for FTS sync", KnowledgeDomain.TECHNICAL)

            # Manually insert an orphan FTS row
            with kb._with_db_lock() as conn:
                conn.execute(
                    "INSERT INTO knowledge_fts(rowid, content, domain) "
                    "VALUES (99999, 'orphan', 'test')"
                )
                conn.commit()
                fts_before = conn.execute("SELECT COUNT(*) FROM knowledge_fts").fetchone()[0]
                node_count = conn.execute("SELECT COUNT(*) FROM knowledge_nodes").fetchone()[0]
                assert fts_before > node_count, "Precondition: orphan FTS entry exists"

            # Trigger sync — should detect mismatch and rebuild
            with kb._with_db_lock() as conn:
                kb._sync_fts_index(conn)
                fts_after = conn.execute("SELECT COUNT(*) FROM knowledge_fts").fetchone()[0]
                node_count = conn.execute("SELECT COUNT(*) FROM knowledge_nodes").fetchone()[0]
                assert fts_after == node_count, "FTS should be in sync after rebuild"
        finally:
            kb.close()


class TestUpsertPreservesRelevanceScore:
    """Regression: add_knowledge upsert overwrote user-tuned relevance_score."""

    @pytest.mark.unit
    async def test_reseed_preserves_tuned_score(self, tmp_path: Any) -> None:
        """Re-adding same content should NOT overwrite manually tuned relevance."""
        import hashlib

        db_path = str(tmp_path / "upsert_score_test.db")
        kb = OfflineKnowledgeBase(db_path, StorageTier.COMPACT)
        try:
            content = "Important knowledge about quantum computing"
            node_id = hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
            await kb.add_knowledge(content, KnowledgeDomain.TECHNICAL, relevance_score=0.5)

            # Manually tune the relevance score higher using the hash-based node_id
            updated = await kb.update_relevance(node_id, 0.95)
            assert updated, "update_relevance should succeed"

            # Verify the score was updated
            with kb._with_db_lock() as conn:
                row = conn.execute(
                    "SELECT relevance_score FROM knowledge_nodes WHERE id = ?", (node_id,)
                ).fetchone()
                assert row is not None
                assert row[0] == pytest.approx(0.95, abs=0.01)

            # Re-add the same content (simulating seed-kb re-run)
            await kb.add_knowledge(content, KnowledgeDomain.TECHNICAL, relevance_score=0.5)

            # Score should be preserved at 0.95, not reset to 0.5
            with kb._with_db_lock() as conn:
                row = conn.execute(
                    "SELECT relevance_score FROM knowledge_nodes WHERE id = ?", (node_id,)
                ).fetchone()
                assert row is not None
                assert row[0] == pytest.approx(0.95, abs=0.01)
        finally:
            kb.close()


# ============================================================================
# Regression: prune_low_relevance uses exact IDs (no TOCTOU)
# ============================================================================


class TestPruneLowRelevanceRegression:
    """Regression: prune should delete by exact IDs, not re-evaluated criteria."""

    @pytest.mark.asyncio
    async def test_prune_deletes_correct_nodes(self) -> None:
        """Prune should remove exactly the low-relevance nodes and their FTS entries."""
        with tempfile.TemporaryDirectory() as td:
            db_path = os.path.join(td, "prune_test.db")
            kb = OfflineKnowledgeBase(db_path=db_path, tier=StorageTier.STANDARD)
            try:
                # Add nodes with varying relevance
                await kb.add_knowledge("keep this", KnowledgeDomain.TECHNICAL, relevance_score=0.9)
                await kb.add_knowledge(
                    "also keep", KnowledgeDomain.ENGINEERING, relevance_score=0.8
                )
                await kb.add_knowledge("prune me", KnowledgeDomain.GENERAL, relevance_score=0.1)
                await kb.add_knowledge("prune me too", KnowledgeDomain.GENERAL, relevance_score=0.2)

                stats_before = await kb.get_storage_stats()
                assert stats_before["node_count"] == 4

                deleted = await kb.prune_low_relevance(threshold=0.5, max_delete=10)
                assert deleted == 2

                stats_after = await kb.get_storage_stats()
                assert stats_after["node_count"] == 2
            finally:
                kb.close()


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
