"""
Tests for #847 — recency_weight and scoring weights exposed in config.

Coverage:
  - CortexConfig has retrieval_recency_weight=0.1 default (not 0.0)
  - CortexConfig has retrieval_recency_half_life_days field
  - CortexConfig has retrieval_explicitness_bonus field
  - CortexConfig has retrieval_stability_bonus field
  - Env var CORTEX_RETRIEVAL_RECENCY_WEIGHT is honoured
  - recency_weight=0.1 vs 0.0 produces different scores for claim with created_at
  - explicitness_bonus / stability_bonus config values flow through scoring
  - RetrievalPipeline.__init__ reads recency_weight from config
"""

from __future__ import annotations

import asyncio
import math
import os
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from cortex.core.retrieval import (
    RetrievalConstraints,
    RetrievalPipeline,
    RetrievedItem,
    _FTSResult,
    _VectorResult,
)


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

@dataclass
class MockConfig:
    """Minimal config stub — mirrors what lightweight_retrieve reads via getattr."""
    retrieval_token_budget: int = 5000
    total_memory_token_budget: int = 10000
    cornerstone_token_budget: int = 800
    embedding_dim: int = 4
    embedding_model: str = "mock"
    retrieval_rerank_enabled: bool = False
    retrieval_rerank_initial_candidates: int = 30
    retrieval_rrf_k: int = 60
    # Scoring weight fields under test
    retrieval_recency_weight: float = 0.1
    retrieval_recency_half_life_days: float = 30.0
    retrieval_explicitness_bonus: float = 0.05
    retrieval_stability_bonus: float = 0.03
    retrieval_temporal_boost_weight: float = 0.0  # disable temporal for isolation


@dataclass
class MockClaim:
    item_id: str
    content: str
    owner_id: str = "default"
    sensitivity: str = "normal"
    status: str = "active"
    explicitness: str = "inferred"
    stability: str = "volatile"
    memory_class: str = "semantic"
    salience: str = "medium"
    category: str = "misc"
    is_deleted: int = 0
    created_at: Optional[str] = None
    event_date: Optional[str] = None
    valid_to: Optional[str] = None
    temporal: Optional[str] = None
    temporal_marker: Optional[str] = None
    cause: Optional[str] = None
    effect: Optional[str] = None
    state_before: Optional[str] = None
    state_after: Optional[str] = None
    entity_id: Optional[str] = None
    entity_type: Optional[str] = None
    scope: Optional[str] = None
    memory_type: Optional[str] = None
    mentions: Optional[Any] = None
    last_mentioned_at: Optional[str] = None
    last_accessed: Optional[str] = None
    superseded_by: Optional[str] = None
    metadata_json: Optional[str] = None
    source_session: Optional[str] = None
    access_count: int = 0


def _make_storage(claims: List[MockClaim]) -> MagicMock:
    """Build a mock storage that returns the given claims for BM25 + vector."""
    storage = MagicMock()
    storage.embed = AsyncMock(return_value=[0.1, 0.2, 0.3, 0.4])

    fts_results = [
        _FTSResult(
            item_id=c.item_id,
            content=c.content,
            item_type="claim",
            rank=-1.0,
        )
        for c in claims
    ]

    async def _fts(query, table="claims", top_k=50, owner_id="default"):
        return fts_results if table == "claims" else []

    storage.fts_search = AsyncMock(side_effect=_fts)
    storage.vector_search = AsyncMock(return_value=[])
    storage.log_retrieval = AsyncMock()

    _claim_map = {c.item_id: c for c in claims}
    sens_map = {c.item_id: c.sensitivity for c in claims}
    storage.get_claims_sensitivity_batch = AsyncMock(return_value=sens_map)

    async def _get_claim(item_id):
        return _claim_map.get(item_id)

    storage.get_claim = AsyncMock(side_effect=_get_claim)

    async def _update_claim(item_id, **kwargs):
        pass

    storage.update_claim = AsyncMock(side_effect=_update_claim)

    return storage


def _run(coro):
    return asyncio.run(coro)


# ---------------------------------------------------------------------------
# Config field tests
# ---------------------------------------------------------------------------

