"""Tests for CortexService — real ChromaDB + mocked HTTP externals."""

import json
import sqlite3
from unittest.mock import AsyncMock

import pytest
from pytest_httpx import HTTPXMock

from neo_cortex.classifier import Classifier
from neo_cortex.cortex import CortexService, NOISE_PATTERNS
from neo_cortex.dedup import DedupStore
from neo_cortex.embedder import JinaEmbedder
from neo_cortex.graph import ConceptGraph
from neo_cortex.memory_index import MemoryIndex
from neo_cortex.models import (
    Activity,
    ClassificationResult,
    IngestRequest,
    QueryAnalysis,
)
from neo_cortex.store import MemoryStore


FAKE_EMBEDDING = [0.1] * 1024
FAKE_EMBEDDING_2 = [0.2] * 1024


def _make_classifier_mock():
    classifier = AsyncMock(spec=Classifier)
    classifier.classify = AsyncMock(return_value=ClassificationResult(
        project="neo-cortex", topic="test", activity=Activity.FEATURE,
        title="Test title", summary="Test summary",
        facts=["fact1"], concepts=["concept1"], files_touched=["test.py"],
    ))
    classifier.is_noise = AsyncMock(return_value=False)
    classifier.analyze_query = AsyncMock(return_value=QueryAnalysis(
        project="neo-cortex", refined_query="refined test query",
    ))
    classifier.aggregate_to_mbel = AsyncMock(return_value="@test::mbel")
    classifier.resolve_conflict = AsyncMock(return_value={"action": "add", "target_id": None, "reason": "no conflict"})
    return classifier


@pytest.fixture
def cortex(tmp_path) -> CortexService:
    """CortexService with real store + mocked embedder/classifier."""
    store = MemoryStore(str(tmp_path), collection_name="test_cortex")
    embedder = AsyncMock(spec=JinaEmbedder)
    embedder.embed_passage = AsyncMock(return_value=FAKE_EMBEDDING)
    embedder.embed_query = AsyncMock(return_value=FAKE_EMBEDDING)
    classifier = _make_classifier_mock()
    return CortexService(store, embedder, classifier)


@pytest.fixture
def cortex_with_index(tmp_path) -> CortexService:
    """CortexService with real store + index + mocked embedder/classifier."""
    store = MemoryStore(str(tmp_path / "chroma"), collection_name="test_cortex_idx")
    index = MemoryIndex(str(tmp_path / "index.db"))
    embedder = AsyncMock(spec=JinaEmbedder)
    embedder.embed_passage = AsyncMock(return_value=FAKE_EMBEDDING)
    embedder.embed_query = AsyncMock(return_value=FAKE_EMBEDDING)
    classifier = _make_classifier_mock()
    return CortexService(store, embedder, classifier, index=index)


@pytest.fixture
def cortex_with_graph(tmp_path) -> CortexService:
    """CortexService with real store + index + graph + mocked embedder/classifier."""
    store = MemoryStore(str(tmp_path / "chroma"), collection_name="test_cortex_graph")
    index = MemoryIndex(str(tmp_path / "index.db"))
    graph = ConceptGraph(index.conn)
    embedder = AsyncMock(spec=JinaEmbedder)
    embedder.embed_passage = AsyncMock(return_value=FAKE_EMBEDDING)
    embedder.embed_query = AsyncMock(return_value=FAKE_EMBEDDING)
    classifier = _make_classifier_mock()
    return CortexService(store, embedder, classifier, index=index, graph=graph)


@pytest.fixture
def cortex_with_dedup(tmp_path) -> CortexService:
    """CortexService with real store + dedup + mocked embedder/classifier."""
    store = MemoryStore(str(tmp_path / "chroma"), collection_name="test_cortex_dedup")
    conn = sqlite3.connect(str(tmp_path / "shared.db"), check_same_thread=False)
    dedup = DedupStore(conn)
    embedder = AsyncMock(spec=JinaEmbedder)
    embedder.embed_passage = AsyncMock(return_value=FAKE_EMBEDDING)
    embedder.embed_query = AsyncMock(return_value=FAKE_EMBEDDING)
    classifier = _make_classifier_mock()
    return CortexService(store, embedder, classifier, dedup=dedup)


class TestCortexIngest:
    @pytest.mark.asyncio
    async def test_ingest_stores_memory(self, cortex):
        req = IngestRequest(
            session_id="test-session",
            question="How do I fix the bug?",
            answer="Check the stack trace.",
        )
        memory_id = await cortex.ingest(req)
        assert memory_id is not None
        assert memory_id.startswith("v3_")
        assert cortex._store.count() == 1

    @pytest.mark.asyncio
    async def test_ingest_static_noise_filtered(self, cortex):
        for noise in ["ciao", "ok", "si", "/clear"]:
            req = IngestRequest(session_id="s1", question=noise, answer="whatever")
            result = await cortex.ingest(req)
            assert result is None
        assert cortex._store.count() == 0

    @pytest.mark.asyncio
    async def test_ingest_system_prefix_filtered(self, cortex):
        req = IngestRequest(
            session_id="s1",
            question="[system] internal message",
            answer="ack",
        )
        assert await cortex.ingest(req) is None

    @pytest.mark.asyncio
    async def test_ingest_groq_noise_filter_short_message(self, cortex):
        cortex._classifier.is_noise = AsyncMock(return_value=True)
        req = IngestRequest(session_id="s1", question="lol wut", answer="idk")
        assert await cortex.ingest(req) is None

    @pytest.mark.asyncio
    async def test_ingest_long_message_skips_groq_noise(self, cortex):
        long_q = "x" * 100
        req = IngestRequest(session_id="s1", question=long_q, answer="answer")
        result = await cortex.ingest(req)
        assert result is not None
        # is_noise should NOT have been called for long messages
        cortex._classifier.is_noise.assert_not_called()


