"""Tests for MCALStore implementation."""

import pytest
from datetime import datetime, timezone
from unittest.mock import MagicMock, AsyncMock, patch


class TestMCALStoreWithoutLangGraph:
    """Tests when langgraph is not installed."""
    
    def test_import_without_langgraph(self):
        """Test that import works even without langgraph."""
        # This should not raise ImportError
        from mcal_langgraph import LANGGRAPH_AVAILABLE
        # LANGGRAPH_AVAILABLE may be True or False depending on environment
        assert isinstance(LANGGRAPH_AVAILABLE, bool)


@pytest.fixture
def mock_mcal():
    """Create a mock MCAL instance."""
    mcal = MagicMock()
    mcal.add = AsyncMock(return_value=MagicMock(
        unified_graph=MagicMock(
            get_active_goals=MagicMock(return_value=["goal1"]),
            get_all_decisions_with_detail=MagicMock(return_value=[]),
            node_count=1,
        )
    ))
    # Mock MemoryEntry-like objects with attributes (not dicts)
    mock_memory = MagicMock()
    mock_memory.id = "mem_1"
    mock_memory.content = "test memory"
    mock_memory.score = 0.9
    mock_memory.metadata = {}
    mock_memory.created_at = None
    mock_memory.updated_at = None
    mcal.search = AsyncMock(return_value=MagicMock(
        memories=[mock_memory],
        active_goals=[],
        relevant_decisions=[],
        context=None,
    ))
    mcal.get_context = AsyncMock(return_value="context text")
    return mcal


class TestMCALStoreBasics:
    """Basic tests for MCALStore."""
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_store_init(self, mock_mcal):
        """Test store initialization."""
        from mcal_langgraph import MCALStore
        store = MCALStore(mock_mcal)
        assert store._mcal is mock_mcal
        assert store._items == {}
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_store_mcal_property(self, mock_mcal):
        """Test mcal property access."""
        from mcal_langgraph import MCALStore
        store = MCALStore(mock_mcal)
        assert store.mcal is mock_mcal


class TestMCALStoreOperations:
    """Test store CRUD operations."""
    
    @pytest.fixture
    def store(self, mock_mcal):
        """Create a store for testing."""
        pytest.importorskip("langgraph")
        from mcal_langgraph import MCALStore
        return MCALStore(mock_mcal)
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    @pytest.mark.asyncio
    async def test_aput_and_aget(self, store):
        """Test async put and get operations."""
        namespace = ("user_123",)
        key = "memory_1"
        value = {"text": "Test memory content"}
        
        await store.aput(namespace, key, value)
        
        result = await store.aget(namespace, key)
        assert result is not None
        assert result.value == value
        assert result.namespace == namespace
        assert result.key == key
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    @pytest.mark.asyncio
    async def test_adelete(self, store):
        """Test async delete operation."""
        namespace = ("user_123",)
        key = "memory_1"
        value = {"text": "To be deleted"}
        
        await store.aput(namespace, key, value)
        await store.adelete(namespace, key)
        
        result = await store.aget(namespace, key)
        assert result is None
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    @pytest.mark.asyncio
    async def test_asearch_with_filter(self, store):
        """Test search with filter."""
        namespace = ("user_123",)
        await store.aput(namespace, "mem1", {"text": "Hello", "category": "greeting"})
        await store.aput(namespace, "mem2", {"text": "Goodbye", "category": "farewell"})
        
        results = await store.asearch(namespace, filter={"category": "greeting"})
        
        assert len(results) == 1
        assert results[0].value["category"] == "greeting"
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    @pytest.mark.asyncio
    async def test_alist_namespaces(self, store):
        """Test namespace listing."""
        await store.aput(("user_1", "chat"), "m1", {"text": "test1"})
        await store.aput(("user_2", "chat"), "m1", {"text": "test2"})
        await store.aput(("user_1", "notes"), "m1", {"text": "test3"})
        
        namespaces = await store.alist_namespaces()
        
        assert len(namespaces) == 3
        assert ("user_1", "chat") in namespaces
        assert ("user_2", "chat") in namespaces
        assert ("user_1", "notes") in namespaces
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    @pytest.mark.asyncio
    async def test_alist_namespaces_with_prefix(self, store):
        """Test namespace listing with prefix filter."""
        await store.aput(("user_1", "chat"), "m1", {"text": "test1"})
        await store.aput(("user_2", "chat"), "m1", {"text": "test2"})
        
        namespaces = await store.alist_namespaces(prefix=("user_1",))
        
        assert len(namespaces) == 1
        assert ("user_1", "chat") in namespaces


