"""
tests/test_working_memory.py — Tests for WorkingMemoryCache (#371).

All tests are self-contained (no external dependencies, no DB, no LLM).
They exercise the full public API of WorkingMemoryCache:

    - Cache hit / miss
    - TTL expiry
    - LRU eviction at capacity
    - Session isolation
    - Category filtering
    - Concurrent (asyncio) access
    - Stats reporting
    - Clear session
    - put / get / evict / get_by_category / query
    - Disabled cache (enabled=False)
    - Invalid category validation
"""

from __future__ import annotations

import asyncio
import time
from typing import List

import pytest

from cortex.core.working_memory import (
    WorkingMemoryCache,
    WorkingMemoryCategory,
    _CacheEntry,
    _is_expired,
)


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

def _make_cache(
    max_items: int = 10,
    default_ttl: int = 3600,
    enabled: bool = True,
) -> WorkingMemoryCache:
    return WorkingMemoryCache(
        max_items=max_items,
        default_ttl_seconds=default_ttl,
        enabled=enabled,
    )


# ---------------------------------------------------------------------------
# Category enum tests
# ---------------------------------------------------------------------------

class TestWorkingMemoryCategory:
    def test_all_categories_present(self):
        categories = WorkingMemoryCategory.values()
        expected = {
            "task_state", "project_state", "session_facts", "turn_summaries",
            "entities", "open_loops", "tool_state", "injected_bundles",
        }
        assert categories == expected

    def test_str_comparison(self):
        assert WorkingMemoryCategory.TASK_STATE == "task_state"
        assert WorkingMemoryCategory.INJECTED_BUNDLES == "injected_bundles"


# ---------------------------------------------------------------------------
# Basic put / get (hit and miss)
# ---------------------------------------------------------------------------

class TestCacheHitMiss:
    @pytest.mark.asyncio
    async def test_cache_hit(self):
        wm = _make_cache()
        await wm.put("task", {"step": 1}, category="task_state", session_id="s1")
        entry = await wm.get("task", session_id="s1")
        assert entry is not None
        assert entry.value == {"step": 1}
        assert entry.category == "task_state"

    @pytest.mark.asyncio
    async def test_cache_miss_key_not_found(self):
        wm = _make_cache()
        entry = await wm.get("nonexistent", session_id="s1")
        assert entry is None

    @pytest.mark.asyncio
    async def test_cache_miss_session_not_found(self):
        wm = _make_cache()
        await wm.put("key", "value", category="session_facts", session_id="s1")
        entry = await wm.get("key", session_id="s_other")
        assert entry is None

    @pytest.mark.asyncio
    async def test_get_value_convenience(self):
        wm = _make_cache()
        await wm.put("x", 42, category="tool_state", session_id="s1")
        val = await wm.get_value("x", session_id="s1")
        assert val == 42

    @pytest.mark.asyncio
    async def test_get_value_miss_returns_none(self):
        wm = _make_cache()
        val = await wm.get_value("missing", session_id="s1")
        assert val is None

    @pytest.mark.asyncio
    async def test_put_overwrites_existing_key(self):
        wm = _make_cache()
        await wm.put("k", "old", category="task_state", session_id="s1")
        await wm.put("k", "new", category="task_state", session_id="s1")
        val = await wm.get_value("k", session_id="s1")
        assert val == "new"

    @pytest.mark.asyncio
    async def test_various_value_types(self):
        wm = _make_cache()
        cases = [
            ("str_val", "hello"),
            ("int_val", 99),
            ("list_val", [1, 2, 3]),
            ("dict_val", {"a": 1}),
            ("none_val", None),
        ]
        for key, value in cases:
            await wm.put(key, value, category="session_facts", session_id="s1")
        for key, value in cases:
            assert await wm.get_value(key, session_id="s1") == value


# ---------------------------------------------------------------------------
# TTL expiry
# ---------------------------------------------------------------------------