class TestCortexRecall:
    @pytest.mark.asyncio
    async def test_basic_recall_empty(self, cortex):
        result = await cortex.recall("anything", n=5, smart=False)
        assert result.count == 0
        assert result.memories == []

    @pytest.mark.asyncio
    async def test_basic_recall_with_data(self, cortex):
        req = IngestRequest(session_id="s1", question="Fix the crash", answer="Stack trace fix")
        await cortex.ingest(req)
        result = await cortex.recall("crash fix", n=5, smart=False)
        assert result.count == 1
        assert result.memories[0].record.question == "Fix the crash"[:500]

    @pytest.mark.asyncio
    async def test_smart_recall_calls_analyzer(self, cortex):
        req = IngestRequest(session_id="s1", question="Engine bug", answer="Fixed it")
        await cortex.ingest(req)
        result = await cortex.recall("engine bugs", n=5, smart=True)
        cortex._classifier.analyze_query.assert_called_once_with("engine bugs")
        assert result.mbel == "@test::mbel"


class TestCortexDream:
    @pytest.mark.asyncio
    async def test_dream_skips_with_few_memories(self, cortex):
        result = await cortex.dream()
        assert result.status == "skipped"
        assert result.dream_count == 0

    @pytest.mark.asyncio
    async def test_dream_runs_with_enough_memories(self, cortex):
        for i in range(3):
            req = IngestRequest(session_id=f"s{i}", question=f"Q{i}", answer=f"A{i}")
            await cortex.ingest(req)
        result = await cortex.dream()
        assert result.status == "completed"
        assert result.dream_count == 1
        assert len(result.cluster) >= 1

    @pytest.mark.asyncio
    async def test_dream_increments_count(self, cortex):
        for i in range(3):
            req = IngestRequest(session_id=f"s{i}", question=f"Q{i}", answer=f"A{i}")
            await cortex.ingest(req)
        await cortex.dream()
        await cortex.dream()
        result = await cortex.dream()
        assert result.dream_count == 3


class TestCortexStats:
    def test_stats_empty(self, cortex):
        stats = cortex.stats()
        assert stats.total_memories == 0

    @pytest.mark.asyncio
    async def test_stats_with_data(self, cortex):
        req = IngestRequest(session_id="s1", question="Test", answer="Answer")
        await cortex.ingest(req)
        stats = cortex.stats()
        assert stats.total_memories == 1


class TestCortexTimeline:
    @pytest.mark.asyncio
    async def test_timeline_returns_memories(self, cortex):
        req = IngestRequest(session_id="s1", question="Timeline test", answer="Works")
        await cortex.ingest(req)
        result = await cortex.timeline(n=10)
        assert result.count == 1


class TestCortexDualWrite:
    @pytest.mark.asyncio
    async def test_ingest_writes_to_both_stores(self, cortex_with_index):
        req = IngestRequest(session_id="s1", question="Dual write test", answer="Both stores")
        memory_id = await cortex_with_index.ingest(req)
        assert memory_id is not None
        # ChromaDB
        assert cortex_with_index._store.count() == 1
        # SQLite
        assert cortex_with_index._index.count() == 1
        record = cortex_with_index._index.get_by_id(memory_id)
        assert record is not None
        assert record.project == "neo-cortex"

    @pytest.mark.asyncio
    async def test_ingest_works_without_index(self, cortex):
        req = IngestRequest(session_id="s1", question="No index", answer="Still works")
        memory_id = await cortex.ingest(req)
        assert memory_id is not None
        assert cortex._store.count() == 1

    @pytest.mark.asyncio
    async def test_ingest_structured_fields_propagated(self, cortex_with_index):
        req = IngestRequest(session_id="s1", question="Structured test", answer="With fields")
        memory_id = await cortex_with_index.ingest(req)
        structured = cortex_with_index._index.get_structured(memory_id)
        assert structured is not None
        assert structured.title == "Test title"
        assert "fact1" in structured.facts
        assert "concept1" in structured.concepts


class TestCortexFallbackSearch:
    @pytest.mark.asyncio
    async def test_recall_fallback_to_fts_when_embed_fails(self, cortex_with_index):
        # Ingest a memory
        req = IngestRequest(session_id="s1", question="Fix the crash bug", answer="Stack trace analysis")
        await cortex_with_index.ingest(req)
        # Make embedder fail
        cortex_with_index._embedder.embed_query = AsyncMock(side_effect=Exception("Jina down"))
        result = await cortex_with_index.recall("crash", n=5, smart=False)
        assert result.count >= 1
        assert result.memories[0].record.question.startswith("Fix the crash")

    @pytest.mark.asyncio
    async def test_recall_fts_returns_results(self, cortex_with_index):
        req = IngestRequest(session_id="s1", question="Deploy systemd service", answer="Created unit file")
        await cortex_with_index.ingest(req)
        cortex_with_index._embedder.embed_query = AsyncMock(side_effect=Exception("Jina down"))
        result = await cortex_with_index.recall("deploy", n=5, smart=False)
        assert result.count >= 1

    @pytest.mark.asyncio
    async def test_recall_fts_works_without_index(self, cortex):
        cortex._embedder.embed_query = AsyncMock(side_effect=Exception("Jina down"))
        result = await cortex.recall("anything", n=5, smart=False)
        assert result.count == 0
        assert result.memories == []