class TestMCALStoreSyncOperations:
    """Test synchronous wrapper operations."""
    
    @pytest.fixture
    def store(self, mock_mcal):
        """Create a store for testing."""
        pytest.importorskip("langgraph")
        from mcal_langgraph import MCALStore
        return MCALStore(mock_mcal)
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_sync_put_and_get(self, store):
        """Test sync put and get operations."""
        namespace = ("user_sync",)
        key = "sync_mem"
        value = {"text": "Sync test"}
        
        store.put(namespace, key, value)
        result = store.get(namespace, key)
        
        assert result is not None
        assert result.value == value
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_sync_delete(self, store):
        """Test sync delete operation."""
        namespace = ("user_sync",)
        key = "to_delete"
        
        store.put(namespace, key, {"text": "delete me"})
        store.delete(namespace, key)
        
        result = store.get(namespace, key)
        assert result is None


class TestTTLSupport:
    """Tests for TTL (Time-To-Live) support - Issue #057."""
    
    @pytest.fixture
    def mock_mcal(self):
        """Create a mock MCAL instance."""
        from unittest.mock import MagicMock, AsyncMock
        mcal = MagicMock()
        mcal.add = AsyncMock()
        mcal.search = AsyncMock(return_value=MagicMock(
            memories=[], active_goals=[], relevant_decisions=[], context=None,
        ))
        return mcal
    
    @pytest.fixture
    def store(self, mock_mcal):
        """Create a store for testing."""
        pytest.importorskip("langgraph")
        from mcal_langgraph import MCALStore
        return MCALStore(mock_mcal)
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_put_with_ttl(self, store):
        """Test storing item with TTL."""
        namespace = ("cache",)
        key = "temp_data"
        value = {"data": "temporary"}
        
        store.put(namespace, key, value, ttl=60.0)  # 60 minutes TTL
        
        result = store.get(namespace, key)
        assert result is not None
        assert result.value == value
        
        # Verify TTL is stored
        assert namespace in store._ttl
        assert key in store._ttl[namespace]
        assert store._ttl[namespace][key] == 60.0
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_expired_item_returns_none(self, store):
        """Test that expired items return None on get."""
        from datetime import datetime, timezone, timedelta
        
        namespace = ("cache",)
        key = "expired_item"
        value = {"data": "will expire"}
        
        store.put(namespace, key, value, ttl=1.0)  # 1 minute TTL
        
        # Manually set expiration to the past
        store._expires_at[namespace][key] = datetime.now(timezone.utc) - timedelta(minutes=5)
        
        # Item should be gone (lazy expiration)
        result = store.get(namespace, key)
        assert result is None
        
        # Item should be deleted from storage
        assert key not in store._items.get(namespace, {})
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_no_ttl_means_no_expiration(self, store):
        """Test that items without TTL don't expire."""
        namespace = ("permanent",)
        key = "forever"
        value = {"data": "permanent data"}
        
        store.put(namespace, key, value)  # No TTL
        
        # Should not have TTL tracking
        assert namespace not in store._ttl or key not in store._ttl.get(namespace, {})
        
        # Should always be retrievable
        result = store.get(namespace, key)
        assert result is not None
        assert result.value == value
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    @pytest.mark.asyncio
    async def test_search_skips_expired_items(self, store):
        """Test that search excludes expired items."""
        from datetime import datetime, timezone, timedelta
        
        namespace = ("cache",)
        
        # Add two items - one expired, one valid
        await store.aput(namespace, "valid", {"text": "valid item"}, ttl=60.0)
        await store.aput(namespace, "expired", {"text": "expired item"}, ttl=1.0)
        
        # Manually expire one
        store._expires_at[namespace]["expired"] = datetime.now(timezone.utc) - timedelta(minutes=5)
        
        # Search should only return the valid item
        results = await store.asearch(namespace)
        
        keys = [r.key for r in results]
        assert "valid" in keys
        assert "expired" not in keys
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_delete_cleans_up_ttl(self, store):
        """Test that delete removes TTL tracking."""
        namespace = ("cache",)
        key = "to_delete"
        
        store.put(namespace, key, {"data": "temp"}, ttl=60.0)
        
        # Verify TTL is stored
        assert key in store._ttl.get(namespace, {})
        
        store.delete(namespace, key)
        
        # TTL should be cleaned up
        assert key not in store._ttl.get(namespace, {})
        assert key not in store._expires_at.get(namespace, {})