class TestTTLExpiry:
    @pytest.mark.asyncio
    async def test_entry_expires_after_ttl(self):
        wm = _make_cache(default_ttl=1)
        await wm.put("short", "value", category="session_facts", session_id="s1")
        # Entry should be live immediately
        entry = await wm.get("short", session_id="s1")
        assert entry is not None

        # Manually push the expiry into the past to avoid real sleep in tests
        async with wm._lock:
            lru = wm._sessions["s1"]
            cached = lru._data["short"]
            cached.expires_at = time.monotonic() - 0.001  # already expired

        entry = await wm.get("short", session_id="s1")
        assert entry is None

    @pytest.mark.asyncio
    async def test_no_ttl_entry_does_not_expire(self):
        wm = _make_cache(default_ttl=0)
        await wm.put("forever", "alive", category="open_loops", session_id="s1", ttl=0)
        # Simulate time passing by checking the entry's expires_at
        entry_check = await wm.get("forever", session_id="s1")
        assert entry_check is not None
        assert entry_check.expires_at is None

    @pytest.mark.asyncio
    async def test_per_entry_ttl_override(self):
        wm = _make_cache(default_ttl=3600)
        # Force a very short TTL for this entry only
        await wm.put("fast", "go", category="task_state", session_id="s1", ttl=1)
        async with wm._lock:
            lru = wm._sessions["s1"]
            cached = lru._data["fast"]
            cached.expires_at = time.monotonic() - 0.001

        assert await wm.get("fast", session_id="s1") is None

    @pytest.mark.asyncio
    async def test_expired_entries_purged_in_get_by_category(self):
        wm = _make_cache()
        await wm.put("old", "gone", category="entities", session_id="s1")
        await wm.put("fresh", "here", category="entities", session_id="s1")

        # Expire "old"
        async with wm._lock:
            lru = wm._sessions["s1"]
            lru._data["old"].expires_at = time.monotonic() - 0.001

        results = await wm.get_by_category("entities", session_id="s1")
        keys = [e.key for e in results]
        assert "old" not in keys
        assert "fresh" in keys

    @pytest.mark.asyncio
    async def test_expiration_counter_increments(self):
        wm = _make_cache(default_ttl=1)
        await wm.put("exp", "v", category="session_facts", session_id="s1")
        async with wm._lock:
            lru = wm._sessions["s1"]
            lru._data["exp"].expires_at = time.monotonic() - 0.001

        await wm.get("exp", session_id="s1")  # triggers expiration
        stats = await wm.stats()
        assert stats["expirations"] >= 1


# ---------------------------------------------------------------------------
# LRU eviction at capacity
# ---------------------------------------------------------------------------

class TestLRUEviction:
    @pytest.mark.asyncio
    async def test_lru_eviction_at_capacity(self):
        wm = _make_cache(max_items=3, default_ttl=0)
        # Fill to capacity
        await wm.put("a", 1, category="task_state", session_id="s1")
        await wm.put("b", 2, category="task_state", session_id="s1")
        await wm.put("c", 3, category="task_state", session_id="s1")

        # "a" is LRU — access b and c to make a least-recently-used
        await wm.get("b", session_id="s1")
        await wm.get("c", session_id="s1")

        # Insert "d" — "a" should be evicted
        await wm.put("d", 4, category="task_state", session_id="s1")

        assert await wm.get("a", session_id="s1") is None  # evicted
        assert await wm.get_value("b", session_id="s1") == 2
        assert await wm.get_value("c", session_id="s1") == 3
        assert await wm.get_value("d", session_id="s1") == 4

    @pytest.mark.asyncio
    async def test_eviction_counter_increments(self):
        wm = _make_cache(max_items=2, default_ttl=0)
        await wm.put("x", 1, category="project_state", session_id="s1")
        await wm.put("y", 2, category="project_state", session_id="s1")
        await wm.put("z", 3, category="project_state", session_id="s1")  # evicts x

        stats = await wm.stats()
        assert stats["evictions"] >= 1

    @pytest.mark.asyncio
    async def test_overwrite_does_not_evict(self):
        wm = _make_cache(max_items=2, default_ttl=0)
        await wm.put("a", 1, category="task_state", session_id="s1")
        await wm.put("b", 2, category="task_state", session_id="s1")
        # Overwrite "a" — should NOT trigger eviction (key already exists)
        await wm.put("a", 99, category="task_state", session_id="s1")

        stats = await wm.stats()
        assert stats["evictions"] == 0
        assert await wm.get_value("a", session_id="s1") == 99
        assert await wm.get_value("b", session_id="s1") == 2

    @pytest.mark.asyncio
    async def test_max_items_respected_per_session(self):
        wm = _make_cache(max_items=5, default_ttl=0)
        for i in range(10):
            await wm.put(f"k{i}", i, category="session_facts", session_id="s1")

        # Only the 5 most recently put items should remain
        async with wm._lock:
            session_lru = wm._sessions.get("s1")
        assert session_lru is not None
        assert len(session_lru) == 5