class TestSmartRecallQuality:
    """Tests for MIN_SIMILARITY filter and FTS5 blend in _smart_recall."""

    @pytest.mark.asyncio
    async def test_garbage_results_filtered_by_min_similarity(self, cortex_with_index):
        """Vector results below MIN_SIMILARITY (0.2) are discarded."""
        # Ingest a memory
        req = IngestRequest(session_id="s1", question="SimHash dedup hamming distance", answer="Use 64-bit hashes")
        await cortex_with_index.ingest(req)

        # Mock store.query to return a garbage-quality result (similarity 0.15)
        from neo_cortex.models import MemoryRecord, Activity
        garbage_record = MemoryRecord(
            id="garbage_1", session_id="s1", timestamp=1.0, turn_number=1,
            question="Unrelated email", answer_preview="...",
            document="random content", project="general", topic="unknown",
            activity=Activity.DISCUSSION, energy=1.0,
        )
        cortex_with_index._store.query = lambda *a, **kw: [(garbage_record, 0.15)]
        result = await cortex_with_index.recall("SimHash dedup", n=5, smart=True)
        # The garbage result (0.15 < 0.2) should be filtered out
        garbage_ids = [m.record.id for m in result.memories if m.record.id == "garbage_1"]
        assert len(garbage_ids) == 0

    @pytest.mark.asyncio
    async def test_fts_blend_when_vector_quality_weak(self, cortex_with_index):
        """When best vector similarity < 0.4, FTS5 results are blended in."""
        # Ingest a memory with keyword "SimHash"
        req = IngestRequest(session_id="s1", question="SimHash dedup hamming distance", answer="Use 64-bit hashes")
        await cortex_with_index.ingest(req)

        # Mock store.query to return weak results (0.25 — above MIN but below WEAK)
        from neo_cortex.models import MemoryRecord, Activity
        weak_record = MemoryRecord(
            id="weak_1", session_id="s1", timestamp=1.0, turn_number=1,
            question="Vaguely related", answer_preview="...",
            document="some content", project="general", topic="unknown",
            activity=Activity.DISCUSSION, energy=1.0,
        )
        cortex_with_index._store.query = lambda *a, **kw: [(weak_record, 0.25)]

        result = await cortex_with_index.recall("SimHash dedup", n=5, smart=True)
        # FTS should find the real SimHash memory via keyword match
        ids = [m.record.id for m in result.memories]
        assert len(ids) >= 1
        # The FTS-blended result should have a higher score than the weak vector result
        if len(result.memories) > 1:
            assert result.memories[0].similarity >= result.memories[1].similarity

    @pytest.mark.asyncio
    async def test_fts_blend_skipped_when_vector_strong(self, cortex_with_index):
        """When best vector similarity >= 0.4, no FTS blend (already good)."""
        req = IngestRequest(session_id="s1", question="Fix encoding BOM", answer="UTF8Encoding(false)")
        await cortex_with_index.ingest(req)

        # Mock store.query to return strong result
        from neo_cortex.models import MemoryRecord, Activity
        strong_record = MemoryRecord(
            id="strong_1", session_id="s1", timestamp=1.0, turn_number=1,
            question="Fix encoding BOM", answer_preview="UTF8Encoding(false)",
            document="content", project="neo-cortex", topic="encoding",
            activity=Activity.BUGFIX, energy=1.0,
        )
        cortex_with_index._store.query = lambda *a, **kw: [(strong_record, 0.75)]

        result = await cortex_with_index.recall("encoding BOM fix", n=5, smart=True)
        # Strong result should be returned as-is without FTS blend changing the count
        assert result.count >= 1
        assert result.memories[0].similarity >= 0.7

    @pytest.mark.asyncio
    async def test_fts_blend_no_duplicates(self, cortex_with_index):
        """FTS blend should not duplicate memories already in vector results."""
        req = IngestRequest(session_id="s1", question="Deploy systemd service", answer="Created unit file")
        mid = await cortex_with_index.ingest(req)

        # The same memory appears in both vector and FTS — should appear only once
        result = await cortex_with_index.recall("Deploy systemd", n=5, smart=True)
        ids = [m.record.id for m in result.memories]
        assert len(ids) == len(set(ids))  # no duplicates


