"""Tests for memory decay behavior."""

import pytest
import tempfile
import os
from datetime import datetime

from remind.models import Concept, Episode, Relation, RelationType
from remind.store import SQLiteMemoryStore
from remind.config import DecayConfig


class TestConceptDecayFields:
    """Tests for decay fields in Concept model."""

    def test_decay_fields_exist_and_default_correctly(self):
        """Verify decay fields exist with correct default values."""
        concept = Concept(summary="Test concept")
        
        assert concept.last_accessed is None
        assert concept.access_count == 0
        assert concept.decay_factor == 1.0

    def test_decay_fields_serialize_correctly(self):
        """Test decay fields serialize to dict correctly."""
        now = datetime.now()
        concept = Concept(
            summary="Test concept",
            last_accessed=now,
            access_count=5,
            decay_factor=0.7,
        )
        
        data = concept.to_dict()
        
        assert data["last_accessed"] == now.isoformat()
        assert data["access_count"] == 5
        assert data["decay_factor"] == 0.7

    def test_decay_fields_deserialize_correctly(self):
        """Test decay fields deserialize from dict correctly."""
        data = {
            "id": "test-id",
            "summary": "Test concept",
            "created_at": datetime.now().isoformat(),
            "updated_at": datetime.now().isoformat(),
            "last_accessed": "2024-01-15T10:30:00",
            "access_count": 3,
            "decay_factor": 0.5,
        }
        
        concept = Concept.from_dict(data)
        
        assert concept.last_accessed == datetime.fromisoformat("2024-01-15T10:30:00")
        assert concept.access_count == 3
        assert concept.decay_factor == 0.5

    def test_backwards_compatibility_missing_decay_fields(self):
        """Verify old concepts without decay fields load with defaults."""
        data = {
            "id": "old-id",
            "summary": "Old concept",
            "created_at": datetime.now().isoformat(),
            "updated_at": datetime.now().isoformat(),
            # No decay fields - should use defaults
        }
        
        concept = Concept.from_dict(data)
        
        assert concept.last_accessed is None
        assert concept.access_count == 0
        assert concept.decay_factor == 1.0


class TestDecayAlgorithm:
    """Tests for the decay algorithm."""

    @pytest.fixture
    def store(self):
        """Create a temporary store for testing."""
        fd, path = tempfile.mkstemp(suffix=".db")
        os.close(fd)
        store = SQLiteMemoryStore(path)
        yield store
        os.unlink(path)

    def test_linear_decay_formula(self, store):
        """Test linear decay formula: new = max(0, old - rate)."""
        concept = Concept(
            id="decay-test",
            summary="Test decay",
            decay_factor=0.8,
            embedding=[0.1] * 128,
        )
        store.add_concept(concept)
        
        # Apply decay with rate 0.1
        store.decay_concepts(decay_rate=0.1)
        
        updated = store.get_concept("decay-test")
        assert abs(updated.decay_factor - 0.7) < 0.001  # 0.8 - 0.1 = 0.7 (with float tolerance)

    def test_decay_clamps_at_zero(self, store):
        """Test that decay_factor doesn't go below 0."""
        concept = Concept(
            id="low-decay",
            summary="Low decay test",
            decay_factor=0.05,
            embedding=[0.1] * 128,
        )
        store.add_concept(concept)
        
        # Apply large decay
        store.decay_concepts(decay_rate=0.5)
        
        updated = store.get_concept("low-decay")
        assert updated.decay_factor == 0.0  # max(0, 0.05 - 0.5) = 0.0

    def test_decay_applies_to_all_concepts(self, store):
        """Test that decay applies to all concepts in store."""
        for i in range(3):
            store.add_concept(
                Concept(
                    id=f"concept-{i}",
                    summary=f"Concept {i}",
                    decay_factor=0.9,
                    embedding=[0.1] * 128,
                )
            )
        
        store.decay_concepts(decay_rate=0.1)
        
        for i in range(3):
            updated = store.get_concept(f"concept-{i}")
            assert updated.decay_factor == 0.8

    def test_related_concepts_decay_identically(self, store):
        """Test that related concepts decay at the same rate as unrelated ones.

        Related-concept extra decay was removed to eliminate the double-decay bug.
        All concepts decay uniformly by decay_rate regardless of graph structure.
        """
        c1 = Concept(
            id="parent",
            summary="Parent concept",
            decay_factor=0.9,
            embedding=[0.1] * 128,
        )
        c2 = Concept(
            id="child",
            summary="Child concept",
            decay_factor=0.9,
            embedding=[0.2] * 128,
        )
        
        c1.relations.append(Relation(
            type=RelationType.IMPLIES,
            target_id="child",
            strength=0.8,
        ))
        
        store.add_concept(c1)
        store.add_concept(c2)
        
        store.decay_concepts(decay_rate=0.1)
        
        parent = store.get_concept("parent")
        child = store.get_concept("child")
        
        # Both decay by exactly decay_rate — graph structure has no effect
        assert abs(parent.decay_factor - 0.8) < 0.001  # 0.9 - 0.1
        assert abs(child.decay_factor - 0.8) < 0.001   # 0.9 - 0.1 (not 0.9 - 0.1 - 0.05)

    def test_decay_accumulates_across_multiple_calls(self, store):
        """Test that calling decay multiple times applies it multiple times."""
        concept = Concept(
            id="multi-decay",
            summary="Multi decay test",
            decay_factor=1.0,
            embedding=[0.1] * 128,
        )
        store.add_concept(concept)
        
        # Apply decay twice
        store.decay_concepts(decay_rate=0.1)
        store.decay_concepts(decay_rate=0.1)
        
        updated = store.get_concept("multi-decay")
        assert updated.decay_factor == 0.8  # 1.0 - 0.1 - 0.1

    def test_recently_accessed_concept_skipped(self, store):
        """Test that concepts with a recent last_accessed are skipped during decay."""
        from datetime import datetime

        concept = Concept(
            id="recent",
            summary="Recently accessed",
            decay_factor=1.0,
            last_accessed=datetime.now(),  # just accessed
            embedding=[0.1] * 128,
        )
        store.add_concept(concept)

        # Grace window of 60s — the concept was just accessed, so it should be skipped
        store.decay_concepts(decay_rate=0.1, skip_recently_accessed_seconds=60)

        updated = store.get_concept("recent")
        assert updated.decay_factor == 1.0  # untouched

    def test_old_accessed_concept_decays(self, store):
        """Test that concepts outside the grace window are decayed normally."""
        from datetime import datetime, timedelta

        concept = Concept(
            id="old",
            summary="Old access",
            decay_factor=1.0,
            last_accessed=datetime.now() - timedelta(hours=2),  # well outside window
            embedding=[0.1] * 128,
        )
        store.add_concept(concept)

        store.decay_concepts(decay_rate=0.1, skip_recently_accessed_seconds=60)

        updated = store.get_concept("old")
        assert abs(updated.decay_factor - 0.9) < 0.001  # 1.0 - 0.1


