#!/usr/bin/env python3
"""
Tests: episodic memory tag-based search + uncertainty (confidence) handling.

Covers:
  - Entity match scoring (weight 3.0)
  - Keyword overlap scoring (weight 0.5 each)
  - Summary text match scoring (weight 0.3 each)
  - Agent name match scoring (weight 1.0)
  - Score threshold filter (< 0.5 excluded)
  - Fixed confidence value (always 0.8 — uncertainty anchor)
  - Salience calculation (min(score * 0.8, 10.0))
  - Max 10 results, sorted by salience descending
  - Empty/missing index edge cases
"""
import sys
import hashlib
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from pulse.brain.search_sources.search_sources_episodic import search_episodic


def _make_entry(
    summary="test summary",
    keywords=None,
    entities=None,
    agent="claude",
    timestamp="2026-03-18T10:00:00+00:00",
    emotional_intensity=0.0,
):
    return {
        "timestamp": timestamp,
        "agent": agent,
        "topic_keywords": keywords or [],
        "entities_mentioned": entities or [],
        "emotional_intensity": emotional_intensity,
        "wants_count": 0,
        "facts_count": 0,
        "capture_log_offset": 0,
        "summary": summary,
    }


def _mock_index(entries):
    """Return (file_mock, load_mock) patchers for search_sources_episodic."""
    file_mock = MagicMock()
    file_mock.exists.return_value = True
    return file_mock, entries


# ---------------------------------------------------------------------------
# Edge cases — empty / missing
# ---------------------------------------------------------------------------
class TestEdgeCases:
    def test_missing_file_returns_empty(self):
        file_mock = MagicMock()
        file_mock.exists.return_value = False
        with patch("pulse.brain.search_sources.search_sources_episodic.EPISODIC_INDEX_FILE", file_mock):
            assert search_episodic("anything") == []

    def test_empty_index_returns_empty(self):
        file_mock = MagicMock()
        file_mock.exists.return_value = True
        with patch("pulse.brain.search_sources.search_sources_episodic.EPISODIC_INDEX_FILE", file_mock), \
             patch("pulse.brain.search_sources.search_sources_episodic.load_index", return_value=[]):
            assert search_episodic("brain query") == []

    def test_empty_query_returns_empty(self):
        file_mock = MagicMock()
        file_mock.exists.return_value = True
        entry = _make_entry(summary="some content", keywords=["brain"])
        with patch("pulse.brain.search_sources.search_sources_episodic.EPISODIC_INDEX_FILE", file_mock), \
             patch("pulse.brain.search_sources.search_sources_episodic.load_index", return_value=[entry]):
            assert search_episodic("") == []
            assert search_episodic("   ") == []


# ---------------------------------------------------------------------------
# Scoring — entity match (weight 3.0)
# ---------------------------------------------------------------------------
class TestEntityMatchScoring:
    def _search(self, index, query):
        file_mock = MagicMock()
        file_mock.exists.return_value = True
        with patch("pulse.brain.search_sources.search_sources_episodic.EPISODIC_INDEX_FILE", file_mock), \
             patch("pulse.brain.search_sources.search_sources_episodic.load_index", return_value=index):
            return search_episodic(query)

    def test_entity_match_produces_hit(self):
        entry = _make_entry(entities=["PULSE"], summary="system boot")
        results = self._search([entry], "pulse")
        assert len(results) == 1

    def test_entity_match_salience_3x0_8(self):
        """Entity-only match: score=3.0 → salience=min(3.0*0.8, 10.0)=2.4."""
        entry = _make_entry(entities=["PULSE"], summary="unrelated content xyz")
        results = self._search([entry], "pulse")
        assert len(results) == 1
        assert results[0]["salience"] == pytest.approx(2.4, abs=0.01)

    def test_entity_partial_match_query_in_entity(self):
        """Query 'puls' is in entity 'PULSE' (query_lower in entity)."""
        entry = _make_entry(entities=["PULSE"], summary="abc")
        results = self._search([entry], "puls")
        assert len(results) == 1

    def test_entity_partial_match_entity_in_query(self):
        """Entity 'pulse' is in query 'check pulse system'."""
        entry = _make_entry(entities=["pulse"], summary="abc")
        results = self._search([entry], "check pulse system")
        assert len(results) == 1

    def test_entity_match_only_once_per_entry(self):
        """Multiple matching entities still only add 3.0 (break after first)."""
        entry = _make_entry(entities=["PULSE", "Brain", "pulse"], summary="abc")
        results = self._search([entry], "pulse")
        # Score = 3.0 (entity) only → salience = 2.4
        assert results[0]["salience"] == pytest.approx(2.4, abs=0.01)