class TestRecallWithConcepts:
    @pytest.mark.asyncio
    async def test_recall_concept_boost_reranks(self, cortex_with_index):
        """Memories matching query concepts should be boosted in results."""
        # Ingest two memories with different concepts
        cortex_with_index._classifier.classify = AsyncMock(return_value=ClassificationResult(
            project="test", topic="t", activity=Activity.FEATURE,
            title="Encoding fix", summary="s",
            concepts=["encoding", "BOM"], files_touched=[],
        ))
        req1 = IngestRequest(session_id="s1", question="Fix encoding BOM bug", answer="Use UTF8Encoding(false)")
        await cortex_with_index.ingest(req1)

        cortex_with_index._classifier.classify = AsyncMock(return_value=ClassificationResult(
            project="test", topic="t", activity=Activity.DEPLOY,
            title="Deploy service", summary="s",
            concepts=["deploy", "systemd"], files_touched=[],
        ))
        # Use a different embedding so vector search doesn't give identical scores
        cortex_with_index._embedder.embed_passage = AsyncMock(return_value=FAKE_EMBEDDING_2)
        req2 = IngestRequest(session_id="s2", question="Deploy to systemd", answer="Created service file")
        await cortex_with_index.ingest(req2)

        # Recall with concept hint — encoding should match via concept boost
        cortex_with_index._embedder.embed_query = AsyncMock(return_value=FAKE_EMBEDDING_2)
        cortex_with_index._classifier.analyze_query = AsyncMock(return_value=QueryAnalysis(
            refined_query="encoding BOM",
        ))
        result = await cortex_with_index.recall("encoding BOM issue", n=5, smart=True)
        assert result.count >= 1
        # The encoding memory should appear (concept boost helps even if vector similarity is lower)
        ids = [m.record.id for m in result.memories]
        assert any("s1" in mid for mid in ids)

    @pytest.mark.asyncio
    async def test_recall_works_without_index(self, cortex):
        """Recall without index still works (no concept boost, no crash)."""
        req = IngestRequest(session_id="s1", question="Test no index", answer="Still works")
        await cortex.ingest(req)
        result = await cortex.recall("test", n=5, smart=True)
        assert result.count >= 0  # no crash


class TestCortexDedup:
    @pytest.mark.asyncio
    async def test_dedup_blocks_duplicate_ingest(self, cortex_with_dedup):
        """Identical question+answer → second ingest blocked."""
        req = IngestRequest(session_id="s1", question="Fix encoding BOM bug", answer="Use UTF8Encoding(false)")
        mid1 = await cortex_with_dedup.ingest(req)
        assert mid1 is not None

        req2 = IngestRequest(session_id="s2", question="Fix encoding BOM bug", answer="Use UTF8Encoding(false)")
        mid2 = await cortex_with_dedup.ingest(req2)
        assert mid2 is None  # blocked by dedup
        assert cortex_with_dedup._store.count() == 1

    @pytest.mark.asyncio
    async def test_dedup_allows_different_content(self, cortex_with_dedup):
        """Different content → both ingested."""
        req1 = IngestRequest(session_id="s1", question="Fix encoding bug", answer="Use UTF8Encoding")
        req2 = IngestRequest(session_id="s2", question="Deploy to production server", answer="Created systemd unit")
        mid1 = await cortex_with_dedup.ingest(req1)
        mid2 = await cortex_with_dedup.ingest(req2)
        assert mid1 is not None
        assert mid2 is not None
        assert cortex_with_dedup._store.count() == 2

    @pytest.mark.asyncio
    async def test_ingest_works_without_dedup(self, cortex):
        """No dedup → no crash, normal behavior."""
        req = IngestRequest(session_id="s1", question="Normal ingest", answer="Works fine")
        mid = await cortex.ingest(req)
        assert mid is not None


class TestCortexGraph:
    @pytest.mark.asyncio
    async def test_ingest_populates_graph(self, cortex_with_graph):
        """Ingest with concepts → graph gets populated."""
        req = IngestRequest(session_id="s1", question="Fix encoding BOM", answer="UTF8Encoding(false)")
        await cortex_with_graph.ingest(req)
        # classifier mock returns concepts=["concept1"]
        assert cortex_with_graph._graph.has_node("concept1")

    @pytest.mark.asyncio
    async def test_ingest_without_graph_no_crash(self, cortex):
        """No graph → no crash."""
        req = IngestRequest(session_id="s1", question="No graph", answer="Still works")
        mid = await cortex.ingest(req)
        assert mid is not None


class TestSpontaneousRecall:
    @pytest.mark.asyncio
    async def test_spontaneous_returns_results(self, cortex_with_index):
        """Spontaneous recall returns important, not-recently-accessed memories."""
        req = IngestRequest(session_id="s1", question="Important architecture decision", answer="Use NetworkX")
        await cortex_with_index.ingest(req)
        results = await cortex_with_index.spontaneous(n=3)
        # May return 0 or more — depends on timing. Should not crash.
        assert isinstance(results, list)

    @pytest.mark.asyncio
    async def test_spontaneous_empty_cortex(self, cortex):
        """Empty cortex → empty list, no crash."""
        results = await cortex.spontaneous()
        assert results == []

    @pytest.mark.asyncio
    async def test_spontaneous_without_index(self, cortex):
        """No index → empty list, no crash."""
        req = IngestRequest(session_id="s1", question="Test", answer="Answer")
        await cortex.ingest(req)
        results = await cortex.spontaneous()
        assert results == []