class TestCortexConfigFields:
    """Verify CortexConfig has the new fields with correct defaults (#847)."""

    def test_retrieval_recency_weight_default_is_0_1(self):
        from cortex.config.model import CortexConfig
        cfg = CortexConfig()
        assert cfg.retrieval_recency_weight == pytest.approx(0.1), (
            "recency_weight default should be 0.1 per research #842"
        )

    def test_retrieval_recency_half_life_days_field_exists(self):
        from cortex.config.model import CortexConfig
        cfg = CortexConfig()
        assert hasattr(cfg, "retrieval_recency_half_life_days")
        assert cfg.retrieval_recency_half_life_days == pytest.approx(30.0)

    def test_retrieval_explicitness_bonus_field_exists(self):
        from cortex.config.model import CortexConfig
        cfg = CortexConfig()
        assert hasattr(cfg, "retrieval_explicitness_bonus")
        assert cfg.retrieval_explicitness_bonus == pytest.approx(0.05)

    def test_retrieval_stability_bonus_field_exists(self):
        from cortex.config.model import CortexConfig
        cfg = CortexConfig()
        assert hasattr(cfg, "retrieval_stability_bonus")
        assert cfg.retrieval_stability_bonus == pytest.approx(0.03)

    def test_env_var_overrides_recency_weight(self, monkeypatch):
        """CORTEX_RETRIEVAL_RECENCY_WEIGHT env var must be honoured."""
        monkeypatch.setenv("CORTEX_RETRIEVAL_RECENCY_WEIGHT", "0.25")
        from cortex.config.model import CortexConfig
        cfg = CortexConfig.from_toml()
        assert cfg.retrieval_recency_weight == pytest.approx(0.25)

    def test_env_var_overrides_recency_half_life_days(self, monkeypatch):
        monkeypatch.setenv("CORTEX_RETRIEVAL_RECENCY_HALF_LIFE_DAYS", "14.0")
        from cortex.config.model import CortexConfig
        cfg = CortexConfig.from_toml()
        assert cfg.retrieval_recency_half_life_days == pytest.approx(14.0)

    def test_env_var_overrides_explicitness_bonus(self, monkeypatch):
        monkeypatch.setenv("CORTEX_RETRIEVAL_EXPLICITNESS_BONUS", "0.10")
        from cortex.config.model import CortexConfig
        cfg = CortexConfig.from_toml()
        assert cfg.retrieval_explicitness_bonus == pytest.approx(0.10)

    def test_env_var_overrides_stability_bonus(self, monkeypatch):
        monkeypatch.setenv("CORTEX_RETRIEVAL_STABILITY_BONUS", "0.07")
        from cortex.config.model import CortexConfig
        cfg = CortexConfig.from_toml()
        assert cfg.retrieval_stability_bonus == pytest.approx(0.07)


# ---------------------------------------------------------------------------
# RetrievalPipeline reads recency_weight from config
# ---------------------------------------------------------------------------

class TestPipelineReadsRecencyWeight:
    """RetrievalPipeline must read retrieval_recency_weight from config."""

    def test_pipeline_reads_recency_weight_from_config(self):
        cfg = MockConfig(retrieval_recency_weight=0.2)
        storage = MagicMock()
        pipeline = RetrievalPipeline(storage=storage, config=cfg)
        assert pipeline.recency_weight == pytest.approx(0.2)

    def test_pipeline_explicit_arg_overrides_config(self):
        cfg = MockConfig(retrieval_recency_weight=0.2)
        storage = MagicMock()
        pipeline = RetrievalPipeline(storage=storage, config=cfg, recency_weight=0.5)
        assert pipeline.recency_weight == pytest.approx(0.5)

    def test_pipeline_reads_recency_half_life_from_config(self):
        cfg = MockConfig(retrieval_recency_half_life_days=14.0)
        storage = MagicMock()
        pipeline = RetrievalPipeline(storage=storage, config=cfg)
        assert pipeline.recency_half_life_days == pytest.approx(14.0)


# ---------------------------------------------------------------------------
# Retrieval scoring: recency_weight=0.0 vs 0.1 produces different scores
# ---------------------------------------------------------------------------