# ---------------------------------------------------------------------------
# Scoring — keyword overlap (weight 0.5 each)
# ---------------------------------------------------------------------------
class TestKeywordOverlapScoring:
    def _search(self, index, query):
        file_mock = MagicMock()
        file_mock.exists.return_value = True
        with patch("pulse.brain.search_sources.search_sources_episodic.EPISODIC_INDEX_FILE", file_mock), \
             patch("pulse.brain.search_sources.search_sources_episodic.load_index", return_value=index):
            return search_episodic(query)

    def test_single_keyword_match_reaches_threshold(self):
        """1 keyword match: score=0.5 → exactly at threshold, should appear."""
        entry = _make_entry(keywords=["brain"], summary="unrelated xyz qrs")
        results = self._search([entry], "brain")
        assert len(results) == 1

    def test_single_keyword_match_salience(self):
        """score=0.5 → salience=0.4."""
        entry = _make_entry(keywords=["brain"], summary="unrelated xyz qrs")
        results = self._search([entry], "brain")
        assert results[0]["salience"] == pytest.approx(0.4, abs=0.01)

    def test_two_keyword_matches_additive(self):
        """2 keyword matches: score=1.0 → salience=0.8."""
        entry = _make_entry(keywords=["brain", "search"], summary="unrelated xyz")
        results = self._search([entry], "brain search")
        assert results[0]["salience"] == pytest.approx(0.8, abs=0.01)

    def test_no_keyword_overlap_no_hit(self):
        """Query words not in keywords and not in summary → no hit."""
        entry = _make_entry(keywords=["brain"], summary="unrelated")
        results = self._search([entry], "database schema migration")
        assert len(results) == 0


# ---------------------------------------------------------------------------
# Scoring — summary text match (weight 0.3 per word, len>2)
# ---------------------------------------------------------------------------
class TestSummaryTextMatch:
    def _search(self, index, query):
        file_mock = MagicMock()
        file_mock.exists.return_value = True
        with patch("pulse.brain.search_sources.search_sources_episodic.EPISODIC_INDEX_FILE", file_mock), \
             patch("pulse.brain.search_sources.search_sources_episodic.load_index", return_value=index):
            return search_episodic(query)

    def test_summary_word_match_scores_0_3(self):
        """1 word in summary (len>2): score=0.3 → below threshold 0.5 → no hit."""
        entry = _make_entry(summary="brain update complete", keywords=[])
        results = self._search([entry], "brain")
        # score = 0.3 < 0.5 → filtered
        assert len(results) == 0

    def test_two_summary_words_exceed_threshold(self):
        """2 words in summary: score=0.6 → above threshold → hit."""
        entry = _make_entry(summary="brain search optimization complete", keywords=[])
        results = self._search([entry], "brain search")
        assert len(results) == 1
        assert results[0]["salience"] == pytest.approx(0.6 * 0.8, abs=0.01)

    def test_short_query_words_skipped(self):
        """Words with len <= 2 are NOT matched in summary."""
        entry = _make_entry(summary="is it ok to do so", keywords=[])
        results = self._search([entry], "is it ok")
        assert len(results) == 0

    def test_summary_match_case_insensitive(self):
        """Summary matching is case-insensitive (both lowercased)."""
        entry = _make_entry(summary="Brain Search is working", keywords=[])
        results = self._search([entry], "brain search")
        assert len(results) == 1


# ---------------------------------------------------------------------------
# Scoring — agent name match (weight 1.0)
# ---------------------------------------------------------------------------
class TestAgentNameMatch:
    def _search(self, index, query):
        file_mock = MagicMock()
        file_mock.exists.return_value = True
        with patch("pulse.brain.search_sources.search_sources_episodic.EPISODIC_INDEX_FILE", file_mock), \
             patch("pulse.brain.search_sources.search_sources_episodic.load_index", return_value=index):
            return search_episodic(query)

    def test_agent_name_in_query_scores_1_0(self):
        """Query containing agent name adds 1.0. Combined with keyword → hit."""
        entry = _make_entry(agent="claude", keywords=["brain"], summary="xyz qrs")
        # score = 0.5 (keyword) + 1.0 (agent) = 1.5 → salience=1.2
        results = self._search([entry], "claude brain")
        assert len(results) == 1
        assert results[0]["salience"] == pytest.approx(1.2, abs=0.01)

    def test_agent_name_alone_below_threshold(self):
        """Agent name alone: score=1.0 → above threshold → hit."""
        entry = _make_entry(agent="gemini", keywords=[], summary="xyz qrs")
        results = self._search([entry], "gemini")
        assert len(results) == 1
        assert results[0]["salience"] == pytest.approx(0.8, abs=0.01)


