"""Tests for RuFlo-inspired features: dedup, confidence decay/boost, MMR, PageRank."""

from __future__ import annotations

import hashlib
from datetime import datetime, timedelta, timezone

import pytest

from attestor.models import Memory, RetrievalResult
from attestor.retrieval.scorer import (
    confidence_decay_boost,
    mmr_rerank,
    pagerank_boost,
)


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

def _mem(content: str, **kwargs) -> Memory:
    return Memory(content=content, **kwargs)


def _result(content: str, score: float = 1.0, source: str = "tag", **kwargs) -> RetrievalResult:
    return RetrievalResult(memory=_mem(content, **kwargs), score=score, match_source=source)


def _hours_ago(hours: float) -> str:
    return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()


# ===========================================================================
# Confidence Decay/Boost
# ===========================================================================

class TestConfidenceDecayBoost:
    def test_fresh_memory_no_decay(self):
        """Recently created memory with no accesses should keep full score."""
        results = [_result("fresh", created_at=_hours_ago(0))]
        out = confidence_decay_boost(results, decay_rate=0.001, boost_rate=0.03)
        assert len(out) == 1
        assert out[0].score == pytest.approx(1.0, abs=0.01)

    def test_old_memory_decays(self):
        """Memory untouched for 500 hours should decay significantly."""
        results = [_result("old", created_at=_hours_ago(500), confidence=1.0)]
        out = confidence_decay_boost(results, decay_rate=0.001, boost_rate=0.03)
        assert len(out) == 1
        # 500 * 0.001 = 0.5 decay, confidence = 0.5, score = 1.0 * 0.5
        assert out[0].score < 0.6

    def test_access_boost_counters_decay(self):
        """High access count should offset time decay."""
        results = [_result("popular", created_at=_hours_ago(200),
                           access_count=20, confidence=1.0)]
        out = confidence_decay_boost(results, decay_rate=0.001, boost_rate=0.03)
        assert len(out) == 1
        # decay: 200 * 0.001 = 0.2, boost: 20 * 0.03 = 0.6
        # conf = 1.0 - 0.2 + 0.6 = 1.4 -> clamped to 1.0
        assert out[0].score == pytest.approx(1.0, abs=0.01)

    def test_confidence_floor(self):
        """Confidence should never drop below 0.1."""
        results = [_result("ancient", created_at=_hours_ago(5000), confidence=1.0)]
        out = confidence_decay_boost(results, decay_rate=0.001, boost_rate=0.03)
        assert len(out) == 1
        assert out[0].score == pytest.approx(0.1, abs=0.01)

    def test_gate_filters_low_confidence(self):
        """Memories below the gate threshold should be filtered out."""
        results = [
            _result("old", created_at=_hours_ago(800), confidence=1.0),
            _result("fresh", created_at=_hours_ago(0), confidence=1.0),
        ]
        out = confidence_decay_boost(results, decay_rate=0.001, gate=0.5)
        # old: conf = max(0.1, 1.0 - 0.8) = 0.2 < 0.5, filtered
        # fresh: conf = 1.0 >= 0.5, kept
        assert len(out) == 1
        assert out[0].memory.content == "fresh"

    def test_last_accessed_used_over_created_at(self):
        """Decay should use last_accessed when available."""
        results = [_result("accessed_recently",
                           created_at=_hours_ago(1000),
                           last_accessed=_hours_ago(1),
                           confidence=1.0)]
        out = confidence_decay_boost(results, decay_rate=0.001)
        # Uses last_accessed (1h ago), not created_at (1000h ago)
        assert out[0].score > 0.95

    def test_empty_results(self):
        assert confidence_decay_boost([]) == []


# ===========================================================================
# MMR Reranking
# ===========================================================================

