"""Audit: working memory eviction uses salience not just LRU.

Verifies that _evict() scores items by salience*recency (not pure LRU),
so high-salience old items survive over low-salience new items.
"""
import json
import time
from datetime import datetime, timezone, timedelta
from pathlib import Path
import pytest


# ---------------------------------------------------------------------------
# Helpers — replicate _evict logic locally so tests are self-contained
# ---------------------------------------------------------------------------

def _recency_weight(iso_ts: str) -> float:
    try:
        ts = datetime.fromisoformat(iso_ts)
        if ts.tzinfo is None:
            ts = ts.replace(tzinfo=timezone.utc)
        age_s = (datetime.now(timezone.utc) - ts).total_seconds()
        return max(0.1, 1.0 - (age_s / 14400) * 0.9)
    except Exception:
        return 0.5


def _make_ts(seconds_ago: float) -> str:
    return (datetime.now(timezone.utc) - timedelta(seconds=seconds_ago)).isoformat()


def _make_item(text: str, salience: float, seconds_ago: float = 0,
               priority_locked: bool = False) -> dict:
    ts = _make_ts(seconds_ago)
    item = {
        "text": text,
        "salience": salience,
        "first_accessed": ts,
        "last_accessed": ts,
        "accessed_count": 1,
        "fingerprint": text[:12],
    }
    if priority_locked:
        item["priority_locked"] = True
    return item


def _evict(session: dict):
    """Exact copy of working_memory._evict — tested independently."""
    items = session.get("items", [])
    cap = session.get("max_items", 20)

    locked = [it for it in items if it.get("priority_locked")]
    unlocked = [it for it in items if not it.get("priority_locked")]

    for it in unlocked:
        it["_es"] = float(it.get("salience", 1.0)) * _recency_weight(
            it.get("last_accessed", it.get("first_accessed", "")))

    unlocked.sort(key=lambda x: x.get("_es", 0), reverse=True)
    keep_unlocked = max(0, cap - len(locked))
    kept = locked + unlocked[:keep_unlocked]

    for i, it in enumerate(kept):
        it["slot_type"] = "active" if i < 9 else "background"
        it.pop("_es", None)

    session["items"] = kept


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------

class TestEvictionUsesSSalience:
    """High-salience items survive over low-salience ones regardless of recency."""

    def test_high_salience_old_item_beats_low_salience_new_item(self):
        """An item with salience=3 accessed 1h ago should beat salience=0.1 accessed now."""
        old_high = _make_item("old_but_important", salience=3.0, seconds_ago=3600)
        new_low  = _make_item("new_but_irrelevant", salience=0.1, seconds_ago=0)

        session = {"items": [old_high, new_low], "max_items": 1}
        _evict(session)

        texts = [it["text"] for it in session["items"]]
        assert "old_but_important" in texts, (
            "High-salience item should survive over low-salience item even if older"
        )
        assert "new_but_irrelevant" not in texts, (
            "Low-salience recent item should be evicted when capacity=1"
        )

    def test_low_salience_item_evicted_over_high_salience(self):
        """Fill to capacity, lowest salience*recency is evicted."""
        items = [
            _make_item("hi_s1", salience=2.5, seconds_ago=100),
            _make_item("hi_s2", salience=2.0, seconds_ago=200),
            _make_item("lo_s",  salience=0.2, seconds_ago=10),   # low salience, recent
        ]
        session = {"items": items, "max_items": 2}
        _evict(session)

        texts = [it["text"] for it in session["items"]]
        assert "lo_s" not in texts, "Low-salience item should be evicted despite being recent"
        assert "hi_s1" in texts and "hi_s2" in texts

    def test_pure_lru_would_fail_this(self):
        """Demonstrates pure LRU (evict oldest) would make wrong choice here."""
        # Pure LRU keeps newest: new_low (0 s old), evicts old_high (3600 s old).
        # Salience-aware should keep old_high (salience=5.0).
        old_high = _make_item("critical_insight", salience=5.0, seconds_ago=3600)
        new_low  = _make_item("trivial_noise",    salience=0.05, seconds_ago=1)

        # Compute eviction scores manually
        score_old = old_high["salience"] * _recency_weight(old_high["last_accessed"])
        score_new = new_low["salience"]  * _recency_weight(new_low["last_accessed"])

        assert score_old > score_new, (
            f"Salience-weighted score of old high-salience ({score_old:.3f}) must beat "
            f"new low-salience ({score_new:.3f}). Pure LRU would evict the old one — wrong."
        )

    def test_equal_salience_prefers_recent(self):
        """When salience is equal, recency decides (partial LRU behaviour is correct)."""
        old_item = _make_item("same_sal_old", salience=1.0, seconds_ago=7200)
        new_item = _make_item("same_sal_new", salience=1.0, seconds_ago=60)

        session = {"items": [old_item, new_item], "max_items": 1}
        _evict(session)

        texts = [it["text"] for it in session["items"]]
        assert "same_sal_new" in texts, "Among equal-salience items, the newer should survive"
        assert "same_sal_old" not in texts

    def test_salience_ordering_preserved_at_capacity(self):
        """Items kept in strict salience*recency order when all have same age."""
        items = [_make_item(f"item_{s}", salience=s, seconds_ago=100)
                 for s in [0.5, 1.0, 1.5, 2.0, 2.5]]
        session = {"items": items, "max_items": 3}
        _evict(session)

        texts = [it["text"] for it in session["items"]]
        assert "item_2.5" in texts
        assert "item_2.0" in texts
        assert "item_1.5" in texts
        assert "item_0.5" not in texts
        assert "item_1.0" not in texts


