"""Tests for asyncio.to_thread() timeout handling in daemon.py.

Addresses GitHub issue #39: Add timeouts to asyncio.to_thread() calls.

Without timeouts, hanging embedding operations or DB writes can exhaust
the thread pool and make the daemon unresponsive.
"""

from __future__ import annotations

import asyncio
import contextlib
from pathlib import Path
from unittest.mock import patch

import pytest
import pytest_asyncio

from claude_engram.daemon import MemoryDaemon


@pytest_asyncio.fixture()
async def daemon(tmp_path: Path) -> MemoryDaemon:
    """Create a MemoryDaemon with a temp database."""
    with patch("claude_engram.daemon.MemoryStorage") as mock_cls:
        from claude_engram.storage import MemoryStorage

        real_storage = MemoryStorage(db_path=tmp_path / "test.db")
        mock_cls.return_value = real_storage
        d = MemoryDaemon()
        d.storage = real_storage
        yield d
        real_storage.close()


class TestTimeoutConstants:
    """Verify timeout constants are defined and have reasonable values."""

    def test_timeout_constants_exist(self) -> None:
        """Timeout constants should be defined at module level."""
        from claude_engram import daemon

        assert hasattr(daemon, "TIMEOUT_EMBEDDING")
        assert hasattr(daemon, "TIMEOUT_DB_READ")
        assert hasattr(daemon, "TIMEOUT_DB_WRITE")

    def test_timeout_values_reasonable(self) -> None:
        """Timeout values should be within expected ranges."""
        from claude_engram import daemon

        # Embedding can be slow for large batches
        assert 20.0 <= daemon.TIMEOUT_EMBEDDING <= 60.0

        # DB reads should be fast
        assert 5.0 <= daemon.TIMEOUT_DB_READ <= 15.0

        # DB writes slightly slower than reads
        assert 10.0 <= daemon.TIMEOUT_DB_WRITE <= 30.0


class TestWithTimeoutHelper:
    """Tests for the with_timeout() helper function."""

    def test_with_timeout_exists(self) -> None:
        """with_timeout helper should be defined."""
        from claude_engram import daemon

        assert hasattr(daemon, "with_timeout")
        assert callable(daemon.with_timeout)

    @pytest.mark.asyncio()
    async def test_with_timeout_returns_result(self) -> None:
        """with_timeout should return the coroutine result."""
        from claude_engram.daemon import with_timeout

        async def sample_coro():
            return "success"

        result = await with_timeout(sample_coro(), timeout=5.0)
        assert result == "success"

    @pytest.mark.asyncio()
    async def test_with_timeout_raises_on_timeout(self) -> None:
        """with_timeout should raise asyncio.TimeoutError when timeout exceeded."""
        from claude_engram.daemon import with_timeout

        async def slow_coro():
            await asyncio.sleep(10)
            return "never reached"

        with pytest.raises(asyncio.TimeoutError):
            await with_timeout(slow_coro(), timeout=0.01)

    @pytest.mark.asyncio()
    async def test_with_timeout_accepts_label_for_logging(self) -> None:
        """with_timeout should accept an optional label for logging."""
        from claude_engram.daemon import with_timeout

        async def sample_coro():
            return "ok"

        # Should not raise, label is for logging only
        result = await with_timeout(sample_coro(), timeout=5.0, label="test_operation")
        assert result == "ok"


