"""
tests/test_deprecation.py — Tests for M7 Deprecation Pipeline.

Coverage:
  - decay.py: Ebbinghaus curve math (compute_stability, compute_retention, apply_decay)
  - decay.py: reinforcement model (compute_reinforcement)
  - pipeline.py: run_decay() — active→cooling, cooling→deprecated, deprecated→archived
  - pipeline.py: check_reinforcement() — confidence restore, cooling→active revert
  - pipeline.py: review_session() — noise filter path, LLM judge path, cornerstone protection
  - pipeline.py: cornerstone/immutable exemption
  - pipeline.py: deprecation_log written on all transitions
"""

from __future__ import annotations

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

import pytest

from cortex.deprecation.decay import (
    apply_decay,
    compute_reinforcement,
    compute_retention,
    compute_stability,
)
from cortex.deprecation.pipeline import (
    STATUS_ACTIVE,
    STATUS_ARCHIVED,
    STATUS_COOLING,
    STATUS_DEPRECATED,
    DecayReport,
    DeprecationPipeline,
    DeprecationReport,
    ReinforcementReport,
)


# ---------------------------------------------------------------------------
# Helpers & fake types
# ---------------------------------------------------------------------------

def _iso(dt: datetime) -> str:
    return dt.isoformat()


def _days_ago(n: float) -> str:
    return _iso(datetime.now(timezone.utc) - timedelta(days=n))


@dataclass
class FakeClaim:
    """Minimal SemanticClaim stand-in for tests."""
    id: str
    owner_id: str = "default"
    subject: str = "Eva"
    predicate: str = "likes"
    object_value: str = "coffee"
    memory_type: str = "session"
    confidence: float = 0.8
    sensitivity: str = "normal"
    scope: str = "user"
    status: str = STATUS_ACTIVE
    last_accessed: Optional[str] = None
    access_count: int = 0
    source_session: Optional[str] = None
    updated_at: Optional[str] = None
    created_at: Optional[str] = None
    superseded_by: Optional[str] = None
    valid_from: Optional[str] = None
    valid_to: Optional[str] = None
    subject_entity_id: Optional[str] = None
    object_entity_id: Optional[str] = None
    datatype: str = "text"


@dataclass
class FakeCornerstone:
    id: str
    label: str = "identity"
    content: str = "I am Eva"


def _make_pipeline(
    claims_by_status: Dict[str, List[FakeClaim]] = None,
    session_claims: List[FakeClaim] = None,
    cornerstones: List[FakeCornerstone] = None,
    llm=None,
    cooling_days: int = 14,
    archive_days: int = 90,
) -> tuple:
    """Return (pipeline, mock_storage, logged_deprecations)."""

    claims_by_status = claims_by_status or {}
    session_claims = session_claims or []
    cornerstones = cornerstones or []

    log_calls: List[Dict] = []

    claim_store: Dict[str, FakeClaim] = {}
    for status_group in claims_by_status.values():
        for c in status_group:
            claim_store[c.id] = c
    for c in session_claims:
        claim_store[c.id] = c

    async def get_claims_by_status(status, owner_id="default"):
        return list(claims_by_status.get(status, []))

    async def get_session_claims(session_id):
        return list(session_claims)

    async def get_cornerstones(owner_id="default", status="active"):
        return list(cornerstones)

    async def get_claim(claim_id):
        return claim_store.get(claim_id)

    async def update_claim(claim_id, **kwargs):
        c = claim_store.get(claim_id)
        if c is None:
            raise ValueError(f"Claim {claim_id} not found")
        for k, v in kwargs.items():
            if hasattr(c, k):
                setattr(c, k, v)
        return c

    async def log_deprecation(**kwargs):
        log_calls.append(dict(kwargs))

    storage = MagicMock()
    storage.get_claims_by_status = AsyncMock(side_effect=get_claims_by_status)
    storage.get_session_claims = AsyncMock(side_effect=get_session_claims)
    storage.get_cornerstones = AsyncMock(side_effect=get_cornerstones)
    storage.get_claim = AsyncMock(side_effect=get_claim)
    storage.update_claim = AsyncMock(side_effect=update_claim)
    storage.log_deprecation = AsyncMock(side_effect=log_deprecation)

    config = MagicMock()
    config.deprecation_cooling_days = cooling_days
    config.deprecation_archive_days = archive_days
    config.promoted_confidence_default = 0.8

    pipeline = DeprecationPipeline(storage=storage, config=config, llm=llm)
    return pipeline, storage, log_calls


