"""
Unit tests for MemoryManager — episodic store creation per user,
remember/recall, async save, encryption toggle, store isolation.
"""

import json
import os
import threading
from unittest.mock import MagicMock, patch

import pytest

from familiar.core.memory_manager import MemoryManager, _get_episodic_encryption


@pytest.fixture
def mock_agent(tmp_path):
    agent = MagicMock()
    agent.config.agent.memory_enabled = True
    agent.config.agent.episodic_max_memories = 100
    agent.config.agent.episodic_working_size = 10
    agent.config.agent.episodic_decay_rate = 0.05
    return agent


@pytest.fixture
def mm(mock_agent, tmp_path):
    """MemoryManager with a temporary episodic directory."""
    mgr = MemoryManager(mock_agent)
    mgr._episodic_dir = tmp_path / "episodic"
    mgr._episodic_dir.mkdir()
    return mgr


class TestEpisodicStoreCreation:
    """Per-user episodic memory store lifecycle."""

    def test_creates_store_on_first_access(self, mm):
        ms = mm.get_episodic_memory("user1")
        assert ms is not None

    def test_returns_same_store_for_same_user(self, mm):
        ms1 = mm.get_episodic_memory("user1")
        ms2 = mm.get_episodic_memory("user1")
        assert ms1 is ms2

    def test_different_stores_for_different_users(self, mm):
        ms1 = mm.get_episodic_memory("user1")
        ms2 = mm.get_episodic_memory("user2")
        assert ms1 is not ms2

    def test_disabled_returns_none(self, mock_agent):
        mock_agent.config.agent.memory_enabled = False
        mgr = MemoryManager(mock_agent)
        assert mgr.get_episodic_memory("user1") is None

    def test_sanitizes_user_id_for_filename(self, mm):
        """Special characters in user_id are sanitized for file paths."""
        ms = mm.get_episodic_memory("user@example.com")
        assert ms is not None
        # Store should be accessible
        assert "user_example.com" in str(mm._episodic_stores.keys()) or ms is not None


class TestStoreIsolation:
    """Memory stores are isolated between users."""

    def test_store_per_user(self, mm):
        ms1 = mm.get_episodic_memory("alice")
        ms2 = mm.get_episodic_memory("bob")
        ms1.store_conversation(
            conversation_id="c1",
            messages=[{"role": "user", "content": "alice secret"}],
            importance=0.8,
        )
        # Bob's store should not have Alice's data
        stats2 = ms2.get_stats()
        assert stats2.get("total_stored", 0) == 0

    def test_thread_safe_creation(self, mm):
        """Concurrent store creation for different users doesn't crash."""
        stores = {}
        errors = []

        def get_store(uid):
            try:
                stores[uid] = mm.get_episodic_memory(uid)
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=get_store, args=(f"user{i}",)) for i in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        assert not errors
        assert len(stores) == 10


class TestSaveAndLoad:
    """Persistence to disk (plaintext)."""

    def test_save_creates_file(self, mm):
        ms = mm.get_episodic_memory("persist_user")
        ms.store_conversation(
            conversation_id="c1",
            messages=[{"role": "user", "content": "remember this"}],
            importance=0.9,
        )
        mm.save_episodic_memory_async("persist_user")
        # Wait for async save (daemon thread)
        import time

        time.sleep(0.5)

        # Check file exists (plaintext)
        expected = mm._episodic_dir / "persist_user.json"
        enc_expected = mm._episodic_dir / "persist_user.json.enc"
        assert expected.exists() or enc_expected.exists()

    def test_save_noop_for_unknown_user(self, mm):
        """Saving for a user with no store is a no-op."""
        mm.save_episodic_memory_async("nonexistent")
        # No crash, no file created

    def test_load_from_plaintext(self, mm):
        """Store can be loaded from a pre-existing JSON file."""
        # Create a memory file manually
        import re

        uid = "preload_user"
        safe_uid = re.sub(r"[^a-zA-Z0-9_\-.]", "_", uid)
        path = mm._episodic_dir / f"{safe_uid}.json"

        from familiar.core.episodic_memory import MemorySystem

        ms = MemorySystem(max_memories=100)
        ms.store_conversation(
            conversation_id="c1",
            messages=[{"role": "user", "content": "pre-existing memory"}],
            importance=0.8,
        )
        data = ms.export()
        path.write_text(json.dumps(data))

        # Now load it
        loaded_ms = mm.get_episodic_memory(uid)
        stats = loaded_ms.get_stats()
        assert stats.get("total_stored", 0) > 0