class TestDreamEbbinghaus:
    @pytest.mark.asyncio
    async def test_dream_applies_decay(self, cortex):
        """Dream applies Ebbinghaus decay — recent memories keep more energy."""
        for i in range(3):
            req = IngestRequest(session_id=f"s{i}", question=f"Question {i}", answer=f"Answer {i}")
            await cortex.ingest(req)
        result = await cortex.dream()
        assert result.status == "completed"
        # All memories are very recent, so should still have high energy
        meta = cortex._store.get_all_metadata()
        for m in meta:
            assert m["energy"] > 0.9  # just created, stability=1.0, oldness~0

    @pytest.mark.asyncio
    async def test_dream_still_skips_few_memories(self, cortex):
        """Dream skips with < 2 memories."""
        result = await cortex.dream()
        assert result.status == "skipped"


class TestRetrievalReinforcement:
    @pytest.mark.asyncio
    async def test_recall_boosts_energy(self, cortex):
        """After recall, found memories have higher energy."""
        req = IngestRequest(session_id="s1", question="Fix encoding bug", answer="Use UTF8Encoding")
        mid = await cortex.ingest(req)
        # Set energy to something below 1.0
        cortex._store.update_energy(mid, 0.5)
        # Recall — should boost energy
        await cortex.recall("encoding bug", n=5, smart=False)
        meta = cortex._store.get_all_metadata()
        found = next(m for m in meta if m["id"] == mid)
        assert found["energy"] > 0.5

    @pytest.mark.asyncio
    async def test_recall_energy_capped_at_one(self, cortex):
        """Energy should not exceed 1.0."""
        req = IngestRequest(session_id="s1", question="Fix encoding bug", answer="Use UTF8Encoding")
        mid = await cortex.ingest(req)
        cortex._store.update_energy(mid, 0.95)
        await cortex.recall("encoding bug", n=5, smart=False)
        meta = cortex._store.get_all_metadata()
        found = next(m for m in meta if m["id"] == mid)
        assert found["energy"] <= 1.0

    @pytest.mark.asyncio
    async def test_recall_reinforcement_formula(self, cortex):
        """energy += eta * (1 - energy), eta = 0.1."""
        req = IngestRequest(session_id="s1", question="Fix encoding bug", answer="Use UTF8Encoding")
        mid = await cortex.ingest(req)
        cortex._store.update_energy(mid, 0.5)
        await cortex.recall("encoding bug", n=5, smart=False)
        expected = 0.5 + 0.1 * (1.0 - 0.5)  # 0.55
        meta = cortex._store.get_all_metadata()
        found = next(m for m in meta if m["id"] == mid)
        assert abs(found["energy"] - expected) < 0.01

    @pytest.mark.asyncio
    async def test_recall_no_boost_empty_results(self, cortex):
        """Empty recall → no crash, no side effects."""
        result = await cortex.recall("nonexistent topic xyz", n=5, smart=False)
        assert result.count == 0


class TestIngestEpisode:
    @pytest.mark.asyncio
    async def test_ingest_episode_stores_memory(self, cortex_with_index):
        """ingest_episode() creates a memory from the episode."""
        text = "User: Usiamo NetworkX?\nAssistant: Sì, zero dep.\nUser: ok va bene"
        mid = await cortex_with_index.ingest_episode(text, "sess-1")
        assert mid is not None
        assert mid.startswith("ep_")
        assert cortex_with_index._store.count() == 1

    @pytest.mark.asyncio
    async def test_ingest_episode_dedup(self, cortex_with_dedup):
        """Duplicate episodes blocked by dedup."""
        text = "User: Fix encoding\nAssistant: UTF8Encoding(false)"
        mid1 = await cortex_with_dedup.ingest_episode(text, "s1")
        mid2 = await cortex_with_dedup.ingest_episode(text, "s2")
        assert mid1 is not None
        assert mid2 is None

    @pytest.mark.asyncio
    async def test_ingest_episode_populates_graph(self, cortex_with_graph):
        """Episode populates the concept graph."""
        text = "User: Come funziona encoding?\nAssistant: BOM header..."
        await cortex_with_graph.ingest_episode(text, "s1")
        assert cortex_with_graph._graph.node_count() > 0

    @pytest.mark.asyncio
    async def test_ingest_episode_empty_text(self, cortex):
        """Empty text → None, no crash."""
        mid = await cortex.ingest_episode("", "s1")
        assert mid is None
        mid2 = await cortex.ingest_episode("   ", "s1")
        assert mid2 is None

    @pytest.mark.asyncio
    async def test_ingest_episode_no_noise_filter(self, cortex):
        """ingest_episode does NOT apply noise filter (digestor already decided)."""
        text = "User: ok\nAssistant: Perfetto, procedo con NetworkX."
        mid = await cortex.ingest_episode(text, "s1")
        assert mid is not None  # "ok" NOT filtered

    @pytest.mark.asyncio
    async def test_ingest_episode_writes_index(self, cortex_with_index):
        """Episode stored in SQLite index too."""
        text = "User: Deploy su systemd\nAssistant: Usa systemctl..."
        mid = await cortex_with_index.ingest_episode(text, "s1")
        assert mid is not None
        record = cortex_with_index._index.get_by_id(mid)
        assert record is not None
        assert record.source == "digestor"