class TestMCALMemory:
    """Tests for MCALMemory class."""
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_memory_init(self, mock_mcal):
        """Test MCALMemory initialization."""
        # MCAL is imported inside __init__, so we patch the mcal module
        with patch("mcal.MCAL", return_value=mock_mcal):
            from mcal_langgraph import MCALMemory
            memory = MCALMemory(llm_provider="anthropic", user_id="test_user")
            assert memory.user_id == "test_user"
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_as_store(self, mock_mcal):
        """Test converting memory to store."""
        with patch("mcal.MCAL", return_value=mock_mcal):
            from mcal_langgraph import MCALMemory, MCALStore
            memory = MCALMemory(llm_provider="anthropic")
            store = memory.as_store()
            assert isinstance(store, MCALStore)


class TestMCALCheckpointer:
    """Tests for MCALCheckpointer class."""
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_checkpointer_init(self):
        """Test checkpointer initialization."""
        from mcal_langgraph import MCALCheckpointer
        checkpointer = MCALCheckpointer(storage_path="/tmp/test")
        assert checkpointer.storage_path == "/tmp/test"
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_checkpointer_put_and_get(self):
        """Test checkpointer put and get."""
        from mcal_langgraph import MCALCheckpointer
        checkpointer = MCALCheckpointer()
        
        config = {"configurable": {"thread_id": "test_thread"}}
        checkpoint = {"messages": ["msg1", "msg2"], "state": {"key": "value"}}
        
        checkpointer.put(config, checkpoint)
        result = checkpointer.get(config)
        
        assert result == checkpoint
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_checkpointer_list(self):
        """Test checkpointer list."""
        from mcal_langgraph import MCALCheckpointer
        checkpointer = MCALCheckpointer()
        
        checkpointer.put({"configurable": {"thread_id": "t1"}}, {"state": 1})
        checkpointer.put({"configurable": {"thread_id": "t2"}}, {"state": 2})
        
        all_checkpoints = checkpointer.list({})
        
        assert len(all_checkpoints) == 2


class TestBackwardCompatibility:
    """Test backward compatibility with old import path."""
    
    def test_old_import_path_works(self):
        """Test that old import path still works and exports the right classes."""
        import sys
        
        # Force reimport to get deprecation warning
        # First remove from cache if present
        modules_to_remove = [k for k in sys.modules.keys() if 'mcal.integrations.langgraph' in k]
        for mod in modules_to_remove:
            del sys.modules[mod]
        
        import warnings
        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")
            
            try:
                # Import the shim module
                from mcal.integrations import langgraph as old_module
                
                # Verify it exports the right things
                assert hasattr(old_module, 'MCALStore')
                assert hasattr(old_module, 'MCALMemory')
                assert hasattr(old_module, 'LANGGRAPH_AVAILABLE')
                
                # Verify they are the same classes as new package
                from mcal_langgraph import MCALStore as NewStore
                assert old_module.MCALStore is NewStore
                
            except ImportError:
                # If mcal is not installed, skip this test
                pytest.skip("mcal not installed")