# ---------------------------------------------------------------------------
# Session isolation
# ---------------------------------------------------------------------------

class TestSessionIsolation:
    @pytest.mark.asyncio
    async def test_sessions_are_isolated(self):
        wm = _make_cache()
        await wm.put("shared_key", "session_1", category="task_state", session_id="s1")
        await wm.put("shared_key", "session_2", category="task_state", session_id="s2")

        v1 = await wm.get_value("shared_key", session_id="s1")
        v2 = await wm.get_value("shared_key", session_id="s2")
        assert v1 == "session_1"
        assert v2 == "session_2"

    @pytest.mark.asyncio
    async def test_clear_session_only_clears_target(self):
        wm = _make_cache()
        await wm.put("k", "v1", category="task_state", session_id="s1")
        await wm.put("k", "v2", category="task_state", session_id="s2")

        removed = await wm.clear_session("s1")
        assert removed == 1
        assert await wm.get_value("k", session_id="s1") is None
        assert await wm.get_value("k", session_id="s2") == "v2"

    @pytest.mark.asyncio
    async def test_multiple_sessions_coexist(self):
        wm = _make_cache(max_items=3, default_ttl=0)
        for sid in ("s1", "s2", "s3"):
            for i in range(3):
                await wm.put(f"item{i}", sid, category="entities", session_id=sid)

        for sid in ("s1", "s2", "s3"):
            for i in range(3):
                assert await wm.get_value(f"item{i}", session_id=sid) == sid

    @pytest.mark.asyncio
    async def test_clear_nonexistent_session_returns_zero(self):
        wm = _make_cache()
        removed = await wm.clear_session("does_not_exist")
        assert removed == 0


# ---------------------------------------------------------------------------
# Category filtering
# ---------------------------------------------------------------------------

class TestCategoryFiltering:
    @pytest.mark.asyncio
    async def test_get_by_category_returns_matching(self):
        wm = _make_cache(default_ttl=0)
        await wm.put("t1", "task", category="task_state", session_id="s1")
        await wm.put("p1", "proj", category="project_state", session_id="s1")
        await wm.put("t2", "task2", category="task_state", session_id="s1")

        tasks = await wm.get_by_category("task_state", session_id="s1")
        assert len(tasks) == 2
        keys = {e.key for e in tasks}
        assert keys == {"t1", "t2"}

    @pytest.mark.asyncio
    async def test_get_by_category_returns_empty_when_none_match(self):
        wm = _make_cache()
        await wm.put("x", 1, category="task_state", session_id="s1")
        result = await wm.get_by_category("open_loops", session_id="s1")
        assert result == []

    @pytest.mark.asyncio
    async def test_get_by_category_ordered_by_recency(self):
        wm = _make_cache(default_ttl=0)
        await wm.put("old", 1, category="entities", session_id="s1")
        await wm.put("new", 2, category="entities", session_id="s1")
        # Access "old" after "new" to make it more recently used
        await wm.get("old", session_id="s1")

        results = await wm.get_by_category("entities", session_id="s1")
        assert results[0].key == "old"  # most recently accessed

    @pytest.mark.asyncio
    async def test_all_eight_categories_accepted(self):
        wm = _make_cache(default_ttl=0)
        for cat in WorkingMemoryCategory.values():
            await wm.put(f"key_{cat}", "v", category=cat, session_id="s1")
            results = await wm.get_by_category(cat, session_id="s1")
            assert len(results) == 1

    @pytest.mark.asyncio
    async def test_invalid_category_raises(self):
        wm = _make_cache()
        with pytest.raises(ValueError, match="Invalid working memory category"):
            await wm.put("k", "v", category="bad_cat", session_id="s1")

    @pytest.mark.asyncio
    async def test_invalid_category_in_get_by_category_raises(self):
        wm = _make_cache()
        with pytest.raises(ValueError, match="Invalid working memory category"):
            await wm.get_by_category("not_a_category", session_id="s1")