class TestIngestEpisodeConflictResolution:
    """Tests for conflict resolution in ingest_episode()."""

    @pytest.mark.asyncio
    async def test_episode_conflict_update(self, cortex_with_index):
        """Episode that updates existing memory → UPDATE (keep old ID)."""
        text1 = "User: neo-cortex ha 200 test\nAssistant: Tutti verdi"
        mid1 = await cortex_with_index.ingest_episode(text1, "s1")
        assert mid1 is not None
        assert cortex_with_index._store.count() == 1

        # Configure classifier to return "update"
        cortex_with_index._classifier.resolve_conflict = AsyncMock(
            return_value={"action": "update", "target_id": mid1, "reason": "test count changed"}
        )

        text2 = "User: neo-cortex ha 244 test\nAssistant: Tutti verdi dopo refactor"
        mid2 = await cortex_with_index.ingest_episode(text2, "s2")

        assert mid2 == mid1  # same ID
        assert cortex_with_index._store.count() == 1  # still 1
        assert cortex_with_index._index.count() == 1

    @pytest.mark.asyncio
    async def test_episode_conflict_noop(self, cortex_with_index):
        """Redundant episode → NOOP (return None)."""
        text1 = "User: Deploy su systemd\nAssistant: Service creato"
        mid1 = await cortex_with_index.ingest_episode(text1, "s1")
        assert mid1 is not None

        cortex_with_index._classifier.resolve_conflict = AsyncMock(
            return_value={"action": "noop", "target_id": mid1, "reason": "redundant"}
        )

        text2 = "User: Deploy su systemd\nAssistant: Service gia pronto"
        mid2 = await cortex_with_index.ingest_episode(text2, "s2")

        assert mid2 is None
        assert cortex_with_index._store.count() == 1

    @pytest.mark.asyncio
    async def test_episode_conflict_add(self, cortex_with_index):
        """Different episode → ADD (new ID)."""
        text1 = "User: Fix encoding BOM\nAssistant: UTF8Encoding(false)"
        mid1 = await cortex_with_index.ingest_episode(text1, "s1")

        # Default mock returns "add"
        cortex_with_index._embedder.embed_passage = AsyncMock(return_value=FAKE_EMBEDDING_2)
        text2 = "User: Deploy to production\nAssistant: Systemd service started"
        mid2 = await cortex_with_index.ingest_episode(text2, "s2")

        assert mid1 is not None
        assert mid2 is not None
        assert mid1 != mid2
        assert cortex_with_index._store.count() == 2

    @pytest.mark.asyncio
    async def test_episode_no_index_skips_conflict(self, cortex):
        """Without index → skip conflict resolution, always ADD."""
        text1 = "User: Test one\nAssistant: Answer one"
        mid1 = await cortex.ingest_episode(text1, "s1")
        text2 = "User: Test two\nAssistant: Answer two"
        mid2 = await cortex.ingest_episode(text2, "s2")
        assert mid1 is not None
        assert mid2 is not None
        assert cortex._store.count() == 2


class TestConflictResolution:
    """Tests for mem0-style conflict resolution during ingest."""

    @pytest.mark.asyncio
    async def test_conflict_update_replaces_old_memory(self, cortex_with_index):
        """When classifier says 'update', old memory is replaced with new content."""
        # Ingest first version
        req1 = IngestRequest(session_id="s1", question="neo-cortex has 200 tests", answer="All green")
        mid1 = await cortex_with_index.ingest(req1)
        assert mid1 is not None
        assert cortex_with_index._store.count() == 1

        # Configure classifier to return "update" for the conflict check
        cortex_with_index._classifier.resolve_conflict = AsyncMock(
            return_value={"action": "update", "target_id": mid1, "reason": "version changed"}
        )

        # Ingest updated version — should UPDATE, not ADD
        req2 = IngestRequest(session_id="s2", question="neo-cortex has 236 tests", answer="All green after refactor")
        mid2 = await cortex_with_index.ingest(req2)

        assert mid2 == mid1  # same ID returned
        assert cortex_with_index._store.count() == 1  # still 1 memory, not 2
        assert cortex_with_index._index.count() == 1

        # Verify content was updated
        record = cortex_with_index._index.get_by_id(mid1)
        assert "236" in record.question

    @pytest.mark.asyncio
    async def test_conflict_noop_skips_redundant(self, cortex_with_index):
        """When classifier says 'noop', incoming memory is discarded."""
        req1 = IngestRequest(session_id="s1", question="Deploy to systemd", answer="Service created")
        mid1 = await cortex_with_index.ingest(req1)
        assert mid1 is not None

        # Configure classifier to return "noop"
        cortex_with_index._classifier.resolve_conflict = AsyncMock(
            return_value={"action": "noop", "target_id": mid1, "reason": "redundant"}
        )

        # Ingest same info again — should be skipped
        req2 = IngestRequest(session_id="s2", question="Deploy to systemd", answer="Service file ready")
        mid2 = await cortex_with_index.ingest(req2)

        assert mid2 is None  # skipped
        assert cortex_with_index._store.count() == 1  # still just 1

    @pytest.mark.asyncio
    async def test_conflict_add_keeps_both(self, cortex_with_index):
        """When classifier says 'add', both memories are kept."""
        req1 = IngestRequest(session_id="s1", question="Fix encoding bug", answer="UTF8Encoding(false)")
        mid1 = await cortex_with_index.ingest(req1)

        # Default mock already returns "add"
        req2 = IngestRequest(session_id="s2", question="Deploy to production", answer="Service started")
        # Use different embedding so similarity is lower
        cortex_with_index._embedder.embed_passage = AsyncMock(return_value=FAKE_EMBEDDING_2)
        mid2 = await cortex_with_index.ingest(req2)

        assert mid1 is not None
        assert mid2 is not None
        assert mid1 != mid2
        assert cortex_with_index._store.count() == 2

    @pytest.mark.asyncio
    async def test_conflict_skipped_without_index(self, cortex):
        """Without index, conflict check is skipped — normal add."""
        req1 = IngestRequest(session_id="s1", question="Test one", answer="Answer one")
        mid1 = await cortex.ingest(req1)
        req2 = IngestRequest(session_id="s2", question="Test two", answer="Answer two")
        mid2 = await cortex.ingest(req2)
        assert mid1 is not None
        assert mid2 is not None
        assert cortex._store.count() == 2

    @pytest.mark.asyncio
    async def test_conflict_not_triggered_for_low_similarity(self, cortex_with_index):
        """Conflict check only runs when similarity > 0.7."""
        req1 = IngestRequest(session_id="s1", question="Fix encoding bug", answer="UTF8Encoding")
        await cortex_with_index.ingest(req1)

        # Different embedding = low similarity
        cortex_with_index._embedder.embed_passage = AsyncMock(return_value=FAKE_EMBEDDING_2)
        req2 = IngestRequest(session_id="s2", question="Deploy to production", answer="Systemd service")
        await cortex_with_index.ingest(req2)

        # resolve_conflict should NOT have been called (similarity < 0.7)
        # First ingest: store is empty so no conflict check
        # Second ingest: different embedding, similarity will be low
        assert cortex_with_index._store.count() == 2