class TestRejuvenation:
    """Tests for concept rejuvenation when recalled."""

    @pytest.fixture
    def store(self):
        """Create a temporary store for testing."""
        fd, path = tempfile.mkstemp(suffix=".db")
        os.close(fd)
        store = SQLiteMemoryStore(path)
        yield store
        os.unlink(path)

    def test_rejuvenation_resets_decay_factor(self, store):
        """Test that rejuvenation resets decay_factor to 1.0."""
        concept = Concept(
            id="rejuvenate-test",
            summary="Test rejuvenation",
            decay_factor=0.5,
            access_count=3,
            last_accessed=datetime(2024, 1, 1, 10, 0, 0),
            embedding=[0.1] * 128,
        )
        store.add_concept(concept)
        
        # Simulate rejuvenation (what happens in recall)
        concept = store.get_concept("rejuvenate-test")
        concept.decay_factor = 1.0
        concept.access_count += 1
        concept.last_accessed = datetime.now()
        concept.updated_at = datetime.now()
        store.update_concept(concept)
        
        updated = store.get_concept("rejuvenate-test")
        assert updated.decay_factor == 1.0
        assert updated.access_count == 4
        assert updated.last_accessed is not None

    def test_rejuvenation_updates_timestamp(self, store):
        """Test that rejuvenation updates last_accessed timestamp."""
        old_time = datetime(2024, 1, 1, 10, 0, 0)
        concept = Concept(
            id="time-test",
            summary="Time test",
            decay_factor=0.7,
            last_accessed=old_time,
            embedding=[0.1] * 128,
        )
        store.add_concept(concept)
        
        # Simulate rejuvenation
        concept = store.get_concept("time-test")
        concept.decay_factor = 1.0
        concept.last_accessed = datetime.now()
        concept.updated_at = datetime.now()
        store.update_concept(concept)
        
        updated = store.get_concept("time-test")
        assert updated.last_accessed > old_time
        assert updated.decay_factor == 1.0