# ---------------------------------------------------------------------------
# Explicit evict
# ---------------------------------------------------------------------------

class TestEvict:
    @pytest.mark.asyncio
    async def test_evict_existing_key(self):
        wm = _make_cache()
        await wm.put("k", "v", category="tool_state", session_id="s1")
        removed = await wm.evict("k", session_id="s1")
        assert removed is True
        assert await wm.get("k", session_id="s1") is None

    @pytest.mark.asyncio
    async def test_evict_missing_key(self):
        wm = _make_cache()
        removed = await wm.evict("ghost", session_id="s1")
        assert removed is False

    @pytest.mark.asyncio
    async def test_evict_missing_session(self):
        wm = _make_cache()
        removed = await wm.evict("k", session_id="no_such_session")
        assert removed is False


# ---------------------------------------------------------------------------
# Stats reporting
# ---------------------------------------------------------------------------

class TestStats:
    @pytest.mark.asyncio
    async def test_stats_initial_state(self):
        wm = _make_cache()
        stats = await wm.stats()
        assert stats["hits"] == 0
        assert stats["misses"] == 0
        assert stats["hit_rate"] == 0.0
        assert stats["evictions"] == 0
        assert stats["expirations"] == 0
        assert stats["total_sessions"] == 0
        assert stats["enabled"] is True

    @pytest.mark.asyncio
    async def test_stats_tracks_hits_misses(self):
        wm = _make_cache()
        await wm.put("k", "v", category="task_state", session_id="s1")
        await wm.get("k", session_id="s1")     # hit
        await wm.get("k", session_id="s1")     # hit
        await wm.get("nope", session_id="s1")  # miss

        stats = await wm.stats()
        assert stats["hits"] == 2
        assert stats["misses"] == 1
        assert stats["hit_rate"] == pytest.approx(2 / 3, abs=1e-4)

    @pytest.mark.asyncio
    async def test_stats_with_session_id(self):
        wm = _make_cache()
        await wm.put("a", 1, category="entities", session_id="s1")
        await wm.put("b", 2, category="entities", session_id="s1")
        await wm.put("c", 3, category="turn_summaries", session_id="s1")

        stats = await wm.stats(session_id="s1")
        assert stats["session_id"] == "s1"
        assert stats["session_items"] == 3
        assert stats["session_categories"]["entities"] == 2
        assert stats["session_categories"]["turn_summaries"] == 1

    @pytest.mark.asyncio
    async def test_stats_missing_session(self):
        wm = _make_cache()
        stats = await wm.stats(session_id="no_such")
        assert stats["session_id"] == "no_such"
        assert stats["session_items"] == 0
        assert stats["session_categories"] == {}

    @pytest.mark.asyncio
    async def test_stats_total_sessions(self):
        wm = _make_cache()
        await wm.put("k1", 1, category="task_state", session_id="s1")
        await wm.put("k2", 2, category="task_state", session_id="s2")
        stats = await wm.stats()
        assert stats["total_sessions"] == 2


# ---------------------------------------------------------------------------
# Clear session
# ---------------------------------------------------------------------------