class TestEncryption:
    """Encryption singleton behavior."""

    def test_no_key_returns_none(self):
        """Without FAMILIAR_ENCRYPTION_KEY, encryption returns None."""
        import familiar.core.memory_manager as mm_mod

        # Reset the singleton
        old_init = mm_mod._episodic_encryption_initialized
        old_enc = mm_mod._episodic_encryption
        try:
            mm_mod._episodic_encryption_initialized = False
            mm_mod._episodic_encryption = None
            with patch.dict(os.environ, {}, clear=False):
                os.environ.pop("FAMILIAR_ENCRYPTION_KEY", None)
                result = _get_episodic_encryption()
                assert result is None
        finally:
            mm_mod._episodic_encryption_initialized = old_init
            mm_mod._episodic_encryption = old_enc

    def test_singleton_returns_same_instance(self):
        """Second call returns cached result without re-init."""
        import familiar.core.memory_manager as mm_mod

        old_init = mm_mod._episodic_encryption_initialized
        old_enc = mm_mod._episodic_encryption
        try:
            mm_mod._episodic_encryption_initialized = True
            mm_mod._episodic_encryption = "cached_sentinel"
            assert _get_episodic_encryption() == "cached_sentinel"
        finally:
            mm_mod._episodic_encryption_initialized = old_init
            mm_mod._episodic_encryption = old_enc


class TestEncryptionWithKey:
    """Encryption singleton with FAMILIAR_ENCRYPTION_KEY set."""

    def test_key_set_but_no_crypto_returns_none(self):
        """Key set but cryptography package unavailable returns None."""
        import familiar.core.memory_manager as mm_mod

        old_init = mm_mod._episodic_encryption_initialized
        old_enc = mm_mod._episodic_encryption
        old_crypto = mm_mod._EP_CRYPTO
        try:
            mm_mod._episodic_encryption_initialized = False
            mm_mod._episodic_encryption = None
            mm_mod._EP_CRYPTO = False
            with patch.dict(os.environ, {"FAMILIAR_ENCRYPTION_KEY": "test-key"}, clear=False):
                result = _get_episodic_encryption()
                assert result is None
        finally:
            mm_mod._episodic_encryption_initialized = old_init
            mm_mod._episodic_encryption = old_enc
            mm_mod._EP_CRYPTO = old_crypto

    def test_key_set_enc_available(self):
        """Key + crypto available → EncryptedStorage singleton created."""
        import familiar.core.memory_manager as mm_mod

        old_init = mm_mod._episodic_encryption_initialized
        old_enc = mm_mod._episodic_encryption
        old_crypto = mm_mod._EP_CRYPTO
        old_class = mm_mod._EpisodicEncStorage
        try:
            mm_mod._episodic_encryption_initialized = False
            mm_mod._episodic_encryption = None
            mm_mod._EP_CRYPTO = True
            mock_es = MagicMock()
            mock_es.is_available = True
            mm_mod._EpisodicEncStorage = MagicMock(return_value=mock_es)
            with patch.dict(os.environ, {"FAMILIAR_ENCRYPTION_KEY": "test-key"}, clear=False):
                result = _get_episodic_encryption()
                assert result is mock_es
        finally:
            mm_mod._episodic_encryption_initialized = old_init
            mm_mod._episodic_encryption = old_enc
            mm_mod._EP_CRYPTO = old_crypto
            mm_mod._EpisodicEncStorage = old_class

    def test_key_set_enc_not_available(self):
        """Key + crypto but is_available=False → None with warning."""
        import familiar.core.memory_manager as mm_mod

        old_init = mm_mod._episodic_encryption_initialized
        old_enc = mm_mod._episodic_encryption
        old_crypto = mm_mod._EP_CRYPTO
        old_class = mm_mod._EpisodicEncStorage
        try:
            mm_mod._episodic_encryption_initialized = False
            mm_mod._episodic_encryption = None
            mm_mod._EP_CRYPTO = True
            mock_es = MagicMock()
            mock_es.is_available = False
            mm_mod._EpisodicEncStorage = MagicMock(return_value=mock_es)
            with patch.dict(os.environ, {"FAMILIAR_ENCRYPTION_KEY": "test-key"}, clear=False):
                result = _get_episodic_encryption()
                assert result is None
        finally:
            mm_mod._episodic_encryption_initialized = old_init
            mm_mod._episodic_encryption = old_enc
            mm_mod._EP_CRYPTO = old_crypto
            mm_mod._EpisodicEncStorage = old_class

    def test_enc_init_exception_returns_none(self):
        """Exception during EncryptedStorage() init → None."""
        import familiar.core.memory_manager as mm_mod

        old_init = mm_mod._episodic_encryption_initialized
        old_enc = mm_mod._episodic_encryption
        old_crypto = mm_mod._EP_CRYPTO
        old_class = mm_mod._EpisodicEncStorage
        try:
            mm_mod._episodic_encryption_initialized = False
            mm_mod._episodic_encryption = None
            mm_mod._EP_CRYPTO = True
            mm_mod._EpisodicEncStorage = MagicMock(side_effect=RuntimeError("boom"))
            with patch.dict(os.environ, {"FAMILIAR_ENCRYPTION_KEY": "test-key"}, clear=False):
                result = _get_episodic_encryption()
                assert result is None
        finally:
            mm_mod._episodic_encryption_initialized = old_init
            mm_mod._episodic_encryption = old_enc
            mm_mod._EP_CRYPTO = old_crypto
            mm_mod._EpisodicEncStorage = old_class

    def test_double_checked_locking(self):
        """Concurrent calls don't re-initialize the singleton."""
        import familiar.core.memory_manager as mm_mod

        old_init = mm_mod._episodic_encryption_initialized
        old_enc = mm_mod._episodic_encryption
        try:
            mm_mod._episodic_encryption_initialized = True
            mm_mod._episodic_encryption = "sentinel"
            results = []
            errors = []

            def get_enc():
                try:
                    results.append(_get_episodic_encryption())
                except Exception as e:
                    errors.append(e)

            threads = [threading.Thread(target=get_enc) for _ in range(10)]
            for t in threads:
                t.start()
            for t in threads:
                t.join()
            assert not errors
            assert all(r == "sentinel" for r in results)
        finally:
            mm_mod._episodic_encryption_initialized = old_init
            mm_mod._episodic_encryption = old_enc


