"""
Tests for Issue #376: Promotion Scoring Formula.

Coverage:
  - PromotionScore dataclass and serialization
  - PromotionWeights validation (must sum to 1.0)
  - PromotionScorer.score() — single claim scoring
  - PromotionScorer.score_batch() — batch scoring
  - Individual component computations:
      - evidence_strength
      - cross_session_frequency
      - user_explicitness
      - retrieval_utility
      - temporal_stability
      - contradiction_absence
  - All components at 0 → score 0
  - All components at 1.0 → score 1.0
  - Single component dominance
  - Threshold filtering
  - Weight customization
  - min_sessions gating
  - Integration with S2Promoter (scorer path)

All storage and feedback dependencies are mocked.
Run: python3.14 -m pytest tests/test_promotion.py -x -v
"""

from __future__ import annotations

import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from unittest.mock import AsyncMock, MagicMock

import pytest

from cortex.core.promotion import (
    PromotionScore,
    PromotionScorer,
    PromotionWeights,
)


# ---------------------------------------------------------------------------
# Minimal stub types
# ---------------------------------------------------------------------------

@dataclass
class FakeSemanticClaim:
    id: str
    owner_id: str = "default"
    subject: str = "user"
    predicate: str = "prefers"
    object_value: str = "dark mode"
    memory_type: str = "session"
    confidence: float = 0.6
    status: str = "active"
    access_count: int = 0
    explicitness: str = "inferred"
    source_session: Optional[str] = "sess-1"
    created_at: Optional[str] = None
    updated_at: Optional[str] = None
    superseded_by: Optional[str] = None


def _make_claim(
    claim_id: Optional[str] = None,
    subject: str = "user",
    predicate: str = "prefers",
    object_value: str = "dark mode",
    confidence: float = 0.6,
    access_count: int = 0,
    explicitness: str = "inferred",
    memory_type: str = "session",
    status: str = "active",
    source_session: Optional[str] = "sess-1",
    created_at: Optional[str] = None,
) -> FakeSemanticClaim:
    return FakeSemanticClaim(
        id=claim_id or str(uuid.uuid4()),
        subject=subject,
        predicate=predicate,
        object_value=object_value,
        confidence=confidence,
        access_count=access_count,
        explicitness=explicitness,
        memory_type=memory_type,
        status=status,
        source_session=source_session,
        created_at=created_at or datetime.now(timezone.utc).isoformat(),
    )


def _make_storage(
    subject_claims: Optional[List] = None,
    session_claims: Optional[List] = None,
) -> MagicMock:
    """Build a mock StorageAdapter."""
    storage = MagicMock()
    storage.get_claims_by_subject = AsyncMock(return_value=subject_claims or [])
    storage.get_session_claims = AsyncMock(return_value=session_claims or [])
    storage.update_claim = AsyncMock()
    return storage


def _make_feedback_system(scores: Optional[dict] = None) -> MagicMock:
    """Build a mock FeedbackSystem."""
    fb = MagicMock()

    async def _get_score(memory_id, **kwargs):
        s = MagicMock()
        s.score = (scores or {}).get(memory_id, 0.5)
        return s

    fb.get_score = _get_score
    return fb


# ---------------------------------------------------------------------------
# PromotionWeights tests
# ---------------------------------------------------------------------------

class TestPromotionWeights:
    """Tests for weight validation."""

    def test_default_weights_sum_to_one(self):
        w = PromotionWeights()
        total = (
            w.evidence_strength
            + w.cross_session_frequency
            + w.user_explicitness
            + w.retrieval_utility
            + w.temporal_stability
            + w.contradiction_absence
        )
        assert abs(total - 1.0) < 0.001

    def test_custom_weights_valid(self):
        w = PromotionWeights(
            evidence_strength=0.5,
            cross_session_frequency=0.1,
            user_explicitness=0.1,
            retrieval_utility=0.1,
            temporal_stability=0.1,
            contradiction_absence=0.1,
        )
        assert w.evidence_strength == 0.5

    def test_weights_not_summing_to_one_raises(self):
        with pytest.raises(ValueError, match="must sum to 1.0"):
            PromotionWeights(
                evidence_strength=0.5,
                cross_session_frequency=0.5,
                user_explicitness=0.5,
                retrieval_utility=0.0,
                temporal_stability=0.0,
                contradiction_absence=0.0,
            )