class TestPriorityLockedItemsNeverEvicted:
    """priority_locked items are always preserved regardless of salience or capacity."""

    def test_locked_item_survives_full_eviction(self):
        locked = _make_item("must_keep", salience=0.01, priority_locked=True)
        others = [_make_item(f"other_{i}", salience=2.0) for i in range(5)]
        session = {"items": [locked] + others, "max_items": 3}
        _evict(session)

        texts = [it["text"] for it in session["items"]]
        assert "must_keep" in texts, "priority_locked items must never be evicted"

    def test_locked_items_count_against_capacity_for_unlocked(self):
        """3 locked items + cap=4 → only 1 unlocked slot."""
        locked_items = [_make_item(f"lock_{i}", salience=0.1, priority_locked=True)
                        for i in range(3)]
        unlocked = [_make_item(f"free_{i}", salience=float(i + 1)) for i in range(5)]
        session = {"items": locked_items + unlocked, "max_items": 4}
        _evict(session)

        all_texts = [it["text"] for it in session["items"]]
        kept_free = [t for t in all_texts if t.startswith("free_")]
        assert len(kept_free) == 1, "Only 1 unlocked slot when 3 locked occupy cap=4"
        assert "free_4" in kept_free, "Highest-salience unlocked item should fill the slot"

    def test_all_locked_no_unlocked_evicted(self):
        locked_items = [_make_item(f"lock_{i}", salience=0.5, priority_locked=True)
                        for i in range(3)]
        session = {"items": locked_items, "max_items": 3}
        _evict(session)
        assert len(session["items"]) == 3


class TestMillersLawSlotAssignment:
    """_evict assigns slot_type 'active' (first 9) or 'background' (rest)."""

    def test_first_9_are_active(self):
        items = [_make_item(f"item_{i}", salience=float(20 - i)) for i in range(20)]
        session = {"items": items, "max_items": 20}
        _evict(session)

        active = [it for it in session["items"] if it.get("slot_type") == "active"]
        background = [it for it in session["items"] if it.get("slot_type") == "background"]
        assert len(active) == 9
        assert len(background) == 11

    def test_active_slots_have_highest_scores(self):
        """The 9 active slots should all have higher salience than any background slot."""
        items = [_make_item(f"item_{i}", salience=float(i + 1), seconds_ago=100)
                 for i in range(20)]
        session = {"items": items, "max_items": 20}
        _evict(session)

        active_sal   = [it["salience"] for it in session["items"] if it["slot_type"] == "active"]
        bg_sal       = [it["salience"] for it in session["items"] if it["slot_type"] == "background"]
        assert min(active_sal) > max(bg_sal), (
            "Every active slot should have higher salience than any background slot"
        )

    def test_fewer_than_9_items_all_active(self):
        items = [_make_item(f"item_{i}", salience=1.0) for i in range(5)]
        session = {"items": items, "max_items": 20}
        _evict(session)

        for it in session["items"]:
            assert it["slot_type"] == "active"

    def test_tmp_es_key_removed_after_eviction(self):
        """Internal _es scoring key must not leak into saved session data."""
        items = [_make_item(f"item_{i}", salience=float(i + 1)) for i in range(5)]
        session = {"items": items, "max_items": 20}
        _evict(session)

        for it in session["items"]:
            assert "_es" not in it, "_es is a temp key and must be cleaned up"