class TestStoreActionTimeout:
    """Tests for store action timeout handling."""

    @pytest.mark.asyncio()
    async def test_store_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Store action should return error on timeout, not crash."""

        def hanging_store(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - longer than timeout but quick thread release

        daemon.storage.store = hanging_store  # type: ignore[assignment]

        # Patch timeout to be very short for testing
        with patch("claude_engram.daemon.TIMEOUT_DB_WRITE", 0.01):
            result = await daemon._process_request(
                {"action": "store", "content": "test", "category": "lesson"}
            )

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_store_success_within_timeout(self, daemon: MemoryDaemon) -> None:
        """Store should succeed when operation completes within timeout."""
        result = await daemon._process_request(
            {"action": "store", "content": "test content", "category": "lesson"}
        )
        assert result["status"] == "stored"
        assert "id" in result


class TestSearchActionTimeout:
    """Tests for search action timeout handling."""

    @pytest.mark.asyncio()
    async def test_search_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Search action should return error on timeout."""

        def hanging_search(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.search = hanging_search  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_READ", 0.01):
            result = await daemon._process_request({"action": "search", "query": "test query"})

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()


class TestRecentActionTimeout:
    """Tests for recent action timeout handling."""

    @pytest.mark.asyncio()
    async def test_recent_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Recent action should return error on timeout."""

        def hanging_recent(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.get_recent = hanging_recent  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_READ", 0.01):
            result = await daemon._process_request({"action": "recent", "limit": 5})

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()


class TestDeleteActionTimeout:
    """Tests for delete action timeout handling."""

    @pytest.mark.asyncio()
    async def test_delete_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Delete action should return error on timeout."""

        def hanging_delete(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.delete = hanging_delete  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_WRITE", 0.01):
            result = await daemon._process_request({"action": "delete", "id": 1})

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()


class TestStatsActionTimeout:
    """Tests for stats action timeout handling."""

    @pytest.mark.asyncio()
    async def test_stats_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Stats action should return error on timeout."""

        def hanging_stats(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.stats = hanging_stats  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_READ", 0.01):
            result = await daemon._process_request({"action": "stats"})

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()


class TestBatchStoreTimeout:
    """Tests for batch_store action timeout handling."""

    @pytest.mark.asyncio()
    async def test_batch_store_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Batch store should return error on timeout."""

        def hanging_batch_store(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.batch_store = hanging_batch_store  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_WRITE", 0.01):
            result = await daemon._process_request(
                {
                    "action": "batch_store",
                    "items": [{"content": "item 1", "category": "lesson"}],
                }
            )

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()


class TestBatchSearchTimeout:
    """Tests for batch_search action timeout handling."""

    @pytest.mark.asyncio()
    async def test_batch_search_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Batch search should return error on timeout."""

        def hanging_batch_search(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.batch_search = hanging_batch_search  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_READ", 0.01):
            result = await daemon._process_request(
                {"action": "batch_search", "queries": [{"query": "test"}]}
            )

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()


class TestContextualLoadTimeout:
    """Tests for contextual_load action timeout handling."""

    @pytest.mark.asyncio()
    async def test_contextual_load_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Contextual load should return error on timeout."""

        def hanging_search(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.search = hanging_search  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_READ", 0.01):
            result = await daemon._process_request(
                {"action": "contextual_load", "message": "test query"}
            )

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()


class TestCounterActionTimeouts:
    """Tests for counter action timeout handling."""

    @pytest.mark.asyncio()
    async def test_update_counter_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Update counter should return error on timeout."""

        def hanging_update(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.update_counter = hanging_update  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_WRITE", 0.01):
            result = await daemon._process_request({"action": "update_counter", "key": "test"})

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_set_counter_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Set counter should return error on timeout."""

        def hanging_set(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.set_counter = hanging_set  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_WRITE", 0.01):
            result = await daemon._process_request(
                {"action": "set_counter", "key": "test", "value": 10}
            )

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_get_counters_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Get counters should return error on timeout."""

        def hanging_get(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.get_counter = hanging_get  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_READ", 0.01):
            result = await daemon._process_request({"action": "get_counters", "key": "test"})

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_delete_counter_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Delete counter should return error on timeout."""

        def hanging_delete(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.delete_counter = hanging_delete  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_WRITE", 0.01):
            result = await daemon._process_request({"action": "delete_counter", "key": "test"})

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()


class TestHealthCheckTimeout:
    """Tests for health_check action timeout handling."""

    @pytest.mark.asyncio()
    async def test_health_check_db_queries_timeout(self, daemon: MemoryDaemon) -> None:
        """Health check DB queries should return error on timeout."""

        def hanging_db_queries(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon._health_check_db_queries = hanging_db_queries  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_READ", 0.01):
            result = await daemon._process_request({"action": "health_check"})

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()


class TestWhatChangedTimeout:
    """Tests for what_changed action timeout handling."""

    @pytest.mark.asyncio()
    async def test_what_changed_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """What changed action should return error on timeout."""
        from datetime import UTC, datetime

        def hanging_recent(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.get_recent = hanging_recent  # type: ignore[assignment]
        # Set last_session_end so the code doesn't return early (value must be ISO format string)
        daemon._last_session_end["proj:test"] = datetime.now(tz=UTC).isoformat()

        with patch("claude_engram.daemon.TIMEOUT_DB_READ", 0.01):
            result = await daemon._process_request(
                {"action": "what_changed", "proj_tag": "proj:test"}
            )

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()


class TestCheckpointTickTimeout:
    """Tests for checkpoint_tick action timeout handling."""

    @pytest.mark.asyncio()
    async def test_checkpoint_store_timeout_logs_warning(
        self, daemon: MemoryDaemon, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Checkpoint store timeout should log warning but not crash."""

        def hanging_store(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.store = hanging_store  # type: ignore[assignment]

        # Force checkpoint to fire: threshold is 25, so counter needs to reach >= 25 after increment
        # checkpoint_tick does: counter = current + 1, so we set to 24 -> becomes 25 >= 25
        daemon._checkpoint_counters["timeout-test"] = 24

        with patch("claude_engram.daemon.TIMEOUT_DB_WRITE", 0.01):
            result = await daemon._process_request(
                {
                    "action": "checkpoint_tick",
                    "session_id": "timeout-test",
                    "profile": "balanced",
                    "snapshot": "test snapshot",
                }
            )

        # Should still return success (checkpoint is fire-and-forget)
        assert result["checkpoint_fired"] is True


class TestInjectActionTimeout:
    """Tests for inject action timeout handling."""

    @pytest.mark.asyncio()
    async def test_inject_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Inject action should return error on timeout."""

        def hanging_search(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.search = hanging_search  # type: ignore[assignment]

        with patch("claude_engram.daemon.TIMEOUT_DB_READ", 0.01):
            result = await daemon._process_request({"action": "inject", "query": "test"})

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()


class TestOwnershipCheckTimeout:
    """Tests for _check_memory_ownership timeout handling."""

    @pytest.mark.asyncio()
    async def test_ownership_check_timeout_returns_error(self, daemon: MemoryDaemon) -> None:
        """Ownership check timeout should return error on delete."""

        def hanging_ownership_check(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon._check_memory_ownership = hanging_ownership_check  # type: ignore[method-assign]

        with patch("claude_engram.daemon.TIMEOUT_DB_READ", 0.01):
            result = await daemon._process_request(
                {"action": "delete", "id": 1, "proj_tag": "proj:test"}
            )

        assert result["status"] == "error"
        assert "timeout" in result["error"].lower() or "timed out" in result["error"].lower()


class TestPeriodicCleanupTimeout:
    """Tests for _periodic_cleanup timeout handling."""

    @pytest.mark.asyncio()
    async def test_cleanup_timeout_logs_but_continues(
        self, daemon: MemoryDaemon, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Periodic cleanup timeout should log error but not stop the loop."""
        import logging

        cleanup_calls = 0

        def hanging_then_ok():
            nonlocal cleanup_calls
            cleanup_calls += 1
            if cleanup_calls == 1:
                import time

                time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly
            return 0

        daemon.storage.cleanup_expired = hanging_then_ok  # type: ignore[assignment]
        daemon._cleanup_interval = 0.05

        with (
            patch("claude_engram.daemon.TIMEOUT_DB_WRITE", 0.01),
            caplog.at_level(logging.WARNING),
        ):
            task = asyncio.create_task(daemon._periodic_cleanup())
            try:
                await asyncio.sleep(0.2)
            finally:
                task.cancel()
                # CancelledError may or may not be raised depending on task state
                with contextlib.suppress(asyncio.CancelledError):
                    await task

        # Should have attempted cleanup multiple times despite first timeout
        assert cleanup_calls >= 1


class TestEmbeddingPreloadTimeout:
    """Tests for embedding model preload timeout handling."""

    @pytest.mark.asyncio()
    async def test_embedding_timeout_logs_warning(
        self, tmp_path: Path, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Embedding preload timeout should log warning, not crash startup."""
        import logging

        with patch("claude_engram.daemon.MemoryStorage") as mock_cls:
            from claude_engram.storage import MemoryStorage

            real_storage = MemoryStorage(db_path=tmp_path / "embed-timeout.db")
            mock_cls.return_value = real_storage

            def hanging_embedder():
                import time

                time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

            with (
                patch("claude_engram.storage._get_embedder", hanging_embedder),
                patch("claude_engram.daemon.TIMEOUT_EMBEDDING", 0.01),
                caplog.at_level(logging.WARNING),
            ):
                d = MemoryDaemon()
                d.storage = real_storage

                # Simulate the embedding preload from start()
                try:
                    from claude_engram.daemon import with_timeout

                    await with_timeout(
                        asyncio.to_thread(hanging_embedder),
                        timeout=0.01,
                        label="embedding_preload",
                    )
                except TimeoutError:
                    # Expected - this is what start() should handle gracefully
                    pass

            real_storage.close()


class TestTimeoutLogging:
    """Tests for timeout error logging."""

    @pytest.mark.asyncio()
    async def test_timeout_logs_operation_context(
        self, daemon: MemoryDaemon, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Timeout errors should log the operation that timed out."""
        import logging

        def hanging_store(*args, **kwargs):
            import time

            time.sleep(0.5)  # Brief hang - still triggers timeout but releases thread quickly

        daemon.storage.store = hanging_store  # type: ignore[assignment]

        with (
            patch("claude_engram.daemon.TIMEOUT_DB_WRITE", 0.01),
            caplog.at_level(logging.WARNING),
        ):
            result = await daemon._process_request(
                {"action": "store", "content": "test", "category": "lesson"}
            )

        assert result["status"] == "error"
        # Log should mention the operation or action
        log_text = caplog.text.lower()
        assert "timeout" in log_text or "timed out" in log_text


class TestAllToThreadCallsHaveTimeout:
    """Verify all asyncio.to_thread calls use with_timeout wrapper."""

    def test_no_bare_to_thread_calls(self) -> None:
        """There should be no bare asyncio.to_thread calls in _process_request."""
        import inspect
        import re

        from claude_engram.daemon import MemoryDaemon

        source = inspect.getsource(MemoryDaemon._process_request)

        # Find all asyncio.to_thread calls
        to_thread_pattern = r"await\s+asyncio\.to_thread\("
        matches = re.findall(to_thread_pattern, source)

        assert len(matches) == 0, (
            f"Found {len(matches)} bare asyncio.to_thread() calls. "
            "All should use with_timeout() wrapper."
        )

    def test_with_timeout_usage_in_process_request(self) -> None:
        """_process_request should use with_timeout for thread operations."""
        import inspect

        from claude_engram.daemon import MemoryDaemon

        source = inspect.getsource(MemoryDaemon._process_request)

        # Should use with_timeout wrapper
        assert "with_timeout" in source, (
            "_process_request should use with_timeout() for asyncio.to_thread calls"
        )