class TestClearSession:
    @pytest.mark.asyncio
    async def test_clear_session_removes_all_entries(self):
        wm = _make_cache()
        for i in range(5):
            await wm.put(f"k{i}", i, category="session_facts", session_id="s1")

        removed = await wm.clear_session("s1")
        assert removed == 5

        for i in range(5):
            assert await wm.get(f"k{i}", session_id="s1") is None

    @pytest.mark.asyncio
    async def test_clear_removes_session_from_registry(self):
        wm = _make_cache()
        await wm.put("k", "v", category="task_state", session_id="s1")
        await wm.clear_session("s1")
        stats = await wm.stats()
        assert stats["total_sessions"] == 0


# ---------------------------------------------------------------------------
# Concurrent (asyncio) access — thread safety
# ---------------------------------------------------------------------------

class TestConcurrentAccess:
    @pytest.mark.asyncio
    async def test_concurrent_puts_no_data_loss(self):
        """Multiple concurrent put() calls should not corrupt the cache."""
        wm = _make_cache(max_items=200, default_ttl=0)

        async def writer(n: int) -> None:
            for i in range(20):
                await wm.put(f"k_{n}_{i}", n * 100 + i, category="session_facts", session_id="s1")

        await asyncio.gather(*[writer(n) for n in range(10)])

        # The session should have exactly max_items entries (200 total puts,
        # but max_items=200 so no eviction — all should be present).
        stats = await wm.stats(session_id="s1")
        assert stats["session_items"] == 200

    @pytest.mark.asyncio
    async def test_concurrent_gets_are_consistent(self):
        """Concurrent get() calls should not see corrupted entries."""
        wm = _make_cache(default_ttl=0)
        await wm.put("shared", {"data": 42}, category="task_state", session_id="s1")

        async def reader() -> Any:
            entry = await wm.get("shared", session_id="s1")
            return entry.value if entry else None

        results = await asyncio.gather(*[reader() for _ in range(50)])
        assert all(r == {"data": 42} for r in results)

    @pytest.mark.asyncio
    async def test_concurrent_mixed_operations(self):
        """Mix of put/get/evict across multiple sessions should not deadlock."""
        wm = _make_cache(max_items=10, default_ttl=0)

        async def session_work(sid: str) -> None:
            for i in range(5):
                await wm.put(f"k{i}", i, category="entities", session_id=sid)
            for i in range(5):
                await wm.get(f"k{i}", session_id=sid)
            await wm.clear_session(sid)

        sessions = [f"sess_{n}" for n in range(20)]
        await asyncio.gather(*[session_work(sid) for sid in sessions])

        # All sessions cleared — total should be 0
        stats = await wm.stats()
        assert stats["total_sessions"] == 0

    @pytest.mark.asyncio
    async def test_concurrent_puts_with_eviction(self):
        """Concurrent puts that trigger LRU eviction should not corrupt counts."""
        wm = _make_cache(max_items=5, default_ttl=0)

        async def rapid_writer(prefix: str) -> None:
            for i in range(10):
                await wm.put(f"{prefix}_{i}", i, category="task_state", session_id="s1")

        await asyncio.gather(
            rapid_writer("a"),
            rapid_writer("b"),
            rapid_writer("c"),
        )

        async with wm._lock:
            lru = wm._sessions.get("s1")
        assert lru is not None
        assert len(lru) <= 5  # never exceeds max_items


# ---------------------------------------------------------------------------
# Disabled cache
# ---------------------------------------------------------------------------

class TestDisabledCache:
    @pytest.mark.asyncio
    async def test_disabled_put_is_noop(self):
        wm = _make_cache(enabled=False)
        await wm.put("k", "v", category="task_state", session_id="s1")
        assert await wm.get("k", session_id="s1") is None

    @pytest.mark.asyncio
    async def test_disabled_get_returns_none(self):
        wm = _make_cache(enabled=False)
        result = await wm.get("k", session_id="s1")
        assert result is None

    @pytest.mark.asyncio
    async def test_disabled_get_by_category_returns_empty(self):
        wm = _make_cache(enabled=False)
        result = await wm.get_by_category("task_state", session_id="s1")
        assert result == []

    @pytest.mark.asyncio
    async def test_disabled_evict_returns_false(self):
        wm = _make_cache(enabled=False)
        assert await wm.evict("k", session_id="s1") is False

    @pytest.mark.asyncio
    async def test_disabled_clear_returns_zero(self):
        wm = _make_cache(enabled=False)
        assert await wm.clear_session("s1") == 0

    @pytest.mark.asyncio
    async def test_disabled_stats_shows_false(self):
        wm = _make_cache(enabled=False)
        stats = await wm.stats()
        assert stats["enabled"] is False

    @pytest.mark.asyncio
    async def test_disabled_query_returns_empty(self):
        wm = _make_cache(enabled=False)
        result = await wm.query("anything", session_id="s1")
        assert result == []