class TestEncryptedLoadFallback:
    """Load order: encrypted first, plaintext fallback."""

    def test_loads_encrypted_when_available(self, mm, tmp_path):
        """If .enc file exists and encryption is available, load encrypted."""
        uid = "enc_user"
        enc_path = mm._episodic_dir / f"{uid}.json.enc"

        mock_enc = MagicMock()
        mock_enc.is_available = True
        mock_enc.decrypt.return_value = json.dumps({"memories": [], "version": 1})

        enc_path.write_text("encrypted_blob")

        with patch("familiar.core.memory_manager._get_episodic_encryption", return_value=mock_enc):
            ms = mm.get_episodic_memory(uid)
            assert ms is not None
            mock_enc.decrypt.assert_called_once_with("encrypted_blob")

    def test_falls_back_to_plaintext_on_decrypt_error(self, mm, tmp_path):
        """If decrypt fails, fall back to plaintext file."""
        uid = "fallback_user"
        enc_path = mm._episodic_dir / f"{uid}.json.enc"
        plain_path = mm._episodic_dir / f"{uid}.json"

        mock_enc = MagicMock()
        mock_enc.is_available = True
        mock_enc.decrypt.side_effect = RuntimeError("bad key")

        enc_path.write_text("corrupted")

        from familiar.core.episodic_memory import MemorySystem

        ms_tmp = MemorySystem(max_memories=100)
        ms_tmp.store_conversation(
            conversation_id="c1",
            messages=[{"role": "user", "content": "fallback data"}],
            importance=0.8,
        )
        plain_path.write_text(json.dumps(ms_tmp.export()))

        with patch("familiar.core.memory_manager._get_episodic_encryption", return_value=mock_enc):
            ms = mm.get_episodic_memory(uid)
            assert ms is not None
            stats = ms.get_stats()
            assert stats.get("total_stored", 0) > 0