class TestStoreUpdateDelete:
    """Tests for store.update() and store.delete()."""

    @pytest.mark.asyncio
    async def test_store_update_changes_content(self, cortex_with_index):
        """store.update() replaces document and metadata."""
        req = IngestRequest(session_id="s1", question="Version 1.0", answer="Initial")
        mid = await cortex_with_index.ingest(req)

        # Build updated record
        from neo_cortex.models import MemoryRecord, Activity
        updated = MemoryRecord(
            id=mid, session_id="s1", timestamp=2.0, turn_number=1,
            question="Version 2.0", answer_preview="Updated",
            document="Q: Version 2.0\nA: Updated",
            project="neo-cortex", topic="test",
            activity=Activity.FEATURE, energy=1.0,
        )
        cortex_with_index._store.update(mid, updated, FAKE_EMBEDDING)

        # Verify ChromaDB updated
        results = cortex_with_index._store.query(FAKE_EMBEDDING, n=1)
        assert len(results) == 1
        assert results[0][0].question == "Version 2.0"

    @pytest.mark.asyncio
    async def test_store_delete_removes_memory(self, cortex_with_index):
        """store.delete() removes from ChromaDB."""
        req = IngestRequest(session_id="s1", question="To be deleted", answer="Gone")
        mid = await cortex_with_index.ingest(req)
        assert cortex_with_index._store.count() == 1

        cortex_with_index._store.delete(mid)
        assert cortex_with_index._store.count() == 0

    @pytest.mark.asyncio
    async def test_index_delete_removes_memory(self, cortex_with_index):
        """index.delete() removes from SQLite and FTS5."""
        req = IngestRequest(session_id="s1", question="Index delete test", answer="Should disappear")
        mid = await cortex_with_index.ingest(req)
        assert cortex_with_index._index.count() == 1

        cortex_with_index._index.delete(mid)
        assert cortex_with_index._index.count() == 0
        assert cortex_with_index._index.get_by_id(mid) is None


class TestIngestKnowledge:
    """Tests for ingest_knowledge() — distilled entries from Gemini."""

    @pytest.mark.asyncio
    async def test_ingest_knowledge_add(self, cortex_with_index):
        """New knowledge entry → stored with correct fields."""
        mid = await cortex_with_index.ingest_knowledge(
            topic="MIN_SIMILARITY filter",
            content="Added MIN_SIMILARITY=0.2 to _smart_recall() in cortex.py.",
            entry_type="bugfix",
            project="neo-cortex",
            session_id="test-session",
        )
        assert mid is not None
        assert mid.startswith("kn_")
        assert cortex_with_index._store.count() == 1

        # Verify via index (store doesn't have get_by_id)
        rec = cortex_with_index._index.get_by_id(mid)
        assert rec is not None
        assert rec.question == "MIN_SIMILARITY filter"
        assert rec.project == "neo-cortex"
        assert rec.source == "distiller"

    @pytest.mark.asyncio
    async def test_ingest_knowledge_noop_on_dedup(self, cortex_with_dedup):
        """Duplicate content → None returned."""
        content = "Added MIN_SIMILARITY=0.2 to _smart_recall() in cortex.py. This is a detailed entry about the filter."
        mid1 = await cortex_with_dedup.ingest_knowledge(
            topic="Filter 1", content=content, session_id="s1",
        )
        assert mid1 is not None

        mid2 = await cortex_with_dedup.ingest_knowledge(
            topic="Filter 2", content=content, session_id="s1",
        )
        assert mid2 is None
        assert cortex_with_dedup._store.count() == 1

    @pytest.mark.asyncio
    async def test_ingest_knowledge_empty_content(self, cortex_with_index):
        """Empty content → None."""
        mid = await cortex_with_index.ingest_knowledge(
            topic="Empty", content="   ", session_id="s1",
        )
        assert mid is None

    @pytest.mark.asyncio
    async def test_ingest_knowledge_index_populated(self, cortex_with_index):
        """Knowledge entry populates memory index with structured fields."""
        mid = await cortex_with_index.ingest_knowledge(
            topic="CORS middleware fix",
            content="Added app.UseCors() to the middleware pipeline in Program.cs.",
            entry_type="bugfix",
            project="neo-reloaded",
            session_id="s1",
        )
        assert mid is not None
        assert cortex_with_index._index.count() == 1

        sf = cortex_with_index._index.get_structured(mid)
        assert sf is not None
        assert sf.title == "CORS middleware fix"