class TestPeriodicDecay:
    """Tests for periodic decay triggering."""

    @pytest.fixture
    def store(self):
        """Create a temporary store for testing."""
        fd, path = tempfile.mkstemp(suffix=".db")
        os.close(fd)
        store = SQLiteMemoryStore(path)
        yield store
        os.unlink(path)

    def test_decay_triggers_at_interval(self, store):
        """Test that decay triggers every N recalls."""
        decay_rate = 0.1
        
        # Add concept with high decay_factor
        concept = Concept(
            id="interval-test",
            summary="Interval test",
            decay_factor=1.0,
            embedding=[0.1] * 128,
        )
        store.add_concept(concept)
        
        # Simulate recalls - decay should trigger at recall 5
        recall_count = 0
        
        # First 4 recalls - no decay
        for i in range(4):
            recall_count += 1
            if recall_count % 5 == 0:
                store.decay_concepts(decay_rate=decay_rate)
        
        concept = store.get_concept("interval-test")
        assert concept.decay_factor == 1.0  # No decay yet
        
        # 5th recall - decay triggers
        recall_count += 1
        if recall_count % 5 == 0:
            store.decay_concepts(decay_rate=decay_rate)
        
        concept = store.get_concept("interval-test")
        assert concept.decay_factor == 0.9  # 1.0 - 0.1


class TestDecayInRetrieval:
    """Tests for decay affecting retrieval scores."""

    @pytest.fixture
    def store(self):
        """Create a temporary store for testing."""
        fd, path = tempfile.mkstemp(suffix=".db")
        os.close(fd)
        store = SQLiteMemoryStore(path)
        yield store
        os.unlink(path)

    def test_decayed_concepts_have_lower_activation(self, store):
        """Test that decayed concepts have lower retrieval activation."""
        # Create two concepts with same embedding but different decay
        c1 = Concept(
            id="fresh",
            summary="Fresh concept",
            decay_factor=1.0,
            confidence=0.9,
            embedding=[1.0, 0.0, 0.0] + [0.0] * 125,
        )
        c2 = Concept(
            id="decayed",
            summary="Decayed concept",
            decay_factor=0.5,
            confidence=0.9,
            embedding=[1.0, 0.0, 0.0] + [0.0] * 125,
        )
        
        store.add_concept(c1)
        store.add_concept(c2)
        
        # Find by embedding
        results = store.find_by_embedding([1.0, 0.0, 0.0] + [0.0] * 125, k=2)
        
        # Both should have same similarity (same embedding)
        # Decay is applied during retrieval, not in store.find_by_embedding
        # So we test the retrieval logic directly
        from remind.retrieval import MemoryRetriever
        from tests.conftest import MockEmbeddingProvider
        
        mock_embedding = MockEmbeddingProvider(dimensions=128)
        mock_embedding.set_embedding("test query", [1.0, 0.0, 0.0] + [0.0] * 125)
        
        retriever = MemoryRetriever(
            embedding=mock_embedding,
            store=store,
            initial_k=10,
            activation_threshold=0.0,  # Include all results
        )
        
        import asyncio
        activated = asyncio.run(retriever.retrieve("test query", k=2))
        
        # Fresh concept should have higher activation
        fresh_result = next((a for a in activated if a.concept.id == "fresh"), None)
        decayed_result = next((a for a in activated if a.concept.id == "decayed"), None)
        
        assert fresh_result is not None
        assert decayed_result is not None
        assert fresh_result.activation > decayed_result.activation

    def test_retrieval_applies_decay_factor(self, store):
        """Test that retrieval multiplies activation by decay_factor."""
        concept = Concept(
            id="decay-check",
            summary="Decay check",
            decay_factor=0.6,
            confidence=1.0,
            embedding=[1.0, 0.0, 0.0] + [0.0] * 125,
        )
        store.add_concept(concept)
        
        from remind.retrieval import MemoryRetriever
        from tests.conftest import MockEmbeddingProvider
        
        mock_embedding = MockEmbeddingProvider(dimensions=128)
        mock_embedding.set_embedding("test", [1.0, 0.0, 0.0] + [0.0] * 125)
        
        retriever = MemoryRetriever(
            embedding=mock_embedding,
            store=store,
            activation_threshold=0.0,
        )
        
        import asyncio
        activated = asyncio.run(retriever.retrieve("test", k=1))
        
        assert len(activated) == 1
        # Activation should be weighted by decay_factor (0.6)
        assert activated[0].activation <= 0.6


class TestDecayConfig:
    """Tests for DecayConfig dataclass."""

    def test_decay_config_defaults(self):
        """Test DecayConfig has correct defaults."""
        from remind.config import DecayConfig
        
        config = DecayConfig()
        
        assert config.enabled is True
        assert config.decay_interval == 20
        assert config.decay_rate == 0.1

    def test_decay_config_custom_values(self):
        """Test DecayConfig with custom values."""
        from remind.config import DecayConfig
        
        config = DecayConfig(
            enabled=False,
            decay_interval=10,
            decay_rate=0.2,
        )
        
        assert config.enabled is False
        assert config.decay_interval == 10
        assert config.decay_rate == 0.2