# ---------------------------------------------------------------------------
# Uncertainty — fixed confidence value
# ---------------------------------------------------------------------------
class TestUncertaintyConfidence:
    def _search(self, index, query):
        file_mock = MagicMock()
        file_mock.exists.return_value = True
        with patch("pulse.brain.search_sources.search_sources_episodic.EPISODIC_INDEX_FILE", file_mock), \
             patch("pulse.brain.search_sources.search_sources_episodic.load_index", return_value=index):
            return search_episodic(query)

    def test_confidence_always_0_8(self):
        """All episodic hits carry confidence=0.8 (fixed uncertainty anchor)."""
        entries = [
            _make_entry(entities=["PULSE"], summary="high score entry"),
            _make_entry(keywords=["brain"], summary="xyz"),
        ]
        results = self._search(entries, "pulse brain")
        for r in results:
            assert r["confidence"] == 0.8, f"Expected 0.8, got {r['confidence']}"

    def test_confidence_independent_of_score(self):
        """Confidence is 0.8 regardless of how high the score is."""
        entry = _make_entry(
            entities=["PULSE"],
            keywords=["brain", "search", "memory", "index"],
            summary="pulse brain search memory index complete",
        )
        results = self._search([entry], "pulse brain search memory index")
        assert len(results) == 1
        assert results[0]["confidence"] == 0.8

    def test_source_field_is_episodic(self):
        """Source tag identifies episodic origin."""
        entry = _make_entry(entities=["PULSE"])
        results = self._search([entry], "pulse")
        assert results[0]["source"] == "episodic"


# ---------------------------------------------------------------------------
# Salience cap and ranking
# ---------------------------------------------------------------------------
class TestSalienceAndRanking:
    def _search(self, index, query):
        file_mock = MagicMock()
        file_mock.exists.return_value = True
        with patch("pulse.brain.search_sources.search_sources_episodic.EPISODIC_INDEX_FILE", file_mock), \
             patch("pulse.brain.search_sources.search_sources_episodic.load_index", return_value=index):
            return search_episodic(query)

    def test_salience_capped_at_10(self):
        """Salience never exceeds 10.0 even with very high score."""
        entry = _make_entry(
            entities=["PULSE", "Brain"],
            keywords=["brain", "search", "memory", "index", "query", "pulse", "system", "ai"],
            summary="pulse brain search memory index query system ai architecture deep",
        )
        # Max possible score: 3.0 + 8*0.5 + many summary words
        results = self._search([entry], "pulse brain search memory index query system ai")
        assert len(results) == 1
        assert results[0]["salience"] <= 10.0

    def test_results_sorted_by_salience_descending(self):
        """Results returned in descending salience order."""
        low = _make_entry(keywords=["brain"], summary="xyz qrs")   # 1 kw
        high = _make_entry(entities=["PULSE"], keywords=["brain"], summary="xyz qrs")  # entity + kw
        results = self._search([low, high], "pulse brain")
        assert results[0]["salience"] > results[1]["salience"]

    def test_max_10_results_returned(self):
        """At most 10 results regardless of how many entries match."""
        entries = [
            _make_entry(entities=["PULSE"], summary=f"entry {i}")
            for i in range(20)
        ]
        results = self._search(entries, "pulse")
        assert len(results) <= 10


# ---------------------------------------------------------------------------
# Result shape validation
# ---------------------------------------------------------------------------
class TestResultShape:
    def _search(self, index, query):
        file_mock = MagicMock()
        file_mock.exists.return_value = True
        with patch("pulse.brain.search_sources.search_sources_episodic.EPISODIC_INDEX_FILE", file_mock), \
             patch("pulse.brain.search_sources.search_sources_episodic.load_index", return_value=index):
            return search_episodic(query)

    def test_result_has_required_fields(self):
        required = {"id", "text", "title", "salience", "confidence", "source",
                    "agent", "timestamp", "entities", "emotional_intensity"}
        entry = _make_entry(entities=["PULSE"])
        results = self._search([entry], "pulse")
        assert len(results) == 1
        assert required.issubset(results[0].keys())

    def test_id_prefixed_with_EP(self):
        entry = _make_entry(entities=["PULSE"])
        results = self._search([entry], "pulse")
        assert results[0]["id"].startswith("EP-")

    def test_title_prefixed_with_episode(self):
        entry = _make_entry(entities=["PULSE"], summary="testing episodic search")
        results = self._search([entry], "pulse")
        assert results[0]["title"].startswith("[EPISODE]")

    def test_entities_capped_at_5(self):
        """entities_mentioned display is capped at 5."""
        entry = _make_entry(
            entities=["A", "B", "C", "D", "E", "F", "G"],
            summary="abc"
        )
        entry["entities_mentioned"] = ["A", "B", "C", "D", "E", "F", "G"]
        # Query must match agent name since entities list may not be searched after 5
        entry["agent"] = "claude"
        results = self._search([entry], "claude")
        if results:
            assert len(results[0]["entities"]) <= 5