class TestRecencyWeightFunction:
    """Verify _recency_weight produces correct decay values."""

    def test_very_fresh_item_near_1(self):
        ts = _make_ts(seconds_ago=1)
        w = _recency_weight(ts)
        assert w > 0.99, f"1-second-old item should have weight ~1.0, got {w}"

    def test_4h_old_item_near_floor(self):
        ts = _make_ts(seconds_ago=14400)
        w = _recency_weight(ts)
        assert w <= 0.11, f"4h-old item should be near floor 0.1, got {w}"

    def test_weight_never_below_floor(self):
        ts = _make_ts(seconds_ago=86400)  # 24h
        w = _recency_weight(ts)
        assert w >= 0.1, "Weight must never drop below 0.1"

    def test_invalid_ts_returns_fallback(self):
        w = _recency_weight("not-a-timestamp")
        assert w == 0.5


class TestEvictionIntegration:
    """Integration: import and call working_memory._evict directly."""

    def test_import_working_memory(self):
        try:
            from pulse.brain import working_memory  # noqa: F401
        except ImportError as e:
            pytest.fail(f"Could not import pulse.brain.memory.working_memory: {e}")

    def test_evict_imported_function_salience_aware(self):
        from pulse.brain.memory.working_memory import _evict as real_evict, _recency_weight as real_rw

        now_ts = datetime.now(timezone.utc).isoformat()
        old_ts = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()

        items = [
            {"text": "high_sal_old", "salience": 4.0, "last_accessed": old_ts,
             "first_accessed": old_ts, "fingerprint": "aaa", "accessed_count": 1},
            {"text": "low_sal_new",  "salience": 0.1, "last_accessed": now_ts,
             "first_accessed": now_ts, "fingerprint": "bbb", "accessed_count": 1},
        ]
        session = {"items": items, "max_items": 1}
        real_evict(session)

        kept = [it["text"] for it in session["items"]]
        assert "high_sal_old" in kept, (
            "Real _evict must keep high-salience item over low-salience item"
        )

    def test_real_add_to_session_deduplicates_by_fingerprint(self, tmp_path, monkeypatch):
        """Duplicate items are merged (accessed_count incremented) not doubled."""
        import pulse.brain.memory.working_memory as wm
        monkeypatch.setattr(wm, "WORKING_MEMORY_DIR", tmp_path / "wm")
        monkeypatch.setattr(wm, "COMPLETED_DIR", tmp_path / "completed")

        hits = [{"text": "brain first principle", "salience": 1.5, "source": "test"}]
        wm._add_to_session("test_agent", hits)
        wm._add_to_session("test_agent", hits)  # second add — same item

        session = wm._load_session("test_agent")
        matching = [it for it in session["items"] if "brain first" in it["text"]]
        assert len(matching) == 1, "Duplicate fingerprints must be merged, not doubled"
        assert matching[0]["accessed_count"] == 2

    def test_real_evict_respects_salience_over_20_items(self, tmp_path, monkeypatch):
        """Add 25 items of varying salience; only top-20 survive after eviction."""
        import pulse.brain.memory.working_memory as wm
        monkeypatch.setattr(wm, "WORKING_MEMORY_DIR", tmp_path / "wm")
        monkeypatch.setattr(wm, "COMPLETED_DIR", tmp_path / "completed")

        # Items with salience 1..25; all same age so recency doesn't differentiate
        hits = [{"text": f"item_{i:02d}", "salience": float(i), "source": "test"}
                for i in range(1, 26)]
        wm._add_to_session("test_agent2", hits)

        session = wm._load_session("test_agent2")
        kept_texts = {it["text"] for it in session["items"]}
        # Top 20 by salience = items 6..25
        for i in range(6, 26):
            assert f"item_{i:02d}" in kept_texts, f"item_{i:02d} (salience={i}) should be kept"
        for i in range(1, 6):
            assert f"item_{i:02d}" not in kept_texts, f"item_{i:02d} (salience={i}) should be evicted"