# ===========================================================================
# Tests: decay.py — pure math
# ===========================================================================

class TestComputeStability:
    def test_zero_accesses(self):
        s = compute_stability(0)
        assert s == 1.0

    def test_increases_with_access_count(self):
        assert compute_stability(1) > compute_stability(0)
        assert compute_stability(5) > compute_stability(3)
        assert compute_stability(10) > compute_stability(5)

    def test_capped_at_max(self):
        # Very high access count should be capped
        s = compute_stability(1000)
        assert s == 365.0

    def test_monotonic(self):
        stabilities = [compute_stability(i) for i in range(20)]
        for i in range(len(stabilities) - 1):
            assert stabilities[i] <= stabilities[i + 1]


class TestComputeRetention:
    def test_no_time_passed(self):
        assert compute_retention(0.0, stability=1.0) == 1.0

    def test_decreases_over_time(self):
        r1 = compute_retention(1.0, 1.0)
        r7 = compute_retention(7.0, 1.0)
        assert r1 > r7

    def test_higher_stability_retains_more(self):
        t = 7.0
        r_low = compute_retention(t, stability=1.0)
        r_high = compute_retention(t, stability=7.0)
        assert r_high > r_low

    def test_ebbinghaus_formula(self):
        # R(t) = e^(-t/S)
        t, S = 3.0, 2.0
        expected = math.exp(-t / S)
        assert abs(compute_retention(t, S) - expected) < 1e-9

    def test_clamps_to_zero_at_large_t(self):
        r = compute_retention(1000.0, stability=1.0)
        assert r == 0.0

    def test_clamps_to_one_maximum(self):
        r = compute_retention(-5.0, stability=1.0)
        assert r == 1.0

    def test_zero_stability(self):
        r = compute_retention(1.0, stability=0.0)
        assert r == 0.0


class TestApplyDecay:
    def test_recent_access_no_decay(self):
        now = datetime.now(timezone.utc)
        last_accessed = _iso(now - timedelta(hours=1))
        result = apply_decay("c1", 0.9, access_count=3, last_accessed=last_accessed, now=now)
        assert not result.decayed or result.new_confidence >= 0.88
        assert result.claim_id == "c1"

    def test_stale_memory_decays(self):
        now = datetime.now(timezone.utc)
        last_accessed = _iso(now - timedelta(days=30))
        result = apply_decay("c2", 0.9, access_count=0, last_accessed=last_accessed, now=now)
        assert result.decayed
        assert result.new_confidence < 0.9

    def test_never_accessed_decays_maximally(self):
        now = datetime.now(timezone.utc)
        result = apply_decay("c3", 0.9, access_count=0, last_accessed=None, now=now)
        assert result.new_confidence == 0.0

    def test_reinforced_memory_decays_slower(self):
        now = datetime.now(timezone.utc)
        last_accessed = _iso(now - timedelta(days=14))
        result_low = apply_decay("a", 0.9, access_count=0, last_accessed=last_accessed, now=now)
        result_high = apply_decay("b", 0.9, access_count=10, last_accessed=last_accessed, now=now)
        assert result_high.new_confidence > result_low.new_confidence

    def test_result_fields_populated(self):
        now = datetime.now(timezone.utc)
        last = _iso(now - timedelta(days=5))
        r = apply_decay("x", 0.7, 2, last, now)
        assert r.original_confidence == 0.7
        assert 0.0 <= r.new_confidence <= 0.7
        assert 0.0 <= r.retention <= 1.0
        assert r.stability > 0

    def test_confidence_never_increases(self):
        now = datetime.now(timezone.utc)
        last = _iso(now - timedelta(days=1))
        r = apply_decay("y", 0.5, 0, last, now)
        assert r.new_confidence <= 0.5


