"""Tests for MemoryIndex — SQLite + FTS5 structured memory store."""

import json
import pytest

from neo_cortex.memory_index import MemoryIndex
from neo_cortex.models import (
    Activity,
    CompactMemory,
    MemoryRecord,
    MemoryType,
    StructuredFields,
)


@pytest.fixture
def index(tmp_path):
    return MemoryIndex(str(tmp_path / "test_index.db"))


@pytest.fixture
def record_a():
    return MemoryRecord(
        id="v3_aaa_0_10001",
        session_id="sess-aaa",
        timestamp=1707300000.0,
        turn_number=0,
        question="How to fix the crash in ProcessIO?",
        answer_preview="The crash was caused by InvalidOperationException...",
        document="Q: crash\nA: fix",
        project="neo-reloaded",
        topic="crash fix",
        activity=Activity.BUGFIX,
        energy=0.8,
    )


@pytest.fixture
def record_b():
    return MemoryRecord(
        id="v3_bbb_1_10002",
        session_id="sess-bbb",
        timestamp=1707400000.0,
        turn_number=1,
        question="Deploy neo-cortex to systemd",
        answer_preview="Created neo-cortex.service with Restart=on-failure...",
        document="Q: deploy\nA: systemd",
        project="neo-cortex",
        topic="deploy",
        activity=Activity.DEPLOY,
        energy=1.0,
    )


@pytest.fixture
def structured_a():
    return StructuredFields(
        title="Fix ProcessIO crash on dispose",
        summary="InvalidOperationException after process dispose",
        facts=["Process.HasExited throws after dispose", "Added try-catch guard"],
        concepts=["process-lifecycle", "exception-handling"],
        files_touched=["src/IO/ProcessIO.cs"],
    )


@pytest.fixture
def structured_b():
    return StructuredFields(
        title="Deploy neo-cortex systemd service",
        summary="Created systemd unit file with on-failure restart",
        facts=["Port 5074", "Restart=on-failure"],
        concepts=["systemd", "deployment"],
        files_touched=["deploy/services/neo-cortex.service"],
    )


class TestCreateAndInit:
    def test_creates_db_on_init(self, index):
        assert index.count() == 0

    def test_creates_tables(self, index):
        # Should not raise
        assert index.count() == 0


class TestInsertAndGet:
    def test_insert_and_get_by_id(self, index, record_a, structured_a):
        index.insert(record_a, structured_a)
        result = index.get_by_id("v3_aaa_0_10001")
        assert result is not None
        assert result.id == "v3_aaa_0_10001"
        assert result.project == "neo-reloaded"
        assert result.question == record_a.question

    def test_get_by_id_not_found(self, index):
        assert index.get_by_id("nonexistent") is None

    def test_insert_and_get_by_ids(self, index, record_a, record_b, structured_a, structured_b):
        index.insert(record_a, structured_a)
        index.insert(record_b, structured_b)
        results = index.get_by_ids(["v3_aaa_0_10001", "v3_bbb_1_10002"])
        assert len(results) == 2
        ids = {r.id for r in results}
        assert "v3_aaa_0_10001" in ids
        assert "v3_bbb_1_10002" in ids

    def test_get_by_ids_partial(self, index, record_a, structured_a):
        index.insert(record_a, structured_a)
        results = index.get_by_ids(["v3_aaa_0_10001", "nonexistent"])
        assert len(results) == 1

    def test_insert_without_structured(self, index, record_a):
        index.insert(record_a)
        result = index.get_by_id("v3_aaa_0_10001")
        assert result is not None
        assert result.project == "neo-reloaded"

    def test_count(self, index, record_a, record_b, structured_a, structured_b):
        assert index.count() == 0
        index.insert(record_a, structured_a)
        assert index.count() == 1
        index.insert(record_b, structured_b)
        assert index.count() == 2

    def test_insert_duplicate_upserts(self, index, record_a, structured_a):
        index.insert(record_a, structured_a)
        # Update energy
        record_a_updated = record_a.model_copy(update={"energy": 0.5})
        index.insert(record_a_updated, structured_a)
        assert index.count() == 1
        result = index.get_by_id("v3_aaa_0_10001")
        assert result.energy == 0.5