# ---------------------------------------------------------------------------
# Query (retrieval integration helper)
# ---------------------------------------------------------------------------

class TestQuery:
    @pytest.mark.asyncio
    async def test_query_finds_matching_value(self):
        wm = _make_cache(default_ttl=0)
        await wm.put("project", "Cortex memory engine", category="project_state", session_id="s1")
        await wm.put("task", "implement working memory", category="task_state", session_id="s1")

        results = await wm.query("cortex", session_id="s1")
        assert any(e.key == "project" for e in results)

    @pytest.mark.asyncio
    async def test_query_finds_matching_key(self):
        wm = _make_cache(default_ttl=0)
        await wm.put("open_issue_42", "Fix LRU eviction bug", category="open_loops", session_id="s1")

        results = await wm.query("open_issue", session_id="s1")
        assert len(results) == 1
        assert results[0].key == "open_issue_42"

    @pytest.mark.asyncio
    async def test_query_returns_empty_on_no_match(self):
        wm = _make_cache(default_ttl=0)
        await wm.put("k", "completely different content", category="task_state", session_id="s1")
        results = await wm.query("xyzzy_not_found", session_id="s1")
        assert results == []

    @pytest.mark.asyncio
    async def test_query_empty_session(self):
        wm = _make_cache()
        results = await wm.query("anything", session_id="empty_session")
        assert results == []

    @pytest.mark.asyncio
    async def test_query_category_filter(self):
        wm = _make_cache(default_ttl=0)
        await wm.put("t1", "deploy cortex module", category="task_state", session_id="s1")
        await wm.put("p1", "cortex v3 project", category="project_state", session_id="s1")

        # Filter to only task_state
        results = await wm.query("cortex", session_id="s1", categories=["task_state"])
        assert all(e.category == "task_state" for e in results)

    @pytest.mark.asyncio
    async def test_query_skips_expired(self):
        wm = _make_cache()
        await wm.put("exp", "will expire", category="session_facts", session_id="s1")
        await wm.put("live", "will survive", category="session_facts", session_id="s1")

        # Expire the first entry
        async with wm._lock:
            lru = wm._sessions["s1"]
            lru._data["exp"].expires_at = time.monotonic() - 0.001

        results = await wm.query("will", session_id="s1")
        keys = [e.key for e in results]
        assert "exp" not in keys
        assert "live" in keys

    @pytest.mark.asyncio
    async def test_query_is_case_insensitive(self):
        wm = _make_cache(default_ttl=0)
        await wm.put("note", "UPPER CASE VALUE", category="session_facts", session_id="s1")
        results = await wm.query("upper case", session_id="s1")
        assert len(results) == 1

    @pytest.mark.asyncio
    async def test_query_ordered_by_recency(self):
        wm = _make_cache(default_ttl=0)
        await wm.put("old", "target text", category="entities", session_id="s1")
        await wm.put("new", "target text", category="entities", session_id="s1")
        # Access "new" after "old" — "new" should appear first
        await wm.get("new", session_id="s1")

        results = await wm.query("target", session_id="s1")
        assert results[0].key == "new"


# ---------------------------------------------------------------------------
# _is_expired helper
# ---------------------------------------------------------------------------

