"""
Tests for issue #1395 — Ghost entries in retrieval.

Validates:
1. Retrieval filters out items with empty/null content (ghost entries).
2. Deleting a claim cascades to embedding_index and vec_index.
"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from cortex.core.retrieval import (
    RetrievalConstraints,
    RetrievalPipeline,
    RetrievedItem,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

@dataclass
class MockConfig:
    retrieval_token_budget: int = 500
    total_memory_token_budget: int = 4000
    cornerstone_token_budget: int = 800
    embedding_dim: int = 4
    embedding_model: str = "mock"


@dataclass
class MockFTSResult:
    item_id: str
    content: str
    item_type: str = "claim"
    rank: float = -1.0


@dataclass
class MockVectorResult:
    item_id: str
    content: str
    score: float
    item_type: str = "claim"


def _mock_claim(claim_id: str):
    obj = MagicMock()
    obj.subject = "Andrew"
    obj.predicate = "likes"
    obj.object_value = "coffee"
    obj.event_date = None
    obj.cause = None
    obj.effect = None
    obj.state_before = None
    obj.state_after = None
    obj.memory_class = "semantic"
    obj.salience = "high"
    obj.status = "active"
    obj.category = "preferences"
    obj.explicitness = "explicit"
    obj.stability = "stable"
    obj.created_at = None
    obj.metadata_json = None
    obj.source_session = None
    obj.valid_to = None
    return obj


def make_storage(fts_items, vector_items, ghost_ids=None):
    """Build a mock storage. ghost_ids = set of item_ids that return None from get_claim."""
    ghost_ids = ghost_ids or set()
    storage = MagicMock()
    storage.embed = AsyncMock(return_value=[0.1, 0.2, 0.3, 0.4])

    _claims = [i for i in fts_items if i.item_type == "claim"]

    async def _fts(query, table="claims", top_k=50, owner_id="default", **kwargs):
        items = _claims if table == "claims" else []
        return items[:top_k]

    storage.fts_search = AsyncMock(side_effect=_fts)

    async def _vec(embedding, item_type=None, top_k=50, owner_id="default"):
        return vector_items[:top_k]

    storage.vector_search = AsyncMock(side_effect=_vec)

    async def _get_claim(claim_id, owner_id=None, **kwargs):
        if claim_id in ghost_ids:
            return None
        return _mock_claim(claim_id)

    storage.get_claim = AsyncMock(side_effect=_get_claim)

    async def _sens_batch(claim_ids):
        return {cid: "normal" for cid in claim_ids}

    storage.get_claims_sensitivity_batch = AsyncMock(side_effect=_sens_batch)
    storage.log_retrieval = AsyncMock()

    return storage


def make_pipeline(storage):
    return RetrievalPipeline(
        storage=storage,
        config=MockConfig(),
        token_budget=500,
    )


# ---------------------------------------------------------------------------
# Test 1: Ghost entries (empty content) filtered from retrieval results
# ---------------------------------------------------------------------------

class TestGhostEntryFiltering:
    @pytest.mark.asyncio
    async def test_ghost_entries_filtered_out(self):
        """Items whose DB row is gone should not appear in results (#1395).

        When vec_index returns an item_id but get_claim returns None,
        the content stays empty. The pipeline must filter these out.
        """
        # "c_ghost" is in vec_index but get_claim returns None for it
        fts = [MockFTSResult("c_real", "Andrew likes coffee")]
        vec = [
            MockVectorResult("c_real", "Andrew likes coffee", 0.9),
            MockVectorResult("c_ghost", "", 0.85),
        ]
        storage = make_storage(fts, vec, ghost_ids={"c_ghost"})
        pipeline = make_pipeline(storage)

        result = await pipeline.retrieve(
            "What does Andrew like?",
            constraints=RetrievalConstraints(top_k=10),
        )

        item_ids = [it.item_id for it in result.items]
        assert "c_real" in item_ids, "Real item should be in results"
        assert "c_ghost" not in item_ids, (
            "Ghost entry with empty content should be filtered out (#1395)"
        )

    @pytest.mark.asyncio
    async def test_all_items_have_content(self):
        """Every returned item must have non-empty content."""
        fts = [
            MockFTSResult("c1", "fact one"),
            MockFTSResult("c2", "fact two"),
        ]
        vec = [
            MockVectorResult("c1", "fact one", 0.9),
            MockVectorResult("c2", "fact two", 0.8),
            MockVectorResult("c_gone", "", 0.7),
        ]
        storage = make_storage(fts, vec, ghost_ids={"c_gone"})
        pipeline = make_pipeline(storage)

        result = await pipeline.retrieve(
            "what are the facts?",
            constraints=RetrievalConstraints(top_k=10),
        )

        for item in result.items:
            assert item.content, (
                f"Item {item.item_id} has empty content — ghost entry leaked through"
            )


# ---------------------------------------------------------------------------
# Test 2: delete_claim cascades to embedding_index and vec_index
# ---------------------------------------------------------------------------

class TestDeleteCascade:
    @pytest.mark.asyncio
    async def test_soft_delete_removes_embedding_index(self):
        """Soft-deleting a claim should DELETE from embedding_index (#1395)."""
        import aiosqlite
        import tempfile, os

        db_path = os.path.join(tempfile.mkdtemp(), "test.db")
        async with aiosqlite.connect(db_path) as conn:
            conn.row_factory = aiosqlite.Row
            # Minimal schema
            await conn.execute("""
                CREATE TABLE semantic_claim (
                    id TEXT PRIMARY KEY,
                    owner_id TEXT,
                    is_deleted INTEGER DEFAULT 0,
                    deleted_at TEXT,
                    status TEXT DEFAULT 'active',
                    updated_at TEXT,
                    subject TEXT, predicate TEXT, object_value TEXT
                )
            """)
            await conn.execute("""
                CREATE TABLE fts_claims (
                    claim_id TEXT PRIMARY KEY,
                    content TEXT
                )
            """)
            await conn.execute("""
                CREATE TABLE embedding_index (
                    item_type TEXT,
                    item_id TEXT,
                    embedding BLOB,
                    model_id TEXT
                )
            """)
            await conn.execute(
                "INSERT INTO semantic_claim(id, owner_id, status, subject, predicate, object_value) "
                "VALUES (?, ?, 'active', 'A', 'likes', 'B')",
                ("c1", "owner1"),
            )
            await conn.execute(
                "INSERT INTO fts_claims(claim_id, content) VALUES (?, ?)",
                ("c1", "A likes B"),
            )
            await conn.execute(
                "INSERT INTO embedding_index(item_type, item_id, embedding, model_id) "
                "VALUES (?, ?, ?, ?)",
                ("claim", "c1", b"\x00" * 16, "test-model"),
            )
            await conn.commit()

            # Build a minimal mixin instance
            from cortex.storage.queries.claims.lifecycle import ClaimLifecycleMixin
            from cortex.storage.queries.claims.crud import ClaimCRUDMixin
            import asyncio

            class FakeAdapter(ClaimLifecycleMixin, ClaimCRUDMixin):
                def __init__(self, conn):
                    self._conn = conn
                    self._write_lock = asyncio.Lock()
                    self._vec_loaded = False

                async def _get_conn(self):
                    return self._conn

            adapter = FakeAdapter(conn)
            await adapter.delete_claim("c1", "owner1", hard=False)

            # Verify embedding_index entry is gone
            async with conn.execute(
                "SELECT COUNT(*) FROM embedding_index WHERE item_id = ?", ("c1",)
            ) as cur:
                row = await cur.fetchone()
            assert row[0] == 0, "embedding_index entry should be deleted on soft-delete"

            # Verify fts entry is gone
            async with conn.execute(
                "SELECT COUNT(*) FROM fts_claims WHERE claim_id = ?", ("c1",)
            ) as cur:
                row = await cur.fetchone()
            assert row[0] == 0, "fts_claims entry should be deleted on soft-delete"

    @pytest.mark.asyncio
    async def test_hard_delete_removes_embedding_index(self):
        """Hard-deleting a claim should DELETE from embedding_index (#1395)."""
        import aiosqlite
        import tempfile, os

        db_path = os.path.join(tempfile.mkdtemp(), "test.db")
        async with aiosqlite.connect(db_path) as conn:
            conn.row_factory = aiosqlite.Row
            await conn.execute("""
                CREATE TABLE semantic_claim (
                    id TEXT PRIMARY KEY,
                    owner_id TEXT,
                    is_deleted INTEGER DEFAULT 0,
                    deleted_at TEXT,
                    status TEXT DEFAULT 'active',
                    updated_at TEXT,
                    subject TEXT, predicate TEXT, object_value TEXT
                )
            """)
            await conn.execute("""
                CREATE TABLE fts_claims (
                    claim_id TEXT PRIMARY KEY,
                    content TEXT
                )
            """)
            await conn.execute("""
                CREATE TABLE embedding_index (
                    item_type TEXT,
                    item_id TEXT,
                    embedding BLOB,
                    model_id TEXT
                )
            """)
            await conn.execute(
                "INSERT INTO semantic_claim(id, owner_id, status, subject, predicate, object_value) "
                "VALUES (?, ?, 'active', 'X', 'is', 'Y')",
                ("c2", "owner1"),
            )
            await conn.execute(
                "INSERT INTO fts_claims(claim_id, content) VALUES (?, ?)",
                ("c2", "X is Y"),
            )
            await conn.execute(
                "INSERT INTO embedding_index(item_type, item_id, embedding, model_id) "
                "VALUES (?, ?, ?, ?)",
                ("claim", "c2", b"\x00" * 16, "test-model"),
            )
            await conn.commit()

            from cortex.storage.queries.claims.lifecycle import ClaimLifecycleMixin
            from cortex.storage.queries.claims.crud import ClaimCRUDMixin
            import asyncio

            class FakeAdapter(ClaimLifecycleMixin, ClaimCRUDMixin):
                def __init__(self, conn):
                    self._conn = conn
                    self._write_lock = asyncio.Lock()
                    self._vec_loaded = False

                async def _get_conn(self):
                    return self._conn

            adapter = FakeAdapter(conn)
            await adapter.delete_claim("c2", "owner1", hard=True)

            # Verify claim row is gone
            async with conn.execute(
                "SELECT COUNT(*) FROM semantic_claim WHERE id = ?", ("c2",)
            ) as cur:
                row = await cur.fetchone()
            assert row[0] == 0, "semantic_claim row should be hard-deleted"

            # Verify embedding_index entry is gone
            async with conn.execute(
                "SELECT COUNT(*) FROM embedding_index WHERE item_id = ?", ("c2",)
            ) as cur:
                row = await cur.fetchone()
            assert row[0] == 0, "embedding_index entry should be deleted on hard-delete"