class TestMMRRerank:
    def test_single_result_unchanged(self):
        results = [_result("only one")]
        out = mmr_rerank(results)
        assert len(out) == 1

    def test_diverse_results_kept(self):
        """Results with different content should all be kept."""
        results = [
            _result("cats love fish", score=0.9),
            _result("dogs play fetch", score=0.8),
            _result("birds can fly", score=0.7),
        ]
        out = mmr_rerank(results, lambda_param=0.7)
        assert len(out) == 3

    def test_near_duplicates_deprioritized(self):
        """Near-duplicate results should be pushed down the ranking."""
        results = [
            _result("the user likes python programming", score=0.9),
            _result("the user likes python programming a lot", score=0.88),
            _result("the project uses rust for performance", score=0.7),
        ]
        out = mmr_rerank(results, lambda_param=0.5)
        # With lambda=0.5, diversity matters more — rust result should move up
        contents = [r.memory.content for r in out]
        assert contents[0] == "the user likes python programming"
        # The diverse "rust" result should come before the near-duplicate python one
        assert contents[1] == "the project uses rust for performance"

    def test_max_results_respected(self):
        results = [_result(f"item {i}", score=1.0 - i * 0.1) for i in range(10)]
        out = mmr_rerank(results, max_results=3)
        assert len(out) == 3

    def test_lambda_1_is_pure_relevance(self):
        """lambda=1.0 should give pure relevance ordering (no diversity penalty)."""
        results = [
            _result("aaa bbb ccc", score=0.9),
            _result("aaa bbb ccc ddd", score=0.8),
            _result("xxx yyy zzz", score=0.7),
        ]
        out = mmr_rerank(results, lambda_param=1.0)
        scores = [r.score for r in out]
        # Should be in descending relevance order
        assert scores == sorted(scores, reverse=True)

    def test_empty_results(self):
        assert mmr_rerank([]) == []


# ===========================================================================
# PageRank Boost
# ===========================================================================

class TestPageRankBoost:
    def test_entity_with_high_pagerank_boosted(self):
        pr_scores = {"python": 0.5, "javascript": 0.1}
        results = [
            _result("Python is great", entity="Python", score=1.0),
            _result("JS is fast", entity="JavaScript", score=1.0),
        ]
        out = pagerank_boost(results, pr_scores, weight=0.3)
        assert out[0].score > out[1].score
        assert out[0].score == pytest.approx(1.0 + 0.3 * 0.5)
        assert out[1].score == pytest.approx(1.0 + 0.3 * 0.1)

    def test_no_entity_no_boost(self):
        pr_scores = {"python": 0.5}
        results = [_result("no entity here", score=1.0)]
        out = pagerank_boost(results, pr_scores, weight=0.3)
        assert out[0].score == 1.0

    def test_empty_pagerank_noop(self):
        results = [_result("test", score=1.0)]
        out = pagerank_boost(results, {}, weight=0.3)
        assert out[0].score == 1.0

    def test_empty_results(self):
        assert pagerank_boost([], {"a": 0.5}) == []


# ===========================================================================
# Content Hash Dedup
# ===========================================================================

@pytest.mark.skipif(
    not __import__("os").environ.get("POSTGRES_URL"),
    reason="dedup tests build AgentMemory — require POSTGRES_URL",
)
class TestContentHashDedup:
    def test_add_dedup(self, tmp_path):
        """Adding the same content twice should return the existing memory."""
        from attestor.core import AgentMemory

        from .conftest import _build_test_config
        with AgentMemory(tmp_path / "dedup_test", config=_build_test_config()) as mem:
            m1 = mem.add("The sky is blue", tags=["fact"])
            m2 = mem.add("The sky is blue", tags=["fact"])
            assert m1.id == m2.id

    def test_add_whitespace_dedup(self, tmp_path):
        """Content differing only by leading/trailing whitespace should dedup."""
        from attestor.core import AgentMemory

        from .conftest import _build_test_config
        with AgentMemory(tmp_path / "ws_test", config=_build_test_config()) as mem:
            m1 = mem.add("hello world", tags=["test"])
            m2 = mem.add("  hello world  ", tags=["test"])
            assert m1.id == m2.id

    def test_different_content_not_deduped(self, tmp_path):
        """Different content should create separate memories."""
        from attestor.core import AgentMemory

        from .conftest import _build_test_config
        with AgentMemory(tmp_path / "diff_test", config=_build_test_config()) as mem:
            m1 = mem.add("The sky is blue", tags=["fact"])
            m2 = mem.add("The sky is red", tags=["fact"])
            assert m1.id != m2.id

    def test_content_hash_stored(self, tmp_path):
        """Memory should have content_hash set after add."""
        from attestor.core import AgentMemory

        from .conftest import _build_test_config
        with AgentMemory(tmp_path / "hash_test", config=_build_test_config()) as mem:
            m = mem.add("test content", tags=["test"])
            assert m.content_hash is not None
            expected = hashlib.sha256(b"test content").hexdigest()
            assert m.content_hash == expected