class TestComputeReinforcement:
    def test_access_count_increments(self):
        r = compute_reinforcement("c1", 0.5, access_count=2)
        assert r.new_access_count == 3

    def test_confidence_restored(self):
        # Low confidence claim should be partially restored
        r = compute_reinforcement("c1", 0.2, access_count=0, promoted_confidence=0.8)
        assert r.new_confidence > 0.2

    def test_high_confidence_not_reduced(self):
        r = compute_reinforcement("c1", 0.95, access_count=5)
        assert r.new_confidence >= 0.95

    def test_stability_increases(self):
        r = compute_reinforcement("c1", 0.6, access_count=3)
        assert r.stability_after > r.stability_before

    def test_confidence_capped_at_1(self):
        r = compute_reinforcement("c1", 1.0, access_count=100)
        assert r.new_confidence <= 1.0


# ===========================================================================
# Tests: pipeline.py — run_decay()
# ===========================================================================

class TestRunDecay:
    @pytest.mark.asyncio
    async def test_active_becomes_cooling_when_stale(self):
        """Active claim not accessed in >cooling_days should become cooling."""
        claim = FakeClaim(
            id="c1", status=STATUS_ACTIVE,
            last_accessed=_days_ago(20),
            confidence=0.9,
        )
        pipeline, storage, logs = _make_pipeline(
            claims_by_status={STATUS_ACTIVE: [claim]},
            cooling_days=14,
        )
        report = await pipeline.run_decay()
        assert report.transitioned_to_cooling == 1
        assert claim.status == STATUS_COOLING
        assert any(l["action"] == "cooling" for l in logs)

    @pytest.mark.asyncio
    async def test_active_stays_active_if_recently_accessed(self):
        """Active claim accessed recently should not transition."""
        claim = FakeClaim(
            id="c1", status=STATUS_ACTIVE,
            last_accessed=_days_ago(2),
            confidence=0.9,
        )
        pipeline, storage, logs = _make_pipeline(
            claims_by_status={STATUS_ACTIVE: [claim]},
            cooling_days=14,
        )
        report = await pipeline.run_decay()
        assert report.transitioned_to_cooling == 0
        assert claim.status == STATUS_ACTIVE

    @pytest.mark.asyncio
    async def test_cooling_becomes_deprecated_when_low_confidence(self):
        """Cooling claim with heavily decayed confidence should be deprecated."""
        claim = FakeClaim(
            id="c2", status=STATUS_COOLING,
            last_accessed=_days_ago(60),  # 60 days → massive decay with stability=1
            access_count=0,
            confidence=0.9,
        )
        pipeline, storage, logs = _make_pipeline(
            claims_by_status={STATUS_COOLING: [claim]},
        )
        report = await pipeline.run_decay()
        assert report.transitioned_to_deprecated == 1
        assert claim.status == STATUS_DEPRECATED

    @pytest.mark.asyncio
    async def test_cooling_confidence_reduced(self):
        """Cooling claim that decays but not below threshold → confidence reduced."""
        claim = FakeClaim(
            id="c3", status=STATUS_COOLING,
            last_accessed=_days_ago(2),   # recent — retention high
            access_count=5,               # well-reinforced → high stability
            confidence=0.9,
        )
        pipeline, storage, logs = _make_pipeline(
            claims_by_status={STATUS_COOLING: [claim]},
        )
        report = await pipeline.run_decay()
        # Status should remain cooling (not deprecated)
        assert claim.status == STATUS_COOLING

    @pytest.mark.asyncio
    async def test_deprecated_becomes_archived_after_grace(self):
        """Deprecated claim older than archive_days should be archived."""
        claim = FakeClaim(
            id="c4", status=STATUS_DEPRECATED,
            updated_at=_days_ago(100),
            confidence=0.1,
        )
        pipeline, storage, logs = _make_pipeline(
            claims_by_status={STATUS_DEPRECATED: [claim]},
            archive_days=90,
        )
        report = await pipeline.run_decay()
        assert report.transitioned_to_archived == 1
        assert claim.status == STATUS_ARCHIVED

    @pytest.mark.asyncio
    async def test_deprecated_not_archived_in_grace_period(self):
        """Deprecated claim within archive_days should not be archived yet."""
        claim = FakeClaim(
            id="c5", status=STATUS_DEPRECATED,
            updated_at=_days_ago(30),
            confidence=0.1,
        )
        pipeline, storage, logs = _make_pipeline(
            claims_by_status={STATUS_DEPRECATED: [claim]},
            archive_days=90,
        )
        report = await pipeline.run_decay()
        assert report.transitioned_to_archived == 0
        assert claim.status == STATUS_DEPRECATED

    @pytest.mark.asyncio
    async def test_cornerstone_exempt_from_decay(self):
        """Cornerstone memories must never be deprecated."""
        cornerstone = FakeCornerstone(id="cs1")
        claim = FakeClaim(
            id="cs1", status=STATUS_ACTIVE,
            memory_type="cornerstone",
            last_accessed=_days_ago(100),
        )
        pipeline, storage, logs = _make_pipeline(
            claims_by_status={STATUS_ACTIVE: [claim]},
            cornerstones=[cornerstone],
        )
        report = await pipeline.run_decay()
        assert report.transitioned_to_cooling == 0
        assert report.skipped_protected == 1
        assert claim.status == STATUS_ACTIVE

    @pytest.mark.asyncio
    async def test_immutable_memory_type_exempt(self):
        """Claims with memory_type='immutable' must be skipped."""
        claim = FakeClaim(
            id="c6", status=STATUS_ACTIVE,
            memory_type="immutable",
            last_accessed=_days_ago(100),
        )
        pipeline, storage, logs = _make_pipeline(
            claims_by_status={STATUS_ACTIVE: [claim]},
        )
        report = await pipeline.run_decay()
        assert report.skipped_protected >= 1
        assert claim.status == STATUS_ACTIVE

    @pytest.mark.asyncio
    async def test_decay_report_fields(self):
        """DecayReport has correct field types and non-negative values."""
        pipeline, storage, logs = _make_pipeline()
        report = await pipeline.run_decay()
        assert isinstance(report, DecayReport)
        assert report.owner_id == "default"
        assert report.total_scanned >= 0
        assert report.duration_ms >= 0

    @pytest.mark.asyncio
    async def test_deprecation_log_written_on_transitions(self):
        """Every status transition must log to deprecation_log."""
        claim = FakeClaim(
            id="c7", status=STATUS_ACTIVE,
            last_accessed=_days_ago(30),
        )
        pipeline, storage, logs = _make_pipeline(
            claims_by_status={STATUS_ACTIVE: [claim]},
        )
        await pipeline.run_decay()
        assert len(logs) >= 1
        assert all("claim_id" in l and "action" in l for l in logs)

    @pytest.mark.asyncio
    async def test_never_accessed_active_becomes_cooling(self):
        """Active claim with last_accessed=None should enter cooling (treated as very stale)."""
        claim = FakeClaim(
            id="c8", status=STATUS_ACTIVE,
            last_accessed=None,
        )
        pipeline, storage, logs = _make_pipeline(
            claims_by_status={STATUS_ACTIVE: [claim]},
            cooling_days=14,
        )
        report = await pipeline.run_decay()
        assert report.transitioned_to_cooling == 1