class TestIsExpired:
    def test_no_expiry(self):
        entry = _CacheEntry(
            key="k", value="v", category="task_state", session_id="s",
            created_at=0.0, expires_at=None, last_accessed=0.0,
        )
        assert _is_expired(entry) is False

    def test_future_expiry(self):
        entry = _CacheEntry(
            key="k", value="v", category="task_state", session_id="s",
            created_at=0.0, expires_at=time.monotonic() + 3600, last_accessed=0.0,
        )
        assert _is_expired(entry) is False

    def test_past_expiry(self):
        entry = _CacheEntry(
            key="k", value="v", category="task_state", session_id="s",
            created_at=0.0, expires_at=time.monotonic() - 1.0, last_accessed=0.0,
        )
        assert _is_expired(entry) is True


# ---------------------------------------------------------------------------
# Audit fix: max_items=0 and negative TTL rejection
# ---------------------------------------------------------------------------

class TestConstructorValidation:
    def test_max_items_zero_raises_value_error(self):
        """max_items=0 must raise ValueError at construction (audit fix)."""
        with pytest.raises(ValueError, match="max_items must be >= 1"):
            WorkingMemoryCache(max_items=0)

    def test_max_items_negative_raises_value_error(self):
        with pytest.raises(ValueError, match="max_items must be >= 1"):
            WorkingMemoryCache(max_items=-1)

    def test_negative_default_ttl_raises_value_error(self):
        """Negative default_ttl_seconds must raise ValueError (audit fix)."""
        with pytest.raises(ValueError, match="default_ttl_seconds must be >= 0"):
            WorkingMemoryCache(default_ttl_seconds=-1)

    def test_zero_default_ttl_is_valid(self):
        """TTL=0 means no expiry — should be allowed."""
        wm = WorkingMemoryCache(default_ttl_seconds=0)
        assert wm._default_ttl == 0

    @pytest.mark.asyncio
    async def test_negative_per_entry_ttl_raises(self):
        """Negative per-entry TTL must raise ValueError (audit fix)."""
        wm = WorkingMemoryCache()
        with pytest.raises(ValueError, match="ttl must be >= 0"):
            await wm.put("k", "v", category="task_state", session_id="s1", ttl=-5)


# ---------------------------------------------------------------------------
# Config integration — ensure new fields load from defaults.toml
# ---------------------------------------------------------------------------

class TestConfigIntegration:
    def test_default_config_has_working_memory_fields(self):
        from cortex.config import CortexConfig
        cfg = CortexConfig.defaults()
        assert cfg.working_memory_enabled is True
        assert cfg.working_memory_max_items == 100
        assert cfg.working_memory_default_ttl_seconds == 1800

    def test_config_overlay_working_memory(self):
        from cortex.config import CortexConfig
        cfg = CortexConfig.from_toml(overlay={
            "cortex": {
                "working_memory": {
                    "enabled": False,
                    "max_items": 50,
                    "default_ttl_seconds": 600,
                }
            }
        })
        assert cfg.working_memory_enabled is False
        assert cfg.working_memory_max_items == 50
        assert cfg.working_memory_default_ttl_seconds == 600

    def test_working_memory_cache_built_from_config(self):
        from cortex.config import CortexConfig
        cfg = CortexConfig.defaults()
        wm = WorkingMemoryCache(
            max_items=cfg.working_memory_max_items,
            default_ttl_seconds=cfg.working_memory_default_ttl_seconds,
            enabled=cfg.working_memory_enabled,
        )
        assert wm.enabled is True
        assert wm._max_items == 100
        assert wm._default_ttl == 1800

    def test_validate_config_catches_invalid_max_items(self):
        """validate_config should flag working_memory_max_items < 1."""
        from cortex.config import CortexConfig, validate_config
        cfg = CortexConfig()
        object.__setattr__(cfg, "working_memory_max_items", 0)
        issues = validate_config(cfg)
        assert any("working_memory_max_items" in i for i in issues)

    def test_validate_config_catches_negative_ttl(self):
        """validate_config should flag working_memory_default_ttl_seconds < 0."""
        from cortex.config import CortexConfig, validate_config
        cfg = CortexConfig()
        object.__setattr__(cfg, "working_memory_default_ttl_seconds", -1)
        issues = validate_config(cfg)
        assert any("working_memory_default_ttl_seconds" in i for i in issues)