# ---------------------------------------------------------------------------
# PromotionScore tests
# ---------------------------------------------------------------------------

class TestPromotionScore:
    """Tests for the PromotionScore dataclass."""

    def test_to_dict(self):
        ps = PromotionScore(
            claim_id="c1",
            overall=0.75,
            evidence_strength=0.8,
            cross_session_frequency=0.5,
            user_explicitness=1.0,
            retrieval_utility=0.6,
            temporal_stability=0.3,
            contradiction_absence=1.0,
            promoted=True,
            reason="promote:score(0.750>=0.60)",
        )
        d = ps.to_dict()
        assert d["claim_id"] == "c1"
        assert d["overall"] == 0.75
        assert d["promoted"] is True
        assert "evidence_strength" in d["components"]


# ---------------------------------------------------------------------------
# PromotionScorer — component computation tests
# ---------------------------------------------------------------------------

class TestComponentComputations:
    """Tests for individual component computations."""

    def setup_method(self):
        self.scorer = PromotionScorer()

    def test_evidence_strength_zero(self):
        """Zero confidence + zero access → low score."""
        claim = _make_claim(confidence=0.0, access_count=0)
        score = self.scorer._compute_evidence_strength(claim)
        assert score == pytest.approx(0.0, abs=0.01)

    def test_evidence_strength_max(self):
        """Full confidence + high access → 1.0."""
        claim = _make_claim(confidence=1.0, access_count=5)
        score = self.scorer._compute_evidence_strength(claim)
        assert score == pytest.approx(1.0, abs=0.01)

    def test_evidence_strength_mid(self):
        """Mid confidence + some access → mid score."""
        claim = _make_claim(confidence=0.5, access_count=2)
        score = self.scorer._compute_evidence_strength(claim)
        assert 0.3 < score < 0.7

    def test_cross_session_single(self):
        """1 session = 0.0."""
        score = self.scorer._compute_cross_session_frequency(1)
        assert score == 0.0

    def test_cross_session_two(self):
        """2 sessions = 0.333."""
        score = self.scorer._compute_cross_session_frequency(2)
        assert score == pytest.approx(1 / 3, abs=0.01)

    def test_cross_session_many(self):
        """4+ sessions → 1.0."""
        score = self.scorer._compute_cross_session_frequency(4)
        assert score == pytest.approx(1.0, abs=0.01)

    def test_explicitness_explicit(self):
        """Explicit → 1.0."""
        claim = _make_claim(explicitness="explicit")
        score = self.scorer._compute_user_explicitness(claim)
        assert score == 1.0

    def test_explicitness_inferred(self):
        """Inferred → 0.3."""
        claim = _make_claim(explicitness="inferred")
        score = self.scorer._compute_user_explicitness(claim)
        assert score == 0.3

    def test_explicitness_unknown(self):
        """Unknown → 0.5."""
        claim = _make_claim(explicitness="other")
        score = self.scorer._compute_user_explicitness(claim)
        assert score == 0.5

    def test_retrieval_utility_with_feedback(self):
        """With feedback score, uses that directly."""
        claim = _make_claim()
        score = self.scorer._compute_retrieval_utility(claim, feedback_score=0.8)
        assert score == 0.8

    def test_retrieval_utility_no_feedback_no_access(self):
        """No feedback + no access → neutral 0.5."""
        claim = _make_claim(access_count=0)
        score = self.scorer._compute_retrieval_utility(claim, feedback_score=None)
        assert score == 0.5

    def test_retrieval_utility_no_feedback_with_access(self):
        """No feedback + some access → > 0.5."""
        claim = _make_claim(access_count=3)
        score = self.scorer._compute_retrieval_utility(claim, feedback_score=None)
        assert score > 0.5

    def test_temporal_stability_no_created_at(self):
        """No created_at → neutral 0.5."""
        claim = _make_claim(created_at=None)
        # Force None
        claim.created_at = None
        score = self.scorer._compute_temporal_stability(claim)
        assert score == 0.5

    def test_temporal_stability_old_claim(self):
        """Old claim (30 days) → high stability."""
        old_dt = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
        claim = _make_claim(created_at=old_dt)
        score = self.scorer._compute_temporal_stability(claim)
        assert score > 0.9

    def test_temporal_stability_new_claim(self):
        """Brand new claim (just now) → low stability."""
        now = datetime.now(timezone.utc).isoformat()
        claim = _make_claim(created_at=now)
        score = self.scorer._compute_temporal_stability(claim)
        assert score < 0.1

    def test_contradiction_absence_none(self):
        """0 contradictions → 1.0."""
        score = self.scorer._compute_contradiction_absence(0)
        assert score == 1.0

    def test_contradiction_absence_some(self):
        """2 contradictions → 0.6."""
        score = self.scorer._compute_contradiction_absence(2)
        assert score == pytest.approx(0.6, abs=0.01)

    def test_contradiction_absence_many(self):
        """5+ contradictions → 0.0."""
        score = self.scorer._compute_contradiction_absence(5)
        assert score == 0.0
        score = self.scorer._compute_contradiction_absence(10)
        assert score == 0.0