# ===========================================================================
# Tests: pipeline.py — check_reinforcement()
# ===========================================================================

class TestCheckReinforcement:
    @pytest.mark.asyncio
    async def test_access_count_incremented(self):
        claim = FakeClaim(id="c1", access_count=3, confidence=0.7, status=STATUS_ACTIVE)
        pipeline, storage, logs = _make_pipeline(session_claims=[claim])
        report = await pipeline.check_reinforcement("c1")
        assert report.new_access_count == 4
        assert report.action == "reinforced"

    @pytest.mark.asyncio
    async def test_cooling_claim_reactivated(self):
        claim = FakeClaim(id="c2", status=STATUS_COOLING, confidence=0.4, access_count=1)
        pipeline, storage, logs = _make_pipeline(session_claims=[claim])
        await pipeline.check_reinforcement("c2")
        assert claim.status == STATUS_ACTIVE

    @pytest.mark.asyncio
    async def test_confidence_restored_if_decayed(self):
        claim = FakeClaim(id="c3", confidence=0.2, access_count=0, status=STATUS_ACTIVE)
        pipeline, storage, logs = _make_pipeline(session_claims=[claim])
        report = await pipeline.check_reinforcement("c3")
        assert report.new_confidence > report.old_confidence

    @pytest.mark.asyncio
    async def test_not_found_claim(self):
        pipeline, storage, logs = _make_pipeline()
        report = await pipeline.check_reinforcement("nonexistent_id")
        assert report.action == "not_found"

    @pytest.mark.asyncio
    async def test_cornerstone_not_reinforced(self):
        """Cornerstones skip reinforcement (protected)."""
        claim = FakeClaim(id="cs1", memory_type="cornerstone", confidence=0.9, access_count=0)
        pipeline, storage, logs = _make_pipeline(session_claims=[claim])
        report = await pipeline.check_reinforcement("cs1")
        assert report.action == "protected"
        assert claim.access_count == 0

    @pytest.mark.asyncio
    async def test_reinforcement_logged(self):
        claim = FakeClaim(id="c4", confidence=0.7, access_count=2, status=STATUS_ACTIVE)
        pipeline, storage, logs = _make_pipeline(session_claims=[claim])
        await pipeline.check_reinforcement("c4")
        assert any(l.get("action") == "reinforced" for l in logs)