class TestSearchFTS:
    def test_search_basic(self, index, record_a, record_b, structured_a, structured_b):
        index.insert(record_a, structured_a)
        index.insert(record_b, structured_b)
        results = index.search_fts("crash")
        assert len(results) >= 1
        assert any(r.id == "v3_aaa_0_10001" for r in results)

    def test_search_with_project_filter(self, index, record_a, record_b, structured_a, structured_b):
        index.insert(record_a, structured_a)
        index.insert(record_b, structured_b)
        results = index.search_fts("", project="neo-cortex")
        assert all(r.project == "neo-cortex" for r in results)

    def test_search_with_activity_filter(self, index, record_a, record_b, structured_a, structured_b):
        index.insert(record_a, structured_a)
        index.insert(record_b, structured_b)
        results = index.search_fts("", activity="deploy")
        assert all(r.activity == "deploy" for r in results)

    def test_search_empty_query_returns_recent(self, index, record_a, record_b, structured_a, structured_b):
        index.insert(record_a, structured_a)
        index.insert(record_b, structured_b)
        results = index.search_fts("")
        assert len(results) == 2
        # Newest first
        assert results[0].timestamp >= results[1].timestamp

    def test_search_no_results(self, index, record_a, structured_a):
        index.insert(record_a, structured_a)
        results = index.search_fts("kubernetes")
        assert len(results) == 0


class TestTimeline:
    def test_timeline_newest_first(self, index, record_a, record_b, structured_a, structured_b):
        index.insert(record_a, structured_a)
        index.insert(record_b, structured_b)
        results = index.timeline(n=10)
        assert len(results) == 2
        assert results[0].timestamp > results[1].timestamp

    def test_timeline_filter_project(self, index, record_a, record_b, structured_a, structured_b):
        index.insert(record_a, structured_a)
        index.insert(record_b, structured_b)
        results = index.timeline(n=10, project="neo-cortex")
        assert len(results) == 1
        assert results[0].project == "neo-cortex"

    def test_timeline_limit(self, index, record_a, record_b, structured_a, structured_b):
        index.insert(record_a, structured_a)
        index.insert(record_b, structured_b)
        results = index.timeline(n=1)
        assert len(results) == 1


class TestStatsAndEnergy:
    def test_stats(self, index, record_a, record_b, structured_a, structured_b):
        index.insert(record_a, structured_a)
        index.insert(record_b, structured_b)
        s = index.stats(dream_count=3)
        assert s["total_memories"] == 2
        assert s["sessions"] == 2
        assert s["dream_count"] == 3
        assert s["avg_energy"] > 0

    def test_stats_empty(self, index):
        s = index.stats(dream_count=0)
        assert s["total_memories"] == 0
        assert s["sessions"] == 0

    def test_update_energy(self, index, record_a, structured_a):
        index.insert(record_a, structured_a)
        index.update_energy("v3_aaa_0_10001", 0.3)
        result = index.get_by_id("v3_aaa_0_10001")
        assert abs(result.energy - 0.3) < 0.001


class TestStructuredFields:
    def test_get_structured(self, index, record_a, structured_a):
        index.insert(record_a, structured_a)
        s = index.get_structured("v3_aaa_0_10001")
        assert s is not None
        assert s.title == "Fix ProcessIO crash on dispose"
        assert "Process.HasExited throws after dispose" in s.facts
        assert "process-lifecycle" in s.concepts
        assert "src/IO/ProcessIO.cs" in s.files_touched

    def test_get_structured_not_found(self, index):
        assert index.get_structured("nonexistent") is None


@pytest.fixture
def index_with_data(index, record_a, record_b, structured_a, structured_b):
    """Index pre-loaded with 2 records + structured fields."""
    index.insert(record_a, structured_a)
    index.insert(record_b, structured_b)
    return index