class TestIssue101SearchAPIFix:
    """Tests for Issue #101: Verify correct MCAL search API usage."""
    
    @pytest.fixture
    def mock_mcal_with_memories(self):
        """MCAL mock returning proper MemoryEntry-like objects."""
        mcal = MagicMock()
        mcal.add = AsyncMock()
        
        mem1 = MagicMock()
        mem1.id = "m1"
        mem1.content = "Use PostgreSQL for persistence"
        mem1.score = 0.95
        mem1.metadata = {"source": "chat"}
        
        mem2 = MagicMock()
        mem2.id = "m2"
        mem2.content = "Redis for caching layer"
        mem2.score = 0.82
        mem2.metadata = {"source": "chat"}
        
        mcal.search = AsyncMock(return_value=MagicMock(
            memories=[mem1, mem2],
            active_goals=[MagicMock(__str__=lambda s: "Build scalable system")],
            relevant_decisions=[MagicMock(__str__=lambda s: "Chose PostgreSQL")],
            context=None,
        ))
        return mcal
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    @pytest.mark.asyncio
    async def test_store_search_passes_limit_not_top_k(self, mock_mcal_with_memories):
        """Verify store.asearch() calls MCAL with limit= not top_k=."""
        from mcal_langgraph import MCALStore
        store = MCALStore(mock_mcal_with_memories)
        
        await store.asearch(("user_1",), query="database", limit=5)
        
        # Verify MCAL.search was called with limit=, not top_k=
        call_kwargs = mock_mcal_with_memories.search.call_args.kwargs
        assert "limit" in call_kwargs, "Should use limit= parameter"
        assert call_kwargs["limit"] == 5
        assert "top_k" not in call_kwargs, "Should NOT use top_k= parameter"
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    @pytest.mark.asyncio
    async def test_store_search_accesses_memories_not_results(self, mock_mcal_with_memories):
        """Verify store.asearch() reads .memories not .results from SearchResult."""
        from mcal_langgraph import MCALStore
        store = MCALStore(mock_mcal_with_memories)
        
        results = await store.asearch(("user_1",), query="database")
        
        # Should have gotten 2 MCAL results
        mcal_results = [r for r in results if r.key.startswith("mcal_")]
        assert len(mcal_results) == 2
        
        # Values should come from MemoryEntry attributes
        assert mcal_results[0].value["text"] == "Use PostgreSQL for persistence"
        assert mcal_results[0].value["score"] == 0.95
        assert mcal_results[1].value["text"] == "Redis for caching layer"
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    @pytest.mark.asyncio
    async def test_store_search_includes_goals_and_decisions(self, mock_mcal_with_memories):
        """Verify goals/decisions come from SearchResult level."""
        from mcal_langgraph import MCALStore
        store = MCALStore(mock_mcal_with_memories)
        
        results = await store.asearch(("user_1",), query="database")
        mcal_results = [r for r in results if r.key.startswith("mcal_")]
        
        # Goals and decisions should be populated from SearchResult
        assert len(mcal_results[0].value["goals"]) > 0
        assert len(mcal_results[0].value["decisions"]) > 0
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    @pytest.mark.asyncio
    async def test_memory_search_passes_limit_not_top_k(self, mock_mcal_with_memories):
        """Verify MCALMemory.search() calls MCAL with limit= not top_k=."""
        with patch("mcal.MCAL", return_value=mock_mcal_with_memories):
            from mcal_langgraph import MCALMemory
            memory = MCALMemory(llm_provider="anthropic", user_id="test")
            # Override _mcal with our mock
            memory._mcal = mock_mcal_with_memories
            
            results = await memory.search("database", top_k=5)
            
            call_kwargs = mock_mcal_with_memories.search.call_args.kwargs
            assert "limit" in call_kwargs
            assert call_kwargs["limit"] == 5
            assert "top_k" not in call_kwargs
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    @pytest.mark.asyncio
    async def test_memory_search_returns_dicts_not_objects(self, mock_mcal_with_memories):
        """Verify MCALMemory.search() returns list of dicts with correct keys."""
        with patch("mcal.MCAL", return_value=mock_mcal_with_memories):
            from mcal_langgraph import MCALMemory
            memory = MCALMemory(llm_provider="anthropic", user_id="test")
            memory._mcal = mock_mcal_with_memories
            
            results = await memory.search("database")
            
            assert isinstance(results, list)
            assert len(results) == 2
            assert isinstance(results[0], dict)
            assert results[0]["content"] == "Use PostgreSQL for persistence"
            assert results[0]["score"] == 0.95
            assert "id" in results[0]
            assert "metadata" in results[0]


class TestBackwardCompatibility:
    """Test backward compatibility with old import path."""
    
    def test_old_import_path_works(self):
        """Test that old import path still works and exports the right classes."""
        import sys
        
        # Force reimport to get deprecation warning
        # First remove from cache if present
        modules_to_remove = [k for k in sys.modules.keys() if 'mcal.integrations.langgraph' in k]
        for mod in modules_to_remove:
            del sys.modules[mod]
        
        import warnings
        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")
            
            try:
                # Import the shim module
                from mcal.integrations import langgraph as old_module
                
                # Verify it exports the right things
                assert hasattr(old_module, 'MCALStore')
                assert hasattr(old_module, 'MCALMemory')
                assert hasattr(old_module, 'LANGGRAPH_AVAILABLE')
                
                # Verify they are the same classes as new package
                from mcal_langgraph import MCALStore as NewStore
                assert old_module.MCALStore is NewStore
                
            except ImportError:
                # If mcal is not installed, skip this test
                pytest.skip("mcal not installed")


class TestThreadSafety:
    """Tests for thread safety of MCALStore."""
    
    @pytest.fixture
    def store(self, mock_mcal):
        """Create a store for testing."""
        pytest.importorskip("langgraph")
        from mcal_langgraph import MCALStore
        return MCALStore(mock_mcal)
    
    @pytest.mark.skipif(
        not pytest.importorskip("langgraph", reason="langgraph not installed"),
        reason="langgraph not installed"
    )
    def test_store_has_lock(self, store):
        """Test that store has RLock for thread safety."""
        import threading
        assert hasattr(store, "_lock")
        assert isinstance(store._lock, type(threading.RLock()))