class TestIngestKnowledgeFast:
    """Tests for ingest_knowledge_fast() — ADD-only + pending_merges."""

    @pytest.mark.asyncio
    async def test_fast_ingest_always_adds(self, cortex_with_index):
        """ingest_knowledge_fast() always ADDs — no conflict resolution."""
        mid1 = await cortex_with_index.ingest_knowledge_fast(
            topic="CORS fix", content="Added app.UseCors() to pipeline.",
            project="neo-reloaded", session_id="s1",
        )
        mid2 = await cortex_with_index.ingest_knowledge_fast(
            topic="CORS middleware", content="app.UseCors() fixes cross-origin.",
            project="neo-reloaded", session_id="s1",
        )
        assert mid1 is not None
        assert mid2 is not None
        assert mid1 != mid2
        assert cortex_with_index._store.count() == 2
        # resolve_conflict should NOT be called
        cortex_with_index._classifier.resolve_conflict.assert_not_called()

    @pytest.mark.asyncio
    async def test_fast_ingest_records_pending_merges(self, cortex_with_index):
        """Fast ingest records similar pairs in pending_merges."""
        # First ingest
        await cortex_with_index.ingest_knowledge_fast(
            topic="Encoding fix", content="Use UTF8Encoding(false) to avoid BOM.",
            project="neo-cortex", session_id="s1",
        )
        # Second ingest — same embedding = similarity 1.0 > 0.65
        mid2 = await cortex_with_index.ingest_knowledge_fast(
            topic="BOM header issue", content="BOM corrupts JSON stdin.",
            project="neo-cortex", session_id="s1",
        )
        pairs = cortex_with_index._index.get_pending_merges()
        assert len(pairs) >= 1
        ids_in_pairs = {p[0] for p in pairs} | {p[1] for p in pairs}
        assert mid2 in ids_in_pairs

    @pytest.mark.asyncio
    async def test_fast_ingest_empty_content(self, cortex_with_index):
        mid = await cortex_with_index.ingest_knowledge_fast(
            topic="Empty", content="   ", session_id="s1",
        )
        assert mid is None

    @pytest.mark.asyncio
    async def test_fast_ingest_dedup(self, cortex_with_dedup):
        """Dedup still works in fast path."""
        content = "Detailed content about encoding BOM fix using UTF8Encoding(false) in .NET."
        mid1 = await cortex_with_dedup.ingest_knowledge_fast(
            topic="T1", content=content, session_id="s1",
        )
        mid2 = await cortex_with_dedup.ingest_knowledge_fast(
            topic="T2", content=content, session_id="s1",
        )
        assert mid1 is not None
        assert mid2 is None


class TestClear:
    """Test clear() methods on all stores."""

    def test_store_clear(self, tmp_path):
        store = MemoryStore(str(tmp_path), collection_name="test_clear")
        from neo_cortex.models import MemoryRecord, MemoryType
        rec = MemoryRecord(
            id="test1", session_id="s1", timestamp=1.0, turn_number=1,
            question="q", answer_preview="a", document="Q: q\nA: a",
            project="p", topic="t", activity=Activity.FEATURE,
            memory_type=MemoryType.EPISODIC, energy=1.0, model="m", source="s",
        )
        store.insert(rec, FAKE_EMBEDDING)
        assert store.count() == 1
        n = store.clear()
        assert n == 1
        assert store.count() == 0

    def test_index_clear(self, tmp_path):
        index = MemoryIndex(str(tmp_path / "idx.db"))
        from neo_cortex.models import MemoryRecord, MemoryType
        rec = MemoryRecord(
            id="test1", session_id="s1", timestamp=1.0, turn_number=1,
            question="q", answer_preview="a", document="Q: q\nA: a",
            project="p", topic="t", activity=Activity.FEATURE,
            memory_type=MemoryType.EPISODIC, energy=1.0, model="m", source="s",
        )
        index.insert(rec)
        assert index.count() == 1
        n = index.clear()
        assert n == 1
        assert index.count() == 0

    def test_dedup_clear(self, tmp_path):
        conn = sqlite3.connect(str(tmp_path / "shared.db"), check_same_thread=False)
        dedup = DedupStore(conn)
        dedup.add("some text", "mem1")
        assert dedup.is_duplicate("some text")
        n = dedup.clear()
        assert n == 1
        assert not dedup.is_duplicate("some text")

    def test_graph_clear(self, tmp_path):
        conn = sqlite3.connect(str(tmp_path / "shared.db"), check_same_thread=False)
        graph = ConceptGraph(conn)
        graph.add_memory("m1", ["python", "testing"])
        assert graph.node_count() == 2
        n = graph.clear()
        assert n == 2
        assert graph.node_count() == 0