class TestConceptSearch:
    def test_search_by_concept_finds_matching(self, index_with_data):
        """Search 'process-lifecycle' → finds memories with that concept."""
        results = index_with_data.search_by_concept("process-lifecycle")
        assert len(results) >= 1
        assert any(r.id == "v3_aaa_0_10001" for r in results)

    def test_search_by_concept_no_match(self, index_with_data):
        results = index_with_data.search_by_concept("nonexistent-xyz")
        assert len(results) == 0

    def test_search_by_concept_partial_match(self, index_with_data):
        """'process' matches 'process-lifecycle'."""
        results = index_with_data.search_by_concept("process")
        assert len(results) >= 1

    def test_search_by_file_touched(self, index_with_data):
        """Search 'ProcessIO.cs' → finds memories that touch that file."""
        results = index_with_data.search_by_file("ProcessIO.cs")
        assert len(results) >= 1
        assert any(r.id == "v3_aaa_0_10001" for r in results)

    def test_search_by_file_no_match(self, index_with_data):
        results = index_with_data.search_by_file("nonexistent.py")
        assert len(results) == 0

    def test_get_all_concepts(self, index_with_data):
        """List all unique concepts with frequency."""
        concepts = index_with_data.get_all_concepts()
        assert isinstance(concepts, dict)
        assert all(isinstance(v, int) for v in concepts.values())
        assert "process-lifecycle" in concepts
        assert "systemd" in concepts

    def test_get_all_concepts_empty(self, index):
        concepts = index.get_all_concepts()
        assert concepts == {}

    def test_get_related_by_concepts(self, index):
        """Two memories sharing concepts are 'related'."""
        # Insert 3 memories: A and B share 'encoding', C has different concepts
        rec_a = MemoryRecord(
            id="rel_a", session_id="s1", timestamp=1.0,
            question="q", answer_preview="a", document="d",
        )
        rec_b = MemoryRecord(
            id="rel_b", session_id="s2", timestamp=2.0,
            question="q", answer_preview="a", document="d",
        )
        rec_c = MemoryRecord(
            id="rel_c", session_id="s3", timestamp=3.0,
            question="q", answer_preview="a", document="d",
        )
        index.insert(rec_a, StructuredFields(concepts=["encoding", "BOM"]))
        index.insert(rec_b, StructuredFields(concepts=["encoding", "stdin"]))
        index.insert(rec_c, StructuredFields(concepts=["deploy", "systemd"]))

        related = index.get_related_by_concepts("rel_a")
        related_ids = [r.id for r in related]
        assert "rel_b" in related_ids  # shares 'encoding'
        assert "rel_a" not in related_ids  # exclude self


class TestSharedConnection:
    """Dedup and ConceptGraph coexist in same DB as MemoryIndex."""

    def test_dedup_shares_connection(self, tmp_path):
        from neo_cortex.dedup import DedupStore
        index = MemoryIndex(str(tmp_path / "shared.db"))
        dedup = DedupStore(index.conn)
        dedup.add("test text", "mem1")
        assert dedup.is_duplicate("test text")
        assert index.count() == 0  # memories table unaffected

    def test_graph_shares_connection(self, tmp_path):
        from neo_cortex.graph import ConceptGraph
        index = MemoryIndex(str(tmp_path / "shared.db"))
        graph = ConceptGraph(index.conn)
        graph.add_memory("m1", ["python", "testing"])
        assert graph.node_count() == 2
        assert index.count() == 0  # memories table unaffected

    def test_all_three_coexist(self, tmp_path):
        from neo_cortex.dedup import DedupStore
        from neo_cortex.graph import ConceptGraph
        index = MemoryIndex(str(tmp_path / "shared.db"))
        dedup = DedupStore(index.conn)
        graph = ConceptGraph(index.conn)

        # All three write to same DB without conflict
        rec = MemoryRecord(
            id="coexist_1", session_id="s1", timestamp=1.0,
            question="q", answer_preview="a", document="d",
        )
        index.insert(rec)
        dedup.add("coexist text", "coexist_1")
        graph.add_memory("coexist_1", ["python"])

        assert index.count() == 1
        assert dedup.is_duplicate("coexist text")
        assert graph.has_node("python")


# --- TDDAB-1: get_stability ---

def test_get_stability_default(index, record_a, structured_a):
    """get_stability returns 1.0 for newly inserted memory."""
    index.insert(record_a, structured_a)
    assert index.get_stability(record_a.id) == 1.0


def test_get_stability_after_update(index, record_a, structured_a):
    """get_stability returns updated value after update_stability."""
    index.insert(record_a, structured_a)
    index.update_stability(record_a.id, 2.5)
    assert index.get_stability(record_a.id) == 2.5


def test_get_stability_missing_returns_default(index):
    """get_stability returns 1.0 for unknown ID."""
    assert index.get_stability("nonexistent") == 1.0


# --- TDDAB-4: observation_level ---

def test_observation_level_stored_and_retrieved(index):
    """observation_level is persisted in SQLite and retrievable."""
    from neo_cortex.models import MemoryRecord, ObservationLevel
    record = MemoryRecord(
        id="obs1", session_id="s1",
        question="q", answer_preview="a", document="doc",
        observation_level=ObservationLevel.INFERRED,
    )
    index.insert(record)
    rows = index.get_by_ids(["obs1"])
    assert len(rows) == 1
    assert rows[0].id == "obs1"


def test_observation_level_default_in_db(index, record_a, structured_a):
    """Existing records without observation_level get 'direct' default."""
    index.insert(record_a, structured_a)
    rows = index.get_by_ids([record_a.id])
    assert len(rows) == 1