class TestConcurrentSave:
    """Concurrent save operations don't corrupt data."""

    def test_concurrent_saves_different_users(self, mm):
        """Multiple users saving concurrently doesn't crash."""
        import time

        errors = []

        def save_user(uid):
            try:
                ms = mm.get_episodic_memory(uid)
                ms.store_conversation(
                    conversation_id="c1",
                    messages=[{"role": "user", "content": f"data for {uid}"}],
                    importance=0.7,
                )
                mm.save_episodic_memory_async(uid)
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=save_user, args=(f"user{i}",)) for i in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        time.sleep(0.5)
        assert not errors

    def test_save_then_load_roundtrip(self, mm):
        """Save → new manager → load preserves data."""
        import time

        uid = "roundtrip_user"
        ms = mm.get_episodic_memory(uid)
        ms.store_conversation(
            conversation_id="c1",
            messages=[{"role": "user", "content": "roundtrip test"}],
            importance=0.9,
        )
        mm.save_episodic_memory_async(uid)
        time.sleep(0.5)

        # Create a new manager that should load from disk
        mm2 = MemoryManager(mm._agent)
        mm2._episodic_dir = mm._episodic_dir
        ms2 = mm2.get_episodic_memory(uid)
        stats = ms2.get_stats()
        assert stats.get("total_stored", 0) > 0


class TestRememberRecall:
    """High-level remember/recall convenience methods."""

    def test_remember_stores_to_memory(self, mm):
        ms = mm.get_episodic_memory("recall_user")
        ms.store_conversation(
            conversation_id="conv-1",
            messages=[{"role": "user", "content": "My cat's name is Luna"}],
            importance=0.9,
        )
        results = ms.retrieve(query="cat name", top_k=5)
        assert len(results) > 0
        assert any("Luna" in m.content for m in results)

    def test_remember_delegates_to_agent_memory(self, mm):
        """remember() calls agent.memory.remember()."""
        mm._agent.memory = MagicMock()
        mm.remember("cat_name", "Luna", "fact")
        mm._agent.memory.remember.assert_called_once_with("cat_name", "Luna", category="fact")

    def test_remember_noop_when_no_memory(self, mm):
        """remember() is a no-op when agent.memory is None."""
        mm._agent.memory = None
        mm.remember("key", "value")  # Should not raise

    def test_recall_delegates_to_agent_memory(self, mm):
        """recall() calls agent.memory.search()."""
        mm._agent.memory = MagicMock()
        mm._agent.memory.search.return_value = [{"key": "cat", "value": "Luna"}]
        result = mm.recall("cat")
        assert len(result) == 1
        mm._agent.memory.search.assert_called_once_with("cat")

    def test_recall_returns_empty_when_no_memory(self, mm):
        """recall() returns [] when agent.memory is None."""
        mm._agent.memory = None
        assert mm.recall("anything") == []


class TestClearHistory:
    """clear_history delegates to agent.history."""

    def test_delegates_to_history(self, mm):
        mm._agent.history = MagicMock()
        mm.clear_history("user1", "telegram")
        mm._agent.history.clear.assert_called_once_with("user1", "telegram")

    def test_default_args(self, mm):
        mm._agent.history = MagicMock()
        mm.clear_history()
        mm._agent.history.clear.assert_called_once_with("default", "default")


class TestUserHistory:
    """Phase 1: User history load/save with data store."""

    def test_get_user_history_fallback(self, mm):
        """Falls back to agent.history when data_store unavailable."""
        mm._agent.history = MagicMock()
        mm._agent.history.get_history.return_value = [{"role": "user", "content": "hi"}]
        ctx = MagicMock()
        ctx.user.id = "user1"
        ctx.user.role.value = "admin"
        with patch("familiar.core.memory_manager.HAS_USER_MANAGEMENT", False):
            result = mm.get_user_history(ctx, "cli")
        assert len(result) == 1

    def test_save_user_history_adds_to_history(self, mm):
        """save_user_history adds messages to agent.history."""
        mm._agent.history = MagicMock()
        ctx = MagicMock()
        ctx.user.id = "user1"
        ctx.user.role.value = "admin"
        with patch("familiar.core.memory_manager.HAS_USER_MANAGEMENT", False):
            mm.save_user_history(ctx, "cli", "hello", "hi there")
        assert mm._agent.history.add_message.call_count == 2


class TestMemoryTreeInit:
    """_init_memory_tree startup behavior."""

    def test_skips_when_tree_unavailable(self, mock_agent):
        """No crash when memory_tree module is missing."""
        with patch("familiar.core.memory_manager.MemoryManager._init_memory_tree") as mock_init:
            mock_init.return_value = None
            mgr = MemoryManager(mock_agent)
            assert mgr is not None
