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

import json
from unittest.mock import AsyncMock

import pytest
from pytest_httpx import HTTPXMock

from neo_cortex.classifier import GroqClassifier
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=GroqClassifier)
    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")
    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(str(tmp_path / "graph.json"))
    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")
    dedup = DedupStore(str(tmp_path / "dedup.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, 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 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"