# ---------------------------------------------------------------------------
# PromotionScorer — scoring math tests
# ---------------------------------------------------------------------------

class TestScoringMath:
    """Tests for overall scoring computation."""

    @pytest.mark.asyncio
    async def test_all_components_zero(self):
        """All components at 0 → overall score 0."""
        claim = _make_claim(
            confidence=0.0,
            access_count=0,
            explicitness="inferred",
            created_at=datetime.now(timezone.utc).isoformat(),
        )
        # Storage returns only this claim (1 session)
        storage = _make_storage(subject_claims=[claim])
        # No contradictions possible with 1 claim
        scorer = PromotionScorer(threshold=0.0)

        result = await scorer.score(claim, "sess-1", storage)

        # Evidence: 0.6*0.0 + 0.4*0 = 0.0
        # Cross-session: 1 session = 0.0
        # Explicitness: inferred = 0.3
        # Retrieval: no access = 0.5
        # Stability: brand new ≈ 0.0
        # Contradiction: 0 = 1.0
        # Not exactly zero due to explicitness (0.3) and retrieval (0.5) and
        # contradiction (1.0) having non-zero defaults, but should be low
        assert result.overall < 0.25

    @pytest.mark.asyncio
    async def test_all_components_max(self):
        """All components at max → overall score ≈ 1.0."""
        old_dt = (datetime.now(timezone.utc) - timedelta(days=60)).isoformat()
        claim = _make_claim(
            confidence=1.0,
            access_count=10,
            explicitness="explicit",
            created_at=old_dt,
            source_session="sess-1",
        )

        # Multiple sessions
        claims_multi_session = [
            _make_claim(subject="user", source_session=f"sess-{i}")
            for i in range(5)
        ]
        storage = _make_storage(subject_claims=claims_multi_session)

        # Full feedback score
        fb = _make_feedback_system({claim.id: 1.0})

        scorer = PromotionScorer(feedback_system=fb)
        result = await scorer.score(claim, "sess-1", storage)

        assert result.overall > 0.9
        assert result.promoted is True

    @pytest.mark.asyncio
    async def test_single_component_dominance_evidence(self):
        """When only evidence_strength matters (weight=1.0), score follows evidence."""
        claim = _make_claim(confidence=0.8, access_count=3)
        storage = _make_storage(subject_claims=[claim])

        weights = PromotionWeights(
            evidence_strength=1.0,
            cross_session_frequency=0.0,
            user_explicitness=0.0,
            retrieval_utility=0.0,
            temporal_stability=0.0,
            contradiction_absence=0.0,
        )
        scorer = PromotionScorer(weights=weights, threshold=0.0)
        result = await scorer.score(claim, "sess-1", storage)

        expected_evidence = 0.6 * 0.8 + 0.4 * (3 / 5)
        assert result.overall == pytest.approx(expected_evidence, abs=0.01)
        assert result.evidence_strength == pytest.approx(expected_evidence, abs=0.01)

    @pytest.mark.asyncio
    async def test_single_component_dominance_explicitness(self):
        """Weight all on user_explicitness: explicit claim → score 1.0."""
        claim = _make_claim(explicitness="explicit")
        storage = _make_storage(subject_claims=[claim])

        weights = PromotionWeights(
            evidence_strength=0.0,
            cross_session_frequency=0.0,
            user_explicitness=1.0,
            retrieval_utility=0.0,
            temporal_stability=0.0,
            contradiction_absence=0.0,
        )
        scorer = PromotionScorer(weights=weights)
        result = await scorer.score(claim, "sess-1", storage)
        assert result.overall == pytest.approx(1.0, abs=0.01)

    @pytest.mark.asyncio
    async def test_threshold_filtering(self):
        """Claims below threshold are not promoted."""
        claim = _make_claim(confidence=0.3, access_count=0, explicitness="inferred")
        storage = _make_storage(subject_claims=[claim])

        scorer = PromotionScorer(threshold=0.9)
        result = await scorer.score(claim, "sess-1", storage)

        assert result.promoted is False
        assert "below_threshold" in result.reason

    @pytest.mark.asyncio
    async def test_threshold_exact_boundary(self):
        """Claim scoring exactly at threshold → promoted."""
        # Use explicit-only weight to control the exact score
        claim = _make_claim(explicitness="explicit")
        storage = _make_storage(subject_claims=[claim])

        weights = PromotionWeights(
            evidence_strength=0.0,
            cross_session_frequency=0.0,
            user_explicitness=1.0,
            retrieval_utility=0.0,
            temporal_stability=0.0,
            contradiction_absence=0.0,
        )
        # explicit → 1.0, threshold = 1.0 → exactly at boundary
        scorer = PromotionScorer(weights=weights, threshold=1.0)
        result = await scorer.score(claim, "sess-1", storage)
        assert result.promoted is True

    @pytest.mark.asyncio
    async def test_min_sessions_gate(self):
        """Claims are not promoted if session count < min_sessions."""
        old_dt = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
        claim = _make_claim(
            confidence=1.0,
            access_count=10,
            explicitness="explicit",
            created_at=old_dt,
        )
        storage = _make_storage(subject_claims=[claim])  # 1 session

        scorer = PromotionScorer(threshold=0.1, min_sessions=3)
        result = await scorer.score(claim, "sess-1", storage)

        # High score but gated by min_sessions
        assert result.overall > 0.5
        assert result.promoted is False
        assert "min_sessions" in result.reason