class TestRecencyWeightScoring:
    """Verify that non-zero recency_weight actually changes retrieved scores."""

    def _retrieve_score(self, recency_weight: float, created_at: str) -> float:
        """Run a minimal retrieve() and return the score of the single returned item."""
        claim = MockClaim(
            item_id="claim-recency-test",
            content="Andrew prefers morning meetings",
            created_at=created_at,
        )
        cfg = MockConfig(retrieval_recency_weight=recency_weight)
        storage = _make_storage([claim])
        pipeline = RetrievalPipeline(storage=storage, config=cfg)
        result = _run(pipeline.retrieve("test query", owner_id="default"))
        assert len(result.items) == 1, "Expected exactly one item in result"
        return result.items[0].score

    def test_recency_weight_zero_vs_nonzero_differ(self):
        """Claim created 1 day ago: score with weight=0.1 > score with weight=0.0."""
        recent = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
        score_no_recency = self._retrieve_score(recency_weight=0.0, created_at=recent)
        score_with_recency = self._retrieve_score(recency_weight=0.1, created_at=recent)
        assert score_with_recency > score_no_recency, (
            f"recency_weight=0.1 should boost score: {score_with_recency} > {score_no_recency}"
        )

    def test_recency_bonus_decays_with_age(self):
        """A 1-day-old claim should score higher than a 90-day-old claim with same content."""
        recent = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
        old = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat()
        score_recent = self._retrieve_score(recency_weight=0.1, created_at=recent)
        score_old = self._retrieve_score(recency_weight=0.1, created_at=old)
        assert score_recent > score_old, (
            f"Recent claim should score higher: {score_recent} > {score_old}"
        )

    def test_recency_bonus_magnitude_is_bounded(self):
        """Recency bonus should never exceed recency_weight (max when age=0)."""
        now_str = datetime.now(timezone.utc).isoformat()
        score_no_recency = self._retrieve_score(recency_weight=0.0, created_at=now_str)
        score_with_recency = self._retrieve_score(recency_weight=0.1, created_at=now_str)
        # Bonus is at most recency_weight * exp(0) = recency_weight = 0.1
        diff = score_with_recency - score_no_recency
        assert diff <= 0.11, (
            f"Recency bonus should be bounded by recency_weight (0.1), got diff={diff}"
        )

    def test_recency_weight_zero_no_boost(self):
        """With recency_weight=0.0, created_at should not affect score at all."""
        recent = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
        score_no_created_at = self._retrieve_score(recency_weight=0.0, created_at=None)
        score_with_created_at = self._retrieve_score(recency_weight=0.0, created_at=recent)
        # Scores may differ due to other factors but recency should add 0
        # They should be equal or very close
        assert score_no_created_at == pytest.approx(score_with_created_at, abs=1e-9), (
            "recency_weight=0.0 should produce identical scores regardless of created_at"
        )


# ---------------------------------------------------------------------------
# Explicitness/stability bonuses are configurable
# ---------------------------------------------------------------------------

class TestExplicitnessStabilityBonuses:
    """Verify explicitness and stability bonuses come from config, not hardcoded."""

    def _retrieve_score_for_claim(
        self,
        explicitness: str,
        stability: str,
        explicitness_bonus: float = 0.05,
        stability_bonus: float = 0.03,
    ) -> float:
        claim = MockClaim(
            item_id="claim-bonus-test",
            content="Andrew lives in Bangkok",
            explicitness=explicitness,
            stability=stability,
            # No created_at so recency bonus is 0
        )
        cfg = MockConfig(
            retrieval_recency_weight=0.0,  # disable recency for isolation
            retrieval_explicitness_bonus=explicitness_bonus,
            retrieval_stability_bonus=stability_bonus,
        )
        storage = _make_storage([claim])
        pipeline = RetrievalPipeline(storage=storage, config=cfg)
        result = _run(pipeline.retrieve("test query", owner_id="default"))
        assert len(result.items) == 1
        return result.items[0].score

    def test_explicit_claim_scores_higher_than_inferred(self):
        score_explicit = self._retrieve_score_for_claim("explicit", "volatile")
        score_inferred = self._retrieve_score_for_claim("inferred", "volatile")
        assert score_explicit > score_inferred, (
            f"explicit should score higher: {score_explicit} > {score_inferred}"
        )

    def test_stable_claim_scores_higher_than_volatile(self):
        score_stable = self._retrieve_score_for_claim("inferred", "stable")
        score_volatile = self._retrieve_score_for_claim("inferred", "volatile")
        assert score_stable > score_volatile, (
            f"stable should score higher: {score_stable} > {score_volatile}"
        )

    def test_explicitness_bonus_is_configurable(self):
        """Different explicitness_bonus values produce proportionally different scores."""
        score_small_bonus = self._retrieve_score_for_claim(
            "explicit", "volatile", explicitness_bonus=0.01
        )
        score_large_bonus = self._retrieve_score_for_claim(
            "explicit", "volatile", explicitness_bonus=0.20
        )
        assert score_large_bonus > score_small_bonus, (
            "Larger explicitness_bonus should produce higher score"
        )

    def test_stability_bonus_is_configurable(self):
        """Different stability_bonus values produce proportionally different scores."""
        score_small_bonus = self._retrieve_score_for_claim(
            "inferred", "stable", stability_bonus=0.01
        )
        score_large_bonus = self._retrieve_score_for_claim(
            "inferred", "stable", stability_bonus=0.15
        )
        assert score_large_bonus > score_small_bonus, (
            "Larger stability_bonus should produce higher score"
        )

    def test_zero_bonuses_produce_no_difference(self):
        """With both bonuses=0.0, explicit+stable == inferred+volatile."""
        score_explicit_stable = self._retrieve_score_for_claim(
            "explicit", "stable", explicitness_bonus=0.0, stability_bonus=0.0
        )
        score_inferred_volatile = self._retrieve_score_for_claim(
            "inferred", "volatile", explicitness_bonus=0.0, stability_bonus=0.0
        )
        assert score_explicit_stable == pytest.approx(score_inferred_volatile, abs=1e-6), (
            "Zero bonuses should produce identical scores regardless of explicitness/stability"
        )