# ===========================================================================
# Access Tracking
# ===========================================================================

@pytest.mark.skipif(
    not __import__("os").environ.get("POSTGRES_URL"),
    reason="access-tracking tests build AgentMemory — require POSTGRES_URL",
)
class TestAccessTracking:
    def test_recall_increments_access(self, tmp_path):
        """Recalling memories should increment their access_count."""
        from attestor.core import AgentMemory

        from .conftest import _build_test_config
        with AgentMemory(tmp_path / "access_test", config=_build_test_config()) as mem:
            m = mem.add("Python is a programming language",
                        tags=["python", "language"], entity="Python")
            # Recall to trigger access tracking
            mem.recall("Python")
            # Re-fetch from store
            updated = mem.get(m.id)
            assert updated is not None
            assert updated.access_count >= 1
            assert updated.last_accessed is not None


# NOTE: The NetworkX PageRank test block was dropped in 2026-04-19 when the
# embedded NetworkX graph backend was removed. PageRank on the new stack is
# served by Neo4j GDS and is exercised by tests/test_integration_neo4j.py
# (when NEO4J_URI is set).


# ===========================================================================
# Config Fields
# ===========================================================================

class TestRetrievalConfig:
    def test_new_config_fields_roundtrip(self, tmp_path):
        from attestor.utils.config import MemoryConfig, load_config, save_config

        cfg = MemoryConfig(
            enable_mmr=False,
            mmr_lambda=0.5,
            fusion_mode="graph_blend",
            confidence_gate=0.3,
            confidence_decay_rate=0.002,
            confidence_boost_rate=0.05,
        )
        save_config(tmp_path, cfg)
        loaded = load_config(tmp_path)
        assert loaded.enable_mmr is False
        assert loaded.mmr_lambda == 0.5
        assert loaded.fusion_mode == "graph_blend"
        assert loaded.confidence_gate == 0.3
        assert loaded.confidence_decay_rate == 0.002
        assert loaded.confidence_boost_rate == 0.05

    def test_default_config_values(self):
        from attestor.utils.config import MemoryConfig

        cfg = MemoryConfig()
        assert cfg.enable_mmr is True
        assert cfg.mmr_lambda == 0.7
        assert cfg.fusion_mode == "rrf"
        assert cfg.confidence_gate == 0.0
        assert cfg.confidence_decay_rate == 0.001
        assert cfg.confidence_boost_rate == 0.03


# ── Regression: MMR cap must respect the orchestrator's vector_top_k ──
# Pre-fix the orchestrator called ``mmr_rerank(results, lambda_param=...)``
# with no ``max_results`` kwarg, so MMR fell back to its hardcoded default
# of 10. Knowledge-update LME questions reliably tripped this: 3+ turns
# from the same session would all match the query, MMR demoted them as
# redundant, and the answer-bearing turn (rank 11+) got dropped.
class TestMmrRespectsOrchestratorCap:
    """Verify mmr_rerank surfaces enough candidates when called with a
    higher ``max_results`` cap from the orchestrator's vector_top_k."""

    def _mk(self, idx: int, score: float) -> "RetrievalResult":
        from attestor.models import Memory, RetrievalResult
        return RetrievalResult(
            memory=Memory(id=str(idx), content=f"distinct content number {idx}"),
            score=score,
            match_source="vector",
        )

    def test_default_caps_at_10(self) -> None:
        """Default behaviour — no max_results — caps at 10."""
        from attestor.retrieval.scorer import mmr_rerank
        results = [self._mk(i, 1.0 - i * 0.01) for i in range(30)]
        out = mmr_rerank(results)
        assert len(out) == 10, "default cap regression"

    def test_custom_max_results_keeps_more(self) -> None:
        """Passing max_results=30 must surface up to 30 candidates."""
        from attestor.retrieval.scorer import mmr_rerank
        results = [self._mk(i, 1.0 - i * 0.01) for i in range(30)]
        out = mmr_rerank(results, max_results=30)
        assert len(out) == 30, (
            "the LME knowledge-update fix relies on this — bumping "
            "max_results must let downstream candidates survive MMR"
        )