# ---------------------------------------------------------------------------
# Batch scoring tests
# ---------------------------------------------------------------------------

class TestBatchScoring:
    """Tests for batch scoring."""

    @pytest.mark.asyncio
    async def test_batch_empty(self):
        """Empty input → empty output."""
        scorer = PromotionScorer()
        storage = _make_storage()
        results = await scorer.score_batch([], "sess-1", storage)
        assert results == []

    @pytest.mark.asyncio
    async def test_batch_multiple_claims(self):
        """Multiple claims scored in one call."""
        claims = [
            _make_claim(claim_id="c1", confidence=0.9, explicitness="explicit"),
            _make_claim(claim_id="c2", confidence=0.3, explicitness="inferred"),
        ]
        storage = _make_storage(subject_claims=claims)

        scorer = PromotionScorer(threshold=0.5)
        results = await scorer.score_batch(claims, "sess-1", storage)

        assert len(results) == 2
        assert results[0].claim_id == "c1"
        assert results[1].claim_id == "c2"
        # Higher confidence/explicit claim should score higher
        assert results[0].overall > results[1].overall

    @pytest.mark.asyncio
    async def test_batch_with_feedback(self):
        """Batch scoring uses feedback system when available."""
        claim = _make_claim(claim_id="c1")
        storage = _make_storage(subject_claims=[claim])
        fb = _make_feedback_system({"c1": 0.9})

        scorer = PromotionScorer(feedback_system=fb)
        results = await scorer.score_batch([claim], "sess-1", storage)

        assert len(results) == 1
        assert results[0].retrieval_utility == 0.9


# ---------------------------------------------------------------------------
# Weight customization via config
# ---------------------------------------------------------------------------

class TestConfigIntegration:
    """Tests for config-driven weight customization."""

    @pytest.mark.asyncio
    async def test_custom_weights_from_explicit(self):
        """Explicit weights override defaults."""
        weights = PromotionWeights(
            evidence_strength=0.0,
            cross_session_frequency=0.0,
            user_explicitness=0.0,
            retrieval_utility=0.0,
            temporal_stability=0.0,
            contradiction_absence=1.0,
        )
        scorer = PromotionScorer(weights=weights, threshold=0.5)

        # No contradictions → contradiction_absence = 1.0 → overall = 1.0
        claim = _make_claim()
        storage = _make_storage(subject_claims=[claim])

        result = await scorer.score(claim, "sess-1", storage)
        assert result.overall == pytest.approx(1.0, abs=0.01)
        assert result.promoted is True

    @pytest.mark.asyncio
    async def test_custom_threshold(self):
        """Custom threshold changes promotion decisions."""
        claim = _make_claim(confidence=0.5, explicitness="inferred")
        storage = _make_storage(subject_claims=[claim])

        # High threshold → not promoted
        scorer_high = PromotionScorer(threshold=0.99)
        result_high = await scorer_high.score(claim, "sess-1", storage)
        assert result_high.promoted is False

        # Low threshold → promoted
        scorer_low = PromotionScorer(threshold=0.01)
        result_low = await scorer_low.score(claim, "sess-1", storage)
        assert result_low.promoted is True

    def test_properties(self):
        """Scorer exposes threshold and weights properties."""
        weights = PromotionWeights()
        scorer = PromotionScorer(weights=weights, threshold=0.7)
        assert scorer.threshold == 0.7
        assert scorer.weights is weights