# ===========================================================================
# Tests: pipeline.py — review_session()
# ===========================================================================

class TestReviewSession:
    @pytest.mark.asyncio
    async def test_noise_claim_deprecated(self):
        """Coding noise should be immediately deprecated without LLM."""
        noise_claim = FakeClaim(
            id="n1",
            subject="",
            predicate="",
            object_value="git commit -m 'fix bug'\nInstalling packages...\n+++ diff output",
        )
        pipeline, storage, logs = _make_pipeline(session_claims=[noise_claim])
        report = await pipeline.review_session("sess_1")
        assert report.total_reviewed == 1
        assert report.auto_noise == 1
        assert noise_claim.status == STATUS_DEPRECATED

    @pytest.mark.asyncio
    async def test_clean_claim_kept(self):
        """Clear signal content should be kept without LLM."""
        signal_claim = FakeClaim(
            id="s1",
            subject="Andrew",
            predicate="decided",
            object_value="to deploy on fly.io because it is cheaper at scale for this use case and the team prefers it",
        )
        pipeline, storage, logs = _make_pipeline(session_claims=[signal_claim])
        report = await pipeline.review_session("sess_2")
        assert report.total_reviewed == 1
        # Should be kept (not deprecated)
        assert signal_claim.status != STATUS_DEPRECATED

    @pytest.mark.asyncio
    async def test_cornerstone_skipped(self):
        """Cornerstone claims must be skipped in session review."""
        cs_claim = FakeClaim(
            id="cs1",
            memory_type="cornerstone",
            object_value="I am Eva, Andrew's AI companion",
        )
        cornerstone = FakeCornerstone(id="cs1")
        pipeline, storage, logs = _make_pipeline(
            session_claims=[cs_claim],
            cornerstones=[cornerstone],
        )
        report = await pipeline.review_session("sess_cs")
        # Should not be deprecated
        assert cs_claim.status != STATUS_DEPRECATED
        assert cs_claim.status == STATUS_ACTIVE

    @pytest.mark.asyncio
    async def test_llm_promote_applied(self):
        """When LLM says PROMOTE, claim should become semantic with higher confidence."""
        claim = FakeClaim(
            id="llm1",
            subject="Eva",
            predicate="prefers",
            object_value="fly.io",
            memory_type="session",
            confidence=0.5,
        )

        mock_llm = AsyncMock()
        mock_llm.complete = AsyncMock(
            return_value='[{"claim_id": "llm1", "action": "PROMOTE", "reason": "User preference"}]'
        )

        pipeline, storage, logs = _make_pipeline(
            session_claims=[claim],
            llm=mock_llm,
        )
        report = await pipeline.review_session("sess_llm")
        assert report.promoted == 1
        assert claim.memory_type == "semantic"

    @pytest.mark.asyncio
    async def test_llm_discard_applied(self):
        """When LLM says DISCARD, claim should be deprecated."""
        claim = FakeClaim(
            id="llm2",
            subject="",
            predicate="note",
            object_value="temp value",
            memory_type="session",
        )

        mock_llm = AsyncMock()
        mock_llm.complete = AsyncMock(
            return_value='[{"claim_id": "llm2", "action": "DISCARD", "reason": "Temporary working note"}]'
        )

        pipeline, storage, logs = _make_pipeline(
            session_claims=[claim],
            llm=mock_llm,
        )
        report = await pipeline.review_session("sess_llm2")
        assert report.deprecated >= 1
        assert claim.status == STATUS_DEPRECATED

    @pytest.mark.asyncio
    async def test_llm_keep_session_applied(self):
        """When LLM says KEEP_SESSION, claim should remain active."""
        claim = FakeClaim(
            id="llm3",
            subject="session",
            predicate="context",
            object_value="working on issue 22 today",
            memory_type="session",
        )

        mock_llm = AsyncMock()
        mock_llm.complete = AsyncMock(
            return_value='[{"claim_id": "llm3", "action": "KEEP_SESSION", "reason": "Session context"}]'
        )

        pipeline, storage, logs = _make_pipeline(
            session_claims=[claim],
            llm=mock_llm,
        )
        report = await pipeline.review_session("sess_llm3")
        assert report.kept_session >= 1
        assert claim.status != STATUS_DEPRECATED

    @pytest.mark.asyncio
    async def test_llm_unavailable_defaults_to_keep(self):
        """When LLM is None, borderline 'maybe' claims default to keep_session."""
        claim = FakeClaim(
            id="m1",
            subject="a",
            predicate="b",
            object_value="c d e",  # short — classified as 'maybe'
        )
        pipeline, storage, logs = _make_pipeline(session_claims=[claim], llm=None)
        report = await pipeline.review_session("sess_nollm")
        assert claim.status != STATUS_DEPRECATED

    @pytest.mark.asyncio
    async def test_report_fields(self):
        """DeprecationReport has correct types."""
        pipeline, storage, logs = _make_pipeline()
        report = await pipeline.review_session("sess_empty")
        assert isinstance(report, DeprecationReport)
        assert report.session_id == "sess_empty"
        assert report.total_reviewed == 0
        assert report.duration_ms >= 0

    @pytest.mark.asyncio
    async def test_immutable_skipped_in_review(self):
        """Immutable claims must not be deprecated during session review."""
        claim = FakeClaim(
            id="imm1",
            memory_type="immutable",
            object_value="git commit -m 'hack'",  # looks like noise
        )
        pipeline, storage, logs = _make_pipeline(session_claims=[claim])
        report = await pipeline.review_session("sess_imm")
        assert claim.status != STATUS_DEPRECATED