# ---------------------------------------------------------------------------
# Integration with S2Promoter (scorer path)
# ---------------------------------------------------------------------------

class TestS2PromoterScorerIntegration:
    """Tests for S2Promoter using the PromotionScorer path."""

    @pytest.mark.asyncio
    async def test_s2_with_scorer_promotes_high_score(self):
        """S2 with scorer promotes claims above threshold."""
        from cortex.sleep.s2_promotion import S2Promoter

        claim = MagicMock()
        claim.id = "c1"
        claim.owner_id = "default"
        claim.memory_type = "session"
        claim.status = "active"
        claim.source_session = "sess-1"
        claim.confidence = 0.8
        claim.subject = "user"
        claim.predicate = "likes"
        claim.object_value = "Python"
        claim.access_count = 3
        claim.explicitness = "explicit"
        claim.created_at = (
            datetime.now(timezone.utc) - timedelta(days=14)
        ).isoformat()

        storage = MagicMock()
        storage.get_session_claims = AsyncMock(return_value=[claim])
        storage.get_claims_by_subject = AsyncMock(return_value=[
            claim,
            # Another claim from a different session
            MagicMock(
                source_session="sess-2",
                status="active",
                predicate="likes",
            ),
        ])
        storage.update_claim = AsyncMock()

        scorer = PromotionScorer(threshold=0.3)

        s2 = S2Promoter(
            storage=storage,
            promotion_scorer=scorer,
        )
        result = await s2.run(session_id="sess-1")

        assert result.claims_reviewed == 1
        assert result.claims_promoted == 1
        assert "c1" in result.promoted_ids

    @pytest.mark.asyncio
    async def test_s2_with_scorer_skips_low_score(self):
        """S2 with scorer skips claims below threshold."""
        from cortex.sleep.s2_promotion import S2Promoter

        claim = MagicMock()
        claim.id = "c1"
        claim.owner_id = "default"
        claim.memory_type = "session"
        claim.status = "active"
        claim.source_session = "sess-1"
        claim.confidence = 0.2
        claim.subject = "user"
        claim.predicate = "maybe"
        claim.object_value = "something"
        claim.access_count = 0
        claim.explicitness = "inferred"
        claim.created_at = datetime.now(timezone.utc).isoformat()

        storage = MagicMock()
        storage.get_session_claims = AsyncMock(return_value=[claim])
        storage.get_claims_by_subject = AsyncMock(return_value=[claim])
        storage.update_claim = AsyncMock()

        scorer = PromotionScorer(threshold=0.9)

        s2 = S2Promoter(
            storage=storage,
            min_confidence=0.1,
            promotion_scorer=scorer,
        )
        result = await s2.run(session_id="sess-1")

        assert result.claims_reviewed == 1
        assert result.claims_promoted == 0
        storage.update_claim.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_s2_without_scorer_uses_legacy(self):
        """S2 without scorer uses legacy rule-based evaluation."""
        from cortex.sleep.s2_promotion import S2Promoter

        claim = MagicMock()
        claim.id = "c1"
        claim.owner_id = "default"
        claim.memory_type = "session"
        claim.status = "active"
        claim.source_session = "sess-1"
        claim.confidence = 0.6
        claim.access_count = 3
        claim.explicitness = "inferred"

        storage = MagicMock()
        storage.get_session_claims = AsyncMock(return_value=[claim])
        storage.update_claim = AsyncMock()

        # No scorer → legacy path
        s2 = S2Promoter(storage=storage, access_threshold=2)
        result = await s2.run(session_id="sess-1")

        assert result.claims_promoted == 1
        assert "c1" in result.promoted_ids
