"""Tests for extended daemon actions: checkpoint_tick, batch ops, contextual_load, etc.

Uses pytest-asyncio to test the async _process_request method directly,
avoiding actual socket communication.
"""

from __future__ import annotations

import asyncio
import contextlib
import os
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 TestCheckpointTick:
    """Tests for checkpoint_tick action."""

    @pytest.mark.asyncio()
    async def test_increments_counter(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {
                "action": "checkpoint_tick",
                "session_id": "test-session",
                "profile": "balanced",
            }
        )
        assert result["checkpoint_fired"] is False
        assert result["counter"] == 1

    @pytest.mark.asyncio()
    async def test_fires_at_threshold(self, daemon: MemoryDaemon) -> None:
        # balanced threshold is 25
        for _i in range(24):
            await daemon._process_request(
                {
                    "action": "checkpoint_tick",
                    "session_id": "fire-session",
                    "profile": "balanced",
                    "snapshot": "state data",
                }
            )

        result = await daemon._process_request(
            {
                "action": "checkpoint_tick",
                "session_id": "fire-session",
                "profile": "balanced",
                "snapshot": "state data for checkpoint",
            }
        )
        assert result["checkpoint_fired"] is True
        assert result["counter"] == 0

    @pytest.mark.asyncio()
    async def test_respects_profile_frequency(self, daemon: MemoryDaemon) -> None:
        # Ultra threshold is 15
        result = await daemon._process_request(
            {
                "action": "checkpoint_tick",
                "session_id": "ultra-session",
                "profile": "ultra",
            }
        )
        assert result["threshold"] == 15

        # Light threshold is 40
        result = await daemon._process_request(
            {
                "action": "checkpoint_tick",
                "session_id": "light-session",
                "profile": "light",
            }
        )
        assert result["threshold"] == 40

    @pytest.mark.asyncio()
    async def test_resets_counter_after_fire(self, daemon: MemoryDaemon) -> None:
        # Get to threshold (ultra = 15)
        for _ in range(14):
            await daemon._process_request(
                {
                    "action": "checkpoint_tick",
                    "session_id": "reset-test",
                    "profile": "ultra",
                }
            )

        result = await daemon._process_request(
            {
                "action": "checkpoint_tick",
                "session_id": "reset-test",
                "profile": "ultra",
            }
        )
        assert result["checkpoint_fired"] is True

        # Counter should be reset, next tick starts at 1
        result = await daemon._process_request(
            {
                "action": "checkpoint_tick",
                "session_id": "reset-test",
                "profile": "ultra",
            }
        )
        assert result["checkpoint_fired"] is False
        assert result["counter"] == 1


class TestBatchActions:
    """Tests for batch_store and batch_search actions."""

    @pytest.mark.asyncio()
    async def test_batch_store_action(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {
                "action": "batch_store",
                "items": [
                    {"content": "item 1", "category": "lesson"},
                    {"content": "item 2", "category": "decision"},
                ],
            }
        )
        assert result["status"] == "stored"
        assert result["count"] == 2
        assert len(result["ids"]) == 2

    @pytest.mark.asyncio()
    async def test_batch_store_with_duplicate_content(self, daemon: MemoryDaemon) -> None:
        """batch_store with duplicate items should handle gracefully via UNIQUE constraint."""
        result = await daemon._process_request(
            {
                "action": "batch_store",
                "items": [
                    {"content": "duplicate lesson about caching", "category": "lesson"},
                    {"content": "duplicate lesson about caching", "category": "lesson"},
                    {"content": "unique lesson about indexing", "category": "lesson"},
                ],
            }
        )
        assert result["status"] == "stored"
        # UNIQUE constraint on content_hash means the duplicate is silently skipped
        assert result["count"] <= 3
        # Verify via search that only unique content was stored
        search = await daemon._process_request(
            {"action": "search", "query": "lesson about caching", "detail_level": 2}
        )
        # Should find at most 1 match for the duplicate content
        caching_matches = [r for r in search["results"] if "caching" in r["content"].lower()]
        assert len(caching_matches) <= 1

    @pytest.mark.asyncio()
    async def test_batch_search_action(self, daemon: MemoryDaemon) -> None:
        # Store some data first
        await daemon._process_request(
            {
                "action": "store",
                "content": "Python programming language info",
                "category": "lesson",
            }
        )

        result = await daemon._process_request(
            {
                "action": "batch_search",
                "queries": [
                    {"query": "Python"},
                    {"query": "programming"},
                ],
            }
        )
        assert result["count"] == 2
        assert len(result["results"]) == 2


class TestContextualLoad:
    """Tests for contextual_load action."""

    @pytest.mark.asyncio()
    async def test_contextual_load_empty_query(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {
                "action": "contextual_load",
                "message": "",
            }
        )
        assert result["context"] == ""
        assert result["count"] == 0

    @pytest.mark.asyncio()
    async def test_contextual_load_with_query(self, daemon: MemoryDaemon) -> None:
        # Store something first
        await daemon._process_request(
            {
                "action": "store",
                "content": "important project decision about architecture",
                "category": "decision",
            }
        )

        result = await daemon._process_request(
            {
                "action": "contextual_load",
                "message": "architecture decision",
            }
        )
        # Result depends on similarity threshold -- may or may not match
        assert "context" in result
        assert "count" in result


class TestCacheStats:
    """Tests for cache_stats action."""

    @pytest.mark.asyncio()
    async def test_cache_stats_returns_stats(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "cache_stats"})
        assert "cache" in result
        stats = result["cache"]
        assert "hits_exact" in stats
        assert "hits_semantic" in stats
        assert "misses" in stats


class TestStoreActionExtended:
    """Tests for store action with proj_tag."""

    @pytest.mark.asyncio()
    async def test_store_passes_proj_tag(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {
                "action": "store",
                "content": "project memory",
                "category": "decision",
                "proj_tag": "proj:test/repo",
            }
        )
        assert result["status"] == "stored"
        assert result["id"] is not None

    @pytest.mark.asyncio()
    async def test_search_passes_proj_tag_and_include_global(self, daemon: MemoryDaemon) -> None:
        # Store with proj_tag
        await daemon._process_request(
            {
                "action": "store",
                "content": "scoped decision about API design",
                "category": "decision",
                "proj_tag": "proj:test/repo",
            }
        )

        result = await daemon._process_request(
            {
                "action": "search",
                "query": "API design",
                "proj_tag": "proj:test/repo",
                "include_global": True,
            }
        )
        assert "results" in result


class TestSessionStats:
    """Tests for session_stats action."""

    @pytest.mark.asyncio()
    async def test_session_stats_default(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {
                "action": "session_stats",
            }
        )
        assert result["session_id"] == "default"
        assert result["tool_call_count"] == 0

    @pytest.mark.asyncio()
    async def test_session_stats_after_ticks(self, daemon: MemoryDaemon) -> None:
        for _ in range(5):
            await daemon._process_request(
                {
                    "action": "checkpoint_tick",
                    "session_id": "stats-session",
                }
            )

        result = await daemon._process_request(
            {
                "action": "session_stats",
                "session_id": "stats-session",
            }
        )
        assert result["tool_call_count"] == 5


class TestUnknownAction:
    """Tests for unknown action handling."""

    @pytest.mark.asyncio()
    async def test_unknown_action_returns_error(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {
                "action": "nonexistent_action",
            }
        )
        assert "error" in result
        assert "Unknown action" in result["error"]


class TestResponseConsistency:
    """D6: All daemon responses should include a 'status' field."""

    @pytest.mark.asyncio()
    async def test_search_has_status(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "search", "query": "anything"})
        assert result.get("status") == "ok"
        assert "results" in result

    @pytest.mark.asyncio()
    async def test_recent_has_status(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "recent", "limit": 5})
        assert result.get("status") == "ok"
        assert "results" in result

    @pytest.mark.asyncio()
    async def test_stats_has_status(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "stats"})
        assert result.get("status") == "ok"
        assert "total_memories" in result

    @pytest.mark.asyncio()
    async def test_store_has_status(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {"action": "store", "content": "test", "category": "lesson"}
        )
        assert result.get("status") == "stored"

    @pytest.mark.asyncio()
    async def test_delete_has_status(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {"action": "store", "content": "to delete", "category": "lesson"}
        )
        mid = result["id"]
        result = await daemon._process_request({"action": "delete", "id": mid})
        assert result.get("status") == "ok"

    @pytest.mark.asyncio()
    async def test_error_response_has_status(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "nonexistent_action"})
        assert result.get("status") == "error"
        assert "error" in result

    @pytest.mark.asyncio()
    async def test_ping_has_status(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "ping"})
        assert result.get("status") == "pong"


class TestInjectProjectScope:
    """Tests for inject action proj_tag scoping.

    The inject action is used by SessionStart hooks to inject memory context.
    It MUST respect proj_tag to avoid cross-project memory leaks.
    """

    @pytest.mark.asyncio()
    async def test_inject_respects_proj_tag(self, daemon: MemoryDaemon) -> None:
        """Inject with proj_tag should NOT return memories from other projects."""
        # Store in project alpha
        await daemon._process_request(
            {
                "action": "store",
                "content": "alpha architecture uses microservices",
                "category": "decision",
                "proj_tag": "proj:alpha",
            }
        )
        # Store in project beta
        await daemon._process_request(
            {
                "action": "store",
                "content": "beta architecture uses monolith",
                "category": "decision",
                "proj_tag": "proj:beta",
            }
        )

        # Inject scoped to alpha
        result = await daemon._process_request(
            {
                "action": "inject",
                "query": "architecture",
                "proj_tag": "proj:alpha",
            }
        )
        assert result["count"] > 0
        assert "alpha" in result["context"].lower() or "microservices" in result["context"].lower()
        # beta's monolith memory should NOT appear
        assert "monolith" not in result["context"].lower()

    @pytest.mark.asyncio()
    async def test_inject_without_proj_tag_returns_all(self, daemon: MemoryDaemon) -> None:
        """Inject without proj_tag should return memories from all projects."""
        await daemon._process_request(
            {
                "action": "store",
                "content": "gamma project uses Redis caching",
                "category": "decision",
                "proj_tag": "proj:gamma",
            }
        )
        await daemon._process_request(
            {
                "action": "store",
                "content": "delta project uses Memcached caching",
                "category": "decision",
                "proj_tag": "proj:delta",
            }
        )

        # Inject without proj_tag — should return all
        result = await daemon._process_request(
            {
                "action": "inject",
                "query": "caching",
            }
        )
        assert result["count"] >= 2

    @pytest.mark.asyncio()
    async def test_inject_includes_global_memories(self, daemon: MemoryDaemon) -> None:
        """Inject with proj_tag should include unscoped global memories."""
        # Store a global memory (no proj_tag)
        await daemon._process_request(
            {
                "action": "store",
                "content": "global lesson about database indexing strategy",
                "category": "lesson",
            }
        )
        # Store a project-scoped memory
        await daemon._process_request(
            {
                "action": "store",
                "content": "epsilon project database schema design",
                "category": "decision",
                "proj_tag": "proj:epsilon",
            }
        )

        # Inject scoped to epsilon — should include global + epsilon
        result = await daemon._process_request(
            {
                "action": "inject",
                "query": "database",
                "proj_tag": "proj:epsilon",
            }
        )
        assert result["count"] >= 1
        # Global memory about indexing should be included
        assert "indexing" in result["context"].lower() or "database" in result["context"].lower()


class TestGetConfig:
    """Tests for get_config daemon action."""

    @pytest.mark.asyncio()
    async def test_returns_defaults_before_star_startup(self, daemon: MemoryDaemon) -> None:
        """get_config should return default profile and null proj_tag."""
        result = await daemon._process_request({"action": "get_config"})
        assert result["status"] == "ok"
        assert result["profile"] == "balanced"
        assert result["proj_tag"] is None

    @pytest.mark.asyncio()
    async def test_returns_updated_after_star_startup(self, daemon: MemoryDaemon) -> None:
        """After star_startup, get_config should reflect the startup values."""
        # Simulate star_startup having cached config
        daemon._last_startup_config["profile"] = "ultra"
        daemon._last_startup_config["proj_tag"] = "proj:test/myproject"

        result = await daemon._process_request({"action": "get_config"})
        assert result["status"] == "ok"
        assert result["profile"] == "ultra"
        assert result["proj_tag"] == "proj:test/myproject"

    @pytest.mark.asyncio()
    async def test_returns_defaults_without_star_startup(self, daemon: MemoryDaemon) -> None:
        """get_config always returns valid defaults even if star never called."""
        result = await daemon._process_request({"action": "get_config"})
        assert result["profile"] == "balanced"
        assert result["proj_tag"] is None


class TestWhatChanged:
    """Tests for what_changed daemon action."""

    @pytest.mark.asyncio()
    async def test_no_prior_session_returns_zero(self, daemon: MemoryDaemon) -> None:
        """what_changed with no prior session returns count=0."""
        result = await daemon._process_request(
            {"action": "what_changed", "proj_tag": "proj:test/repo"}
        )
        assert result["status"] == "ok"
        assert result["count"] == 0
        assert result["categories"] == {}

    @pytest.mark.asyncio()
    async def test_detects_cross_window_decision(self, daemon: MemoryDaemon) -> None:
        """After session_summary + decision store, what_changed detects the decision."""
        proj_tag = "proj:test/crosswin"

        # Store a session_summary (marks session end)
        await daemon._process_request(
            {
                "action": "store",
                "content": "session summary of work done",
                "category": "session_summary",
                "proj_tag": proj_tag,
            }
        )
        # Small delay so the "after" memory timestamp is strictly after _last_session_end
        await asyncio.sleep(0.05)

        # Store a decision (simulating another window)
        await daemon._process_request(
            {
                "action": "store",
                "content": "decided to use PostgreSQL",
                "category": "decision",
                "proj_tag": proj_tag,
            }
        )

        result = await daemon._process_request({"action": "what_changed", "proj_tag": proj_tag})
        assert result["status"] == "ok"
        assert result["count"] == 1
        assert result["categories"]["decision"] == 1

    @pytest.mark.asyncio()
    async def test_filters_transient_categories(self, daemon: MemoryDaemon) -> None:
        """Transient categories like checkpoint should NOT appear in what_changed."""
        proj_tag = "proj:test/filter"

        # Store session_summary (marks end)
        await daemon._process_request(
            {
                "action": "store",
                "content": "session ended",
                "category": "session_summary",
                "proj_tag": proj_tag,
            }
        )
        await asyncio.sleep(0.05)

        # Store a checkpoint (transient category)
        await daemon._process_request(
            {
                "action": "store",
                "content": "checkpoint state data",
                "category": "checkpoint",
                "proj_tag": proj_tag,
            }
        )

        result = await daemon._process_request({"action": "what_changed", "proj_tag": proj_tag})
        assert result["count"] == 0
        assert "checkpoint" not in result["categories"]

    @pytest.mark.asyncio()
    async def test_different_proj_tag_isolation(self, daemon: MemoryDaemon) -> None:
        """what_changed for a different proj_tag returns count=0."""
        proj_a = "proj:test/project-a"
        proj_b = "proj:test/project-b"

        # End session on project A
        await daemon._process_request(
            {
                "action": "store",
                "content": "session summary A",
                "category": "session_summary",
                "proj_tag": proj_a,
            }
        )
        await asyncio.sleep(0.05)

        # Store decision on project A
        await daemon._process_request(
            {
                "action": "store",
                "content": "decision for A",
                "category": "decision",
                "proj_tag": proj_a,
            }
        )

        # Query project B -- should see nothing
        result = await daemon._process_request({"action": "what_changed", "proj_tag": proj_b})
        assert result["count"] == 0

    @pytest.mark.asyncio()
    async def test_last_session_end_updated_on_session_summary(self, daemon: MemoryDaemon) -> None:
        """_last_session_end should be updated every time a session_summary is stored."""
        proj_tag = "proj:test/update"

        # No session end initially
        assert proj_tag not in daemon._last_session_end

        # Store session_summary
        await daemon._process_request(
            {
                "action": "store",
                "content": "first session summary",
                "category": "session_summary",
                "proj_tag": proj_tag,
            }
        )
        assert proj_tag in daemon._last_session_end
        first_end = daemon._last_session_end[proj_tag]

        await asyncio.sleep(0.05)

        # Store another session_summary
        await daemon._process_request(
            {
                "action": "store",
                "content": "second session summary",
                "category": "session_summary",
                "proj_tag": proj_tag,
            }
        )
        second_end = daemon._last_session_end[proj_tag]
        assert second_end > first_end


class TestWhatChangedValidation:
    """Negative tests for what_changed action."""

    @pytest.mark.asyncio()
    async def test_what_changed_requires_proj_tag(self, daemon: MemoryDaemon) -> None:
        """what_changed without proj_tag should return error."""
        result = await daemon._process_request({"action": "what_changed"})
        assert result["status"] == "error"
        assert "proj_tag" in result["error"]


class TestContextualLoadCachePromotion:
    """contextual_load should promote semantic hits to exact cache."""

    @pytest.mark.asyncio()
    async def test_second_identical_query_hits_exact_cache(self, daemon: MemoryDaemon) -> None:
        """After a semantic hit, the same query should hit exact cache."""
        await daemon._process_request(
            {
                "action": "store",
                "content": "architecture decision about microservices",
                "category": "decision",
            }
        )

        # First call — may or may not find a semantic match (depends on threshold)
        result1 = await daemon._process_request(
            {"action": "contextual_load", "query": "architecture decision about microservices"}
        )
        if result1["count"] == 0:
            pytest.skip("Similarity below threshold — cannot test cache promotion")

        # Second identical call — should hit exact cache
        exact_before = daemon.cache.stats["hits_exact"]
        await daemon._process_request(
            {"action": "contextual_load", "query": "architecture decision about microservices"}
        )
        exact_after = daemon.cache.stats["hits_exact"]
        assert exact_after == exact_before + 1


class TestCrossWindowIntegration:
    """Integration tests: get_config + what_changed cross-window interaction.

    Tests the full flow of cross-window awareness where multiple
    Claude sessions (windows) share state through the daemon.
    """

    @pytest.mark.asyncio()
    async def test_session_lifecycle_updates_last_session_end(self, daemon: MemoryDaemon) -> None:
        """Store session_summary, verify get_config defaults, then what_changed returns 0."""
        proj_tag = "proj:test/lifecycle"

        # get_config returns defaults (no star_startup called)
        config = await daemon._process_request({"action": "get_config"})
        assert config["profile"] == "balanced"
        assert config["proj_tag"] is None

        # Store session_summary (marks session end)
        await daemon._process_request(
            {
                "action": "store",
                "content": "session completed with 5 tasks done",
                "category": "session_summary",
                "proj_tag": proj_tag,
            }
        )

        # _last_session_end was set AFTER store, so what_changed should
        # return count=0 (no new memories since the session end timestamp)
        result = await daemon._process_request({"action": "what_changed", "proj_tag": proj_tag})
        assert result["status"] == "ok"
        assert result["count"] == 0
        assert result["categories"] == {}

    @pytest.mark.asyncio()
    async def test_cross_window_scenario(self, daemon: MemoryDaemon) -> None:
        """Window A ends session, Window B stores 2 memories, Window A sees them."""
        proj_tag = "proj:test/cross-window"

        # Window A: store session_summary (marks session end)
        await daemon._process_request(
            {
                "action": "store",
                "content": "Window A session ended after refactoring auth module",
                "category": "session_summary",
                "proj_tag": proj_tag,
            }
        )
        # Small delay so memories stored after have strictly later timestamp
        await asyncio.sleep(0.05)

        # Window B: store a decision
        await daemon._process_request(
            {
                "action": "store",
                "content": "decided to use PostgreSQL for the new service",
                "category": "decision",
                "proj_tag": proj_tag,
            }
        )

        # Window B: store a lesson
        await daemon._process_request(
            {
                "action": "store",
                "content": "lesson learned: always add indexes on foreign keys",
                "category": "lesson",
                "proj_tag": proj_tag,
            }
        )

        # Window A: check what changed since session end
        result = await daemon._process_request({"action": "what_changed", "proj_tag": proj_tag})
        assert result["status"] == "ok"
        assert result["count"] == 2
        assert result["categories"]["decision"] == 1
        assert result["categories"]["lesson"] == 1

    @pytest.mark.asyncio()
    async def test_transient_categories_excluded(self, daemon: MemoryDaemon) -> None:
        """Transient categories (checkpoint, file_ownership) excluded from what_changed."""
        proj_tag = "proj:test/transient-exclusion"

        # End session
        await daemon._process_request(
            {
                "action": "store",
                "content": "session ended",
                "category": "session_summary",
                "proj_tag": proj_tag,
            }
        )
        await asyncio.sleep(0.05)

        # Store checkpoint (transient -- volatile TTL tier)
        await daemon._process_request(
            {
                "action": "store",
                "content": "checkpoint: agent state snapshot",
                "category": "checkpoint",
                "proj_tag": proj_tag,
            }
        )

        # Store file_ownership (transient -- session TTL tier, not in CROSS_WINDOW)
        await daemon._process_request(
            {
                "action": "store",
                "content": "file ownership: src/main.py -> auth module",
                "category": "file_ownership",
                "proj_tag": proj_tag,
            }
        )

        # what_changed should return 0 (both categories excluded from CROSS_WINDOW)
        result = await daemon._process_request({"action": "what_changed", "proj_tag": proj_tag})
        assert result["count"] == 0
        assert "checkpoint" not in result["categories"]
        assert "file_ownership" not in result["categories"]

    @pytest.mark.asyncio()
    async def test_daemon_restart_graceful_degradation(self, tmp_path: Path) -> None:
        """Fresh daemon (simulating restart) returns count=0 for what_changed."""
        with patch("claude_engram.daemon.MemoryStorage") as mock_cls:
            from claude_engram.storage import MemoryStorage

            real_storage = MemoryStorage(db_path=tmp_path / "restart-test.db")
            mock_cls.return_value = real_storage
            fresh_daemon = MemoryDaemon()
            fresh_daemon.storage = real_storage

            try:
                # _last_session_end is empty on fresh daemon
                assert len(fresh_daemon._last_session_end) == 0

                result = await fresh_daemon._process_request(
                    {"action": "what_changed", "proj_tag": "proj:test/restart"}
                )
                assert result["status"] == "ok"
                assert result["count"] == 0
                assert result["categories"] == {}
            finally:
                real_storage.close()

    @pytest.mark.asyncio()
    async def test_get_config_returns_defaults_before_star_startup(self, tmp_path: Path) -> None:
        """Fresh daemon returns default profile=balanced, proj_tag=None."""
        with patch("claude_engram.daemon.MemoryStorage") as mock_cls:
            from claude_engram.storage import MemoryStorage

            real_storage = MemoryStorage(db_path=tmp_path / "config-test.db")
            mock_cls.return_value = real_storage
            fresh_daemon = MemoryDaemon()
            fresh_daemon.storage = real_storage

            try:
                result = await fresh_daemon._process_request({"action": "get_config"})
                assert result["status"] == "ok"
                assert result["profile"] == "balanced"
                assert result["proj_tag"] is None
            finally:
                real_storage.close()


# ============================================================================
# NEW TESTS for the 11 daemon fixes
# ============================================================================


class TestQ3InitCleanupTask:
    """Q3: _cleanup_task initialized in __init__, stop() uses None check."""

    @pytest.mark.asyncio()
    async def test_stop_without_start_does_not_raise(self, tmp_path: Path) -> None:
        """Call stop() before start() -- should not crash."""
        with patch("claude_engram.daemon.MemoryStorage") as mock_cls:
            from claude_engram.storage import MemoryStorage

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

            try:
                # _cleanup_task is None, stop() should not raise
                assert d._cleanup_task is None
                await d.stop()  # Should not raise AttributeError
            finally:
                real_storage.close()

    @pytest.mark.asyncio()
    async def test_cleanup_task_initialized_to_none(self, daemon: MemoryDaemon) -> None:
        """_cleanup_task should be None after init, before start()."""
        assert daemon._cleanup_task is None


class TestSignalHandlerCallsStop:
    """Fix #1: Signal handler shutdown event must trigger stop() for cleanup."""

    @pytest.mark.asyncio()
    async def test_shutdown_event_triggers_stop(self, tmp_path: Path) -> None:
        """After _shutdown is set, start() should call stop() for cleanup."""
        with patch("claude_engram.daemon.MemoryStorage") as mock_cls:
            from claude_engram.storage import MemoryStorage

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

            # Track whether stop() was called by patching storage.close()
            close_called = False
            original_close = real_storage.close

            def tracking_close():
                nonlocal close_called
                close_called = True
                original_close()

            real_storage.close = tracking_close

            # Simulate: start() sets up server, then signal fires _shutdown.set()
            # We can't run full start() (needs socket), so test the contract:
            # after _shutdown.wait() returns, stop() must be called
            d._shutdown.set()
            await d.stop()
            assert close_called, "storage.close() must be called during shutdown"

    def test_start_calls_stop_after_shutdown(self) -> None:
        """Verify start() source code calls stop() after shutdown.wait()."""
        import inspect

        from claude_engram.daemon import MemoryDaemon

        source = inspect.getsource(MemoryDaemon.start)
        # After await self._shutdown.wait(), there should be await self.stop()
        assert "await self.stop()" in source, (
            "start() must call await self.stop() after _shutdown.wait()"
        )


class TestQ4DeprecatedEventLoop:
    """Q4: get_running_loop() replaces get_event_loop()."""

    def test_no_get_event_loop_in_source(self) -> None:
        """Verify get_event_loop() is NOT called in daemon.py source."""
        import inspect

        from claude_engram.daemon import MemoryDaemon

        source = inspect.getsource(MemoryDaemon.start)
        # Check that the deprecated call pattern is not present
        assert "asyncio.get_event_loop()" not in source
        assert "asyncio.get_running_loop()" in source


class TestQ5TTLCacheBounded:
    """Q5: _checkpoint_counters and _last_session_end use TTLCache."""

    def test_checkpoint_counters_is_ttlcache(self, daemon: MemoryDaemon) -> None:
        from cachetools import TTLCache

        assert isinstance(daemon._checkpoint_counters, TTLCache)

    def test_last_session_end_is_ttlcache(self, daemon: MemoryDaemon) -> None:
        from cachetools import TTLCache

        assert isinstance(daemon._last_session_end, TTLCache)

    @pytest.mark.asyncio()
    async def test_checkpoint_tick_still_works(self, daemon: MemoryDaemon) -> None:
        """checkpoint_tick should work correctly with TTLCache backing."""
        result = await daemon._process_request(
            {
                "action": "checkpoint_tick",
                "session_id": "ttl-test",
                "profile": "balanced",
            }
        )
        assert result["counter"] == 1
        assert result["checkpoint_fired"] is False

    @pytest.mark.asyncio()
    async def test_what_changed_still_works(self, daemon: MemoryDaemon) -> None:
        """what_changed should work with TTLCache-backed _last_session_end."""
        proj_tag = "proj:test/ttl-test"
        await daemon._process_request(
            {
                "action": "store",
                "content": "session ended for ttl test",
                "category": "session_summary",
                "proj_tag": proj_tag,
            }
        )
        assert proj_tag in daemon._last_session_end
        result = await daemon._process_request({"action": "what_changed", "proj_tag": proj_tag})
        assert result["status"] == "ok"


class TestQ7EnvConfigurable:
    """Q7: Checkpoint thresholds and cleanup interval configurable via env."""

    def test_default_thresholds(self, daemon: MemoryDaemon) -> None:
        assert daemon._checkpoint_thresholds["light"] == 40
        assert daemon._checkpoint_thresholds["balanced"] == 25
        assert daemon._checkpoint_thresholds["ultra"] == 15

    def test_env_overrides_threshold(self, tmp_path: Path) -> None:
        with (
            patch("claude_engram.daemon.MemoryStorage") as mock_cls,
            patch.dict(os.environ, {"ENGRAM_CHECKPOINT_ULTRA": "5"}),
        ):
            from claude_engram.storage import MemoryStorage

            real_storage = MemoryStorage(db_path=tmp_path / "q7-test.db")
            mock_cls.return_value = real_storage
            d = MemoryDaemon()
            d.storage = real_storage
            try:
                assert d._checkpoint_thresholds["ultra"] == 5
                assert d._checkpoint_thresholds["light"] == 40
            finally:
                real_storage.close()

    def test_env_overrides_cleanup_interval(self, tmp_path: Path) -> None:
        with (
            patch("claude_engram.daemon.MemoryStorage") as mock_cls,
            patch.dict(os.environ, {"ENGRAM_CLEANUP_INTERVAL": "1800"}),
        ):
            from claude_engram.storage import MemoryStorage

            real_storage = MemoryStorage(db_path=tmp_path / "q7-interval.db")
            mock_cls.return_value = real_storage
            d = MemoryDaemon()
            d.storage = real_storage
            try:
                assert d._cleanup_interval == 1800
            finally:
                real_storage.close()

    def test_default_cleanup_interval(self, daemon: MemoryDaemon) -> None:
        assert daemon._cleanup_interval == 3600


class TestC1AsyncWrapping:
    """C1: recent, delete, stats, health_check use asyncio.to_thread()."""

    @pytest.mark.asyncio()
    async def test_recent_returns_results(self, daemon: MemoryDaemon) -> None:
        await daemon._process_request(
            {"action": "store", "content": "test recent async", "category": "lesson"}
        )
        result = await daemon._process_request({"action": "recent", "limit": 5})
        assert result["status"] == "ok"
        assert isinstance(result["results"], list)

    @pytest.mark.asyncio()
    async def test_delete_returns_result(self, daemon: MemoryDaemon) -> None:
        store_result = await daemon._process_request(
            {"action": "store", "content": "test delete async", "category": "lesson"}
        )
        mid = store_result["id"]
        result = await daemon._process_request({"action": "delete", "id": mid})
        assert result["status"] == "ok"
        assert result["deleted"] is True

    @pytest.mark.asyncio()
    async def test_stats_returns_result(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "stats"})
        assert result["status"] == "ok"
        assert "total_memories" in result


class TestC4DeleteOwnership:
    """C4: Delete action verifies proj_tag ownership."""

    @pytest.mark.asyncio()
    async def test_delete_wrong_proj_tag_fails(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {
                "action": "store",
                "content": "memory owned by project A for delete test",
                "category": "decision",
                "proj_tag": "proj:A",
            }
        )
        mem_id = result["id"]
        result = await daemon._process_request(
            {"action": "delete", "id": mem_id, "proj_tag": "proj:B"}
        )
        assert result["status"] == "error"
        assert "does not belong" in result["error"]

    @pytest.mark.asyncio()
    async def test_delete_correct_proj_tag_succeeds(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {
                "action": "store",
                "content": "memory owned by project A for successful delete",
                "category": "decision",
                "proj_tag": "proj:A",
            }
        )
        mem_id = result["id"]
        result = await daemon._process_request(
            {"action": "delete", "id": mem_id, "proj_tag": "proj:A"}
        )
        assert result["status"] == "ok"
        assert result["deleted"] is True

    @pytest.mark.asyncio()
    async def test_delete_without_proj_tag_always_works(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {
                "action": "store",
                "content": "memory for unrestricted delete",
                "category": "lesson",
                "proj_tag": "proj:X",
            }
        )
        mem_id = result["id"]
        result = await daemon._process_request({"action": "delete", "id": mem_id})
        assert result["status"] == "ok"
        assert result["deleted"] is True

    @pytest.mark.asyncio()
    async def test_delete_nonexistent_with_proj_tag(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {"action": "delete", "id": 99999, "proj_tag": "proj:A"}
        )
        assert result["status"] == "ok"
        assert result["deleted"] is False


class TestH5InputValidation:
    """H5: Input validation for required fields and limits."""

    @pytest.mark.asyncio()
    async def test_store_missing_content(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "store", "category": "lesson"})
        assert result["status"] == "error"
        assert "content" in result["error"]

    @pytest.mark.asyncio()
    async def test_store_content_too_long(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {"action": "store", "content": "x" * 60_000, "category": "lesson"}
        )
        assert result["status"] == "error"
        assert "string under" in result["error"]

    @pytest.mark.asyncio()
    async def test_store_content_not_string(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {"action": "store", "content": 12345, "category": "lesson"}
        )
        assert result["status"] == "error"

    @pytest.mark.asyncio()
    async def test_search_missing_query(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "search"})
        assert result["status"] == "error"
        assert "query" in result["error"]

    @pytest.mark.asyncio()
    async def test_delete_missing_id(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "delete"})
        assert result["status"] == "error"
        assert "id" in result["error"]

    @pytest.mark.asyncio()
    async def test_batch_store_exceeds_max(self, daemon: MemoryDaemon) -> None:
        items = [{"content": f"item {i}", "category": "lesson"} for i in range(51)]
        result = await daemon._process_request({"action": "batch_store", "items": items})
        assert result["status"] == "error"
        assert "maximum" in result["error"]

    @pytest.mark.asyncio()
    async def test_batch_search_exceeds_max(self, daemon: MemoryDaemon) -> None:
        queries = [{"query": f"query {i}"} for i in range(51)]
        result = await daemon._process_request({"action": "batch_search", "queries": queries})
        assert result["status"] == "error"
        assert "maximum" in result["error"]

    @pytest.mark.asyncio()
    async def test_batch_store_within_limit(self, daemon: MemoryDaemon) -> None:
        items = [{"content": f"valid item {i}", "category": "lesson"} for i in range(3)]
        result = await daemon._process_request({"action": "batch_store", "items": items})
        assert result["status"] == "stored"
        assert result["count"] == 3

    @pytest.mark.asyncio()
    async def test_batch_store_item_content_too_long(self, daemon: MemoryDaemon) -> None:
        """Fix #4: batch_store must validate content length per item."""
        items = [
            {"content": "short ok", "category": "lesson"},
            {"content": "x" * 60_000, "category": "lesson"},  # exceeds 50K
        ]
        result = await daemon._process_request({"action": "batch_store", "items": items})
        assert result["status"] == "error"
        assert "Item 1" in result["error"]

    @pytest.mark.asyncio()
    async def test_batch_store_item_content_not_string(self, daemon: MemoryDaemon) -> None:
        """Fix #4: batch_store must reject non-string content."""
        items = [{"content": 12345, "category": "lesson"}]
        result = await daemon._process_request({"action": "batch_store", "items": items})
        assert result["status"] == "error"
        assert "Item 0" in result["error"]


class TestQ9WhatChangedLimit:
    """Q9: what_changed returns only CROSS_WINDOW_CATEGORIES."""

    @pytest.mark.asyncio()
    async def test_what_changed_filters_categories(self, daemon: MemoryDaemon) -> None:
        proj_tag = "proj:test/q9"
        await daemon._process_request(
            {
                "action": "store",
                "content": "session ended for Q9 test",
                "category": "session_summary",
                "proj_tag": proj_tag,
            }
        )
        await asyncio.sleep(0.05)
        await daemon._process_request(
            {
                "action": "store",
                "content": "a decision for Q9",
                "category": "decision",
                "proj_tag": proj_tag,
            }
        )
        await daemon._process_request(
            {
                "action": "store",
                "content": "a checkpoint for Q9",
                "category": "checkpoint",
                "proj_tag": proj_tag,
            }
        )
        await daemon._process_request(
            {
                "action": "store",
                "content": "a lesson for Q9",
                "category": "lesson",
                "proj_tag": proj_tag,
            }
        )
        result = await daemon._process_request({"action": "what_changed", "proj_tag": proj_tag})
        assert result["status"] == "ok"
        assert result["count"] == 2
        assert "decision" in result["categories"]
        assert "lesson" in result["categories"]
        assert "checkpoint" not in result["categories"]


class TestH4InjectDedup:
    """H4: contextual_load deduplicates with inject."""

    @pytest.mark.asyncio()
    async def test_inject_tracks_ids_by_session(self, daemon: MemoryDaemon) -> None:
        await daemon._process_request(
            {
                "action": "store",
                "content": "architecture decision about microservices for dedup test",
                "category": "decision",
                "proj_tag": "proj:dedup",
            }
        )
        result = await daemon._process_request(
            {
                "action": "inject",
                "query": "architecture microservices",
                "proj_tag": "proj:dedup",
                "session_id": "sess-001",
            }
        )
        if result["count"] > 0:
            assert "sess-001" in daemon._injected_ids
            assert len(daemon._injected_ids["sess-001"]) > 0

    @pytest.mark.asyncio()
    async def test_contextual_load_filters_injected(self, daemon: MemoryDaemon) -> None:
        """contextual_load should not return memories already injected.

        Uses two memories: one pre-injected (should be filtered) and one not
        (should appear). This isolates dedup from similarity threshold behavior.
        """
        # Store two related memories
        result_a = await daemon._process_request(
            {
                "action": "store",
                "content": "service mesh topology using Istio sidecar pattern",
                "category": "decision",
                "proj_tag": "proj:dedup-filter",
            }
        )
        await daemon._process_request(
            {
                "action": "store",
                "content": "service mesh observability with distributed tracing",
                "category": "lesson",
                "proj_tag": "proj:dedup-filter",
            }
        )
        # Pre-inject only memory A
        daemon._injected_ids["sess-filter"] = {result_a["id"]}

        result = await daemon._process_request(
            {
                "action": "contextual_load",
                "query": "service mesh",
                "proj_tag": "proj:dedup-filter",
                "session_id": "sess-filter",
            }
        )
        if result["count"] == 0:
            pytest.skip("Neither memory met similarity threshold")

        context = result.get("context", "").lower()
        # Memory A (Istio sidecar) should be filtered out by dedup
        assert "istio" not in context and "sidecar" not in context, (
            "Injected memory A should be filtered by dedup"
        )
        # Memory B (observability/tracing) should remain
        assert "observability" in context or "tracing" in context, (
            "Non-injected memory B should appear in results"
        )

    @pytest.mark.asyncio()
    async def test_inject_without_session_id_falls_back(self, daemon: MemoryDaemon) -> None:
        await daemon._process_request(
            {
                "action": "store",
                "content": "global memory about deploy strategy",
                "category": "lesson",
            }
        )
        result = await daemon._process_request({"action": "inject", "query": "deploy strategy"})
        if result["count"] > 0:
            # Without session_id or proj_tag, falls back to "global"
            assert "global" in daemon._injected_ids

    @pytest.mark.asyncio()
    async def test_concurrent_sessions_dont_overwrite(self, daemon: MemoryDaemon) -> None:
        """Fix #3: Two sessions with same proj_tag must not overwrite each other's IDs."""
        await daemon._process_request(
            {
                "action": "store",
                "content": "memory alpha for concurrency dedup test",
                "category": "decision",
                "proj_tag": "proj:shared",
            }
        )
        await daemon._process_request(
            {
                "action": "store",
                "content": "memory beta for concurrency dedup test",
                "category": "lesson",
                "proj_tag": "proj:shared",
            }
        )
        # Session A injects
        await daemon._process_request(
            {
                "action": "inject",
                "query": "concurrency dedup",
                "proj_tag": "proj:shared",
                "session_id": "sess-A",
            }
        )
        ids_a = daemon._injected_ids.get("sess-A", set()).copy()

        # Session B injects — must NOT overwrite sess-A
        await daemon._process_request(
            {
                "action": "inject",
                "query": "concurrency dedup",
                "proj_tag": "proj:shared",
                "session_id": "sess-B",
            }
        )
        ids_a_after = daemon._injected_ids.get("sess-A", set())
        assert ids_a == ids_a_after, "Session B must not overwrite session A's injected IDs"


# ============================================================================
# NEW TESTS: Counter daemon actions
# ============================================================================


class TestCounterActions:
    """Tests for counter daemon actions: update, set, get, delete."""

    @pytest.mark.asyncio()
    async def test_update_counter_action(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {"action": "update_counter", "key": "serena_calls", "delta": 1}
        )
        assert result["status"] == "ok"
        assert result["key"] == "serena_calls"
        assert result["value"] == 1

        # Increment again
        result = await daemon._process_request(
            {"action": "update_counter", "key": "serena_calls", "delta": 5}
        )
        assert result["value"] == 6

    @pytest.mark.asyncio()
    async def test_update_counter_default_delta(self, daemon: MemoryDaemon) -> None:
        """update_counter without delta should default to 1."""
        result = await daemon._process_request({"action": "update_counter", "key": "default_delta"})
        assert result["value"] == 1

    @pytest.mark.asyncio()
    async def test_update_counter_with_proj_tag(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {
                "action": "update_counter",
                "key": "tool_calls",
                "delta": 3,
                "proj_tag": "proj:my-repo",
            }
        )
        assert result["status"] == "ok"
        assert result["proj_tag"] == "proj:my-repo"
        assert result["value"] == 3

    @pytest.mark.asyncio()
    async def test_update_counter_missing_key(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "update_counter"})
        assert result["status"] == "error"
        assert "key" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_set_counter_action(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request(
            {"action": "set_counter", "key": "metric", "value": 42}
        )
        assert result["status"] == "ok"
        assert result["value"] == 42

        # Verify via get_counters
        get_result = await daemon._process_request({"action": "get_counters", "key": "metric"})
        assert get_result["value"] == 42

    @pytest.mark.asyncio()
    async def test_set_counter_missing_key(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "set_counter", "value": 10})
        assert result["status"] == "error"
        assert "key" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_set_counter_missing_value(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "set_counter", "key": "metric"})
        assert result["status"] == "error"
        assert "value" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_get_counters_single_key(self, daemon: MemoryDaemon) -> None:
        await daemon._process_request({"action": "update_counter", "key": "reads", "delta": 7})
        result = await daemon._process_request({"action": "get_counters", "key": "reads"})
        assert result["status"] == "ok"
        assert result["value"] == 7

    @pytest.mark.asyncio()
    async def test_get_counters_all_keys(self, daemon: MemoryDaemon) -> None:
        await daemon._process_request(
            {"action": "update_counter", "key": "a", "delta": 1, "proj_tag": "proj:t"}
        )
        await daemon._process_request(
            {"action": "update_counter", "key": "b", "delta": 2, "proj_tag": "proj:t"}
        )
        result = await daemon._process_request({"action": "get_counters", "proj_tag": "proj:t"})
        assert result["status"] == "ok"
        assert result["counters"] == {"a": 1, "b": 2}

    @pytest.mark.asyncio()
    async def test_get_counters_missing_key_returns_zero(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "get_counters", "key": "nonexistent"})
        assert result["status"] == "ok"
        assert result["value"] == 0

    @pytest.mark.asyncio()
    async def test_delete_counter_action(self, daemon: MemoryDaemon) -> None:
        await daemon._process_request({"action": "update_counter", "key": "temp", "delta": 1})
        result = await daemon._process_request({"action": "delete_counter", "key": "temp"})
        assert result["status"] == "ok"
        assert result["deleted"] is True

        # Verify it's gone
        get_result = await daemon._process_request({"action": "get_counters", "key": "temp"})
        assert get_result["value"] == 0

    @pytest.mark.asyncio()
    async def test_delete_counter_missing_key_param(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "delete_counter"})
        assert result["status"] == "error"
        assert "key" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_delete_counter_nonexistent(self, daemon: MemoryDaemon) -> None:
        result = await daemon._process_request({"action": "delete_counter", "key": "ghost"})
        assert result["status"] == "ok"
        assert result["deleted"] is False

    # -- Type validation tests --

    @pytest.mark.asyncio()
    async def test_update_counter_string_delta(self, daemon: MemoryDaemon) -> None:
        """String delta should be rejected."""
        result = await daemon._process_request(
            {"action": "update_counter", "key": "x", "delta": "5"}
        )
        assert result["status"] == "error"
        assert "delta" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_update_counter_float_delta(self, daemon: MemoryDaemon) -> None:
        """Float delta should be rejected (must be int)."""
        result = await daemon._process_request(
            {"action": "update_counter", "key": "x", "delta": 1.5}
        )
        assert result["status"] == "error"
        assert "delta" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_update_counter_bool_delta(self, daemon: MemoryDaemon) -> None:
        """Bool delta should be rejected (bool is subclass of int in Python)."""
        result = await daemon._process_request(
            {"action": "update_counter", "key": "x", "delta": True}
        )
        assert result["status"] == "error"
        assert "delta" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_set_counter_string_value(self, daemon: MemoryDaemon) -> None:
        """String value should be rejected."""
        result = await daemon._process_request({"action": "set_counter", "key": "x", "value": "42"})
        assert result["status"] == "error"
        assert "value" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_set_counter_float_value(self, daemon: MemoryDaemon) -> None:
        """Float value should be rejected."""
        result = await daemon._process_request({"action": "set_counter", "key": "x", "value": 1.5})
        assert result["status"] == "error"
        assert "value" in result["error"].lower()

    @pytest.mark.asyncio()
    async def test_set_counter_bool_value(self, daemon: MemoryDaemon) -> None:
        """Bool value should be rejected."""
        result = await daemon._process_request({"action": "set_counter", "key": "x", "value": True})
        assert result["status"] == "error"
        assert "value" in result["error"].lower()

    # -- Edge case tests --

    @pytest.mark.asyncio()
    async def test_update_counter_delta_zero(self, daemon: MemoryDaemon) -> None:
        """delta=0 should be a no-op that returns current value."""
        await daemon._process_request({"action": "update_counter", "key": "z", "delta": 5})
        result = await daemon._process_request({"action": "update_counter", "key": "z", "delta": 0})
        assert result["status"] == "ok"
        assert result["value"] == 5

    @pytest.mark.asyncio()
    async def test_update_counter_empty_string_key(self, daemon: MemoryDaemon) -> None:
        """Empty string key should be rejected."""
        result = await daemon._process_request({"action": "update_counter", "key": "", "delta": 1})
        assert result["status"] == "error"
        assert "key" in result["error"].lower()


# ============================================================================
# Daemon Robustness Tests (readline limit, client timeout, logging, comments)
# ============================================================================


class TestHandleClientTimeout:
    """handle_client should timeout stalled clients after 30 seconds."""

    @pytest.mark.asyncio()
    async def test_readline_timeout_disconnects_stalled_client(self, daemon: MemoryDaemon) -> None:
        """A client that never sends a newline should be disconnected by timeout."""
        reader = asyncio.StreamReader()
        # Simulate a client that connects but never sends data (no newline)
        # The reader will hang on readline() indefinitely without the timeout fix

        class FakeTransport:
            def get_extra_info(self, name, default=None):  # noqa: ARG002
                return default

            def is_closing(self):
                return False

            def close(self):
                pass

            def write(self, data):  # noqa: ARG002
                pass

        class FakeWriter:
            def __init__(self):
                self.transport = FakeTransport()
                self._closed = False

            def close(self):
                self._closed = True

            async def wait_closed(self):
                pass

            def write(self, data):  # noqa: ARG002
                pass

            async def drain(self):
                pass

        writer = FakeWriter()  # type: ignore[arg-type]

        # Feed EOF after a short delay so the test doesn't actually wait 30s.
        # The handle_client should use asyncio.wait_for with a timeout.
        # We test the timeout path by feeding no data at all and then
        # closing the feed after a tiny delay.
        async def close_feed_soon():
            await asyncio.sleep(0.05)
            reader.feed_eof()

        asyncio.create_task(close_feed_soon())

        # This should complete quickly without hanging
        await asyncio.wait_for(daemon.handle_client(reader, writer), timeout=5.0)
        assert writer._closed

    @pytest.mark.asyncio()
    async def test_readline_timeout_value_in_source(self) -> None:
        """Verify handle_client uses asyncio.wait_for with a timeout for readline."""
        import inspect

        source = inspect.getsource(MemoryDaemon.handle_client)
        assert "wait_for" in source, "handle_client must use asyncio.wait_for for readline"
        assert "TimeoutError" in source, "handle_client must catch TimeoutError"


class TestServerBufferLimit:
    """start() should pass limit= to asyncio.start_unix_server."""

    def test_start_passes_buffer_limit(self) -> None:
        """Verify start() source code passes limit= to start_unix_server."""
        import inspect

        source = inspect.getsource(MemoryDaemon.start)
        assert "limit=" in source, (
            "start() must pass limit= to asyncio.start_unix_server to cap readline buffer"
        )
        # Verify it's 1MB (1_048_576)
        assert "1_048_576" in source or "1048576" in source, (
            "Buffer limit should be 1MB (1_048_576)"
        )


class TestLoggerLazyFormatting:
    """Logger calls should use lazy formatting, not f-strings."""

    def test_no_fstring_in_logger_info_calls(self) -> None:
        """Verify daemon.py does not use f-strings in logger.info/error/warning calls."""
        import inspect

        source = inspect.getsource(MemoryDaemon)
        # Also check module-level main() function
        from claude_engram.daemon import main

        main_source = inspect.getsource(main)

        # Check for f-string patterns in logger calls
        import re

        # Match logger.<level>(f"...") patterns
        fstring_pattern = r'logger\.\w+\(f["\']'
        matches_class = re.findall(fstring_pattern, source)
        matches_main = re.findall(fstring_pattern, main_source)
        all_matches = matches_class + matches_main
        assert len(all_matches) == 0, (
            f"Found {len(all_matches)} f-string logger call(s) — "
            f"use lazy formatting instead: {all_matches}"
        )


class TestTTLCacheThreadSafetyComments:
    """TTLCache fields should have thread-safety comments."""

    def test_checkpoint_counters_has_thread_safety_comment(self) -> None:
        """_checkpoint_counters definition should have a thread-safety comment."""
        import inspect

        source = inspect.getsource(MemoryDaemon.__init__)
        # Find the _checkpoint_counters line and check for nearby comment
        lines = source.split("\n")
        for i, line in enumerate(lines):
            if "_checkpoint_counters" in line and "TTLCache" in line:
                # Check previous lines or same line for thread-safety comment
                context = "\n".join(lines[max(0, i - 2) : i + 1])
                assert "thread" in context.lower() or "event loop" in context.lower(), (
                    "_checkpoint_counters should have a thread-safety comment"
                )
                return
        pytest.fail("_checkpoint_counters TTLCache definition not found in __init__")

    def test_injected_ids_has_thread_safety_comment(self) -> None:
        """_injected_ids definition should have a thread-safety comment."""
        import inspect

        source = inspect.getsource(MemoryDaemon.__init__)
        lines = source.split("\n")
        for i, line in enumerate(lines):
            if "_injected_ids" in line and "TTLCache" in line:
                context = "\n".join(lines[max(0, i - 2) : i + 1])
                assert "thread" in context.lower() or "event loop" in context.lower(), (
                    "_injected_ids should have a thread-safety comment"
                )
                return
        pytest.fail("_injected_ids TTLCache definition not found in __init__")


class TestGetConnCouplingDocumented:
    """_get_conn() access from daemon should be documented as intentional coupling."""

    def test_check_memory_ownership_has_coupling_comment(self) -> None:
        """_check_memory_ownership should document its _get_conn() usage."""
        import inspect

        source = inspect.getsource(MemoryDaemon._check_memory_ownership)
        assert "_get_conn" in source, "_check_memory_ownership must use _get_conn"
        # Should have a comment near _get_conn explaining the coupling
        lines = source.split("\n")
        for i, line in enumerate(lines):
            if "_get_conn" in line:
                context = "\n".join(lines[max(0, i - 2) : i + 1])
                assert "#" in context, (
                    "_get_conn() usage in _check_memory_ownership should have "
                    "a comment documenting the intentional coupling"
                )
                return
        pytest.fail("_get_conn not found in _check_memory_ownership")

    def test_health_check_db_queries_has_coupling_comment(self) -> None:
        """_health_check_db_queries should document its _get_conn() usage."""
        import inspect

        source = inspect.getsource(MemoryDaemon._health_check_db_queries)
        assert "_get_conn" in source, "_health_check_db_queries must use _get_conn"
        lines = source.split("\n")
        for i, line in enumerate(lines):
            if "_get_conn" in line:
                context = "\n".join(lines[max(0, i - 2) : i + 1])
                assert "#" in context, (
                    "_get_conn() usage in _health_check_db_queries should have "
                    "a comment documenting the intentional coupling"
                )
                return
        pytest.fail("_get_conn not found in _health_check_db_queries")


# ============================================================================
# Fix 1: handle_client protocol tests
# ============================================================================


class _FakeTransport:
    """Minimal asyncio.Transport stub for FakeWriter."""

    def get_extra_info(self, name, default=None):  # noqa: ARG002
        return default

    def is_closing(self):
        return False

    def close(self):
        pass

    def write(self, data):  # noqa: ARG002
        pass


class _FakeWriter:
    """Minimal asyncio.StreamWriter stub that captures written bytes."""

    def __init__(self):
        self.transport = _FakeTransport()
        self._closed = False
        self._buf = bytearray()

    def close(self):
        self._closed = True

    async def wait_closed(self):
        pass

    def write(self, data: bytes):
        self._buf.extend(data)

    async def drain(self):
        pass

    def get_responses(self) -> list[dict]:
        """Parse newline-delimited JSON responses from the buffer."""
        import json

        responses = []
        for line in self._buf.split(b"\n"):
            if line.strip():
                responses.append(json.loads(line))
        return responses


class TestHandleClientProtocol:
    """Tests for handle_client socket protocol: JSON parsing, multi-request, EOF."""

    @pytest.mark.asyncio()
    async def test_valid_json_request(self, daemon: MemoryDaemon) -> None:
        """Feed valid JSON -> verify JSON response with status='pong'."""
        reader = asyncio.StreamReader()
        reader.feed_data(b'{"action":"ping"}\n')
        reader.feed_eof()

        writer = _FakeWriter()
        await daemon.handle_client(reader, writer)  # type: ignore[arg-type]

        responses = writer.get_responses()
        assert len(responses) == 1
        assert responses[0]["status"] == "pong"
        assert writer._closed

    @pytest.mark.asyncio()
    async def test_invalid_json_returns_error(self, daemon: MemoryDaemon) -> None:
        """Feed invalid JSON -> verify error response."""
        reader = asyncio.StreamReader()
        reader.feed_data(b"not json\n")
        reader.feed_eof()

        writer = _FakeWriter()
        await daemon.handle_client(reader, writer)  # type: ignore[arg-type]

        responses = writer.get_responses()
        assert len(responses) == 1
        assert responses[0]["status"] == "error"
        assert "Invalid JSON" in responses[0]["error"]
        assert writer._closed

    @pytest.mark.asyncio()
    async def test_multiple_requests_per_connection(self, daemon: MemoryDaemon) -> None:
        """Feed 2 requests on the same connection -> verify 2 responses."""
        reader = asyncio.StreamReader()
        reader.feed_data(b'{"action":"ping"}\n')
        reader.feed_data(b'{"action":"ping"}\n')
        reader.feed_eof()

        writer = _FakeWriter()
        await daemon.handle_client(reader, writer)  # type: ignore[arg-type]

        responses = writer.get_responses()
        assert len(responses) == 2
        assert responses[0]["status"] == "pong"
        assert responses[1]["status"] == "pong"
        assert writer._closed

    @pytest.mark.asyncio()
    async def test_empty_line_eof_clean_disconnect(self, daemon: MemoryDaemon) -> None:
        """Feed empty data (EOF) -> verify clean disconnect with no error."""
        reader = asyncio.StreamReader()
        reader.feed_data(b"")
        reader.feed_eof()

        writer = _FakeWriter()
        await daemon.handle_client(reader, writer)  # type: ignore[arg-type]

        responses = writer.get_responses()
        assert len(responses) == 0
        assert writer._closed


# ============================================================================
# Fix 4: _periodic_cleanup test
# ============================================================================


class TestPeriodicCleanup:
    """Tests for _periodic_cleanup method."""

    @pytest.mark.asyncio()
    async def test_periodic_cleanup_calls_cleanup_expired(self, daemon: MemoryDaemon) -> None:
        """_periodic_cleanup should call storage.cleanup_expired at least once."""
        # Set a very short cleanup interval (1s is minimum for int type)
        daemon._cleanup_interval = 1  # type: ignore[assignment]

        cleanup_call_count = 0

        def tracking_cleanup_expired():
            nonlocal cleanup_call_count
            cleanup_call_count += 1
            return 0

        daemon.storage.cleanup_expired = tracking_cleanup_expired  # type: ignore[assignment]

        task = asyncio.create_task(daemon._periodic_cleanup())
        try:
            await asyncio.sleep(1.5)
        finally:
            task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await task

        assert cleanup_call_count >= 1, (
            f"cleanup_expired should have been called at least once, "
            f"but was called {cleanup_call_count} times"
        )


# ============================================================================
# Socket Orphan Detection and Recovery
# ============================================================================


class TestSocketOrphanDetection:
    """Tests for detecting and recovering from orphan socket scenarios.

    An orphan socket occurs when the socket file is deleted while the daemon
    is running. The daemon continues to hold the file descriptor but new
    clients cannot connect because the filesystem path no longer exists.
    """

    @pytest.mark.asyncio()
    async def test_check_socket_health_returns_true_when_exists(
        self, daemon: MemoryDaemon, tmp_path: Path
    ) -> None:
        """_check_socket_health returns True when socket file exists."""
        # Create a fake socket file
        socket_path = tmp_path / "test.sock"
        socket_path.touch()

        # Patch SOCKET_PATH to use our test path
        with patch("claude_engram.daemon.SOCKET_PATH", socket_path):
            result = daemon._check_socket_health()
            assert result is True

    @pytest.mark.asyncio()
    async def test_check_socket_health_returns_false_when_missing(
        self, daemon: MemoryDaemon, tmp_path: Path
    ) -> None:
        """_check_socket_health returns False when socket file is deleted."""
        socket_path = tmp_path / "nonexistent.sock"
        # Don't create the file - it should be missing

        with patch("claude_engram.daemon.SOCKET_PATH", socket_path):
            result = daemon._check_socket_health()
            assert result is False

    @pytest.mark.asyncio()
    async def test_periodic_socket_check_recreates_socket(self, tmp_path: Path) -> None:
        """_periodic_socket_check detects missing socket and recreates it."""
        with patch("claude_engram.daemon.MemoryStorage") as mock_cls:
            from claude_engram.storage import MemoryStorage

            real_storage = MemoryStorage(db_path=tmp_path / "socket-check.db")
            mock_cls.return_value = real_storage
            d = MemoryDaemon()
            d.storage = real_storage

            socket_path = tmp_path / "daemon.sock"
            recreate_count = 0

            async def mock_recreate_socket():
                nonlocal recreate_count
                recreate_count += 1

            d._recreate_socket = mock_recreate_socket  # type: ignore[assignment]

            try:
                # Set very short check interval for testing
                d._socket_check_interval = 0.1

                # Start the periodic check task
                with patch("claude_engram.daemon.SOCKET_PATH", socket_path):
                    task = asyncio.create_task(d._periodic_socket_check())
                    try:
                        # Wait for at least one check cycle
                        await asyncio.sleep(0.25)
                    finally:
                        task.cancel()
                        with contextlib.suppress(asyncio.CancelledError):
                            await task

                # Socket didn't exist, so recreate should have been called
                assert recreate_count >= 1, (
                    f"_recreate_socket should have been called, "
                    f"but was called {recreate_count} times"
                )
            finally:
                real_storage.close()

    @pytest.mark.asyncio()
    async def test_socket_check_does_not_recreate_when_healthy(self, tmp_path: Path) -> None:
        """_periodic_socket_check does NOT recreate when socket exists."""
        with patch("claude_engram.daemon.MemoryStorage") as mock_cls:
            from claude_engram.storage import MemoryStorage

            real_storage = MemoryStorage(db_path=tmp_path / "healthy-socket.db")
            mock_cls.return_value = real_storage
            d = MemoryDaemon()
            d.storage = real_storage

            socket_path = tmp_path / "healthy.sock"
            socket_path.touch()  # Socket exists
            recreate_count = 0

            async def mock_recreate_socket():
                nonlocal recreate_count
                recreate_count += 1

            d._recreate_socket = mock_recreate_socket  # type: ignore[assignment]

            try:
                d._socket_check_interval = 0.1

                with patch("claude_engram.daemon.SOCKET_PATH", socket_path):
                    task = asyncio.create_task(d._periodic_socket_check())
                    try:
                        await asyncio.sleep(0.25)
                    finally:
                        task.cancel()
                        with contextlib.suppress(asyncio.CancelledError):
                            await task

                # Socket existed, so recreate should NOT have been called
                assert recreate_count == 0, (
                    f"_recreate_socket should NOT have been called when socket exists, "
                    f"but was called {recreate_count} times"
                )
            finally:
                real_storage.close()

    def test_socket_check_interval_initialized(self, daemon: MemoryDaemon) -> None:
        """Daemon should have _socket_check_interval attribute with reasonable default."""
        assert hasattr(daemon, "_socket_check_interval")
        # Default should be 30 seconds (frequent enough to catch issues, not too chatty)
        assert daemon._socket_check_interval == 30

    def test_socket_check_interval_env_configurable(self, tmp_path: Path) -> None:
        """_socket_check_interval should be configurable via environment variable."""
        with (
            patch("claude_engram.daemon.MemoryStorage") as mock_cls,
            patch.dict(os.environ, {"ENGRAM_SOCKET_CHECK_INTERVAL": "60"}),
        ):
            from claude_engram.storage import MemoryStorage

            real_storage = MemoryStorage(db_path=tmp_path / "env-interval.db")
            mock_cls.return_value = real_storage
            d = MemoryDaemon()
            d.storage = real_storage
            try:
                assert d._socket_check_interval == 60
            finally:
                real_storage.close()

    def test_socket_check_task_initialized_to_none(self, daemon: MemoryDaemon) -> None:
        """_socket_check_task should be None after init, before start()."""
        assert hasattr(daemon, "_socket_check_task")
        assert daemon._socket_check_task is None

    def test_health_check_includes_socket_orphan_warning(self) -> None:
        """health_check response should include orphan socket warning when detected."""
        # This test verifies the health_check action reports socket health issues
        # We'll test this by checking the health_check response structure includes
        # a field for socket_healthy or similar
        import inspect

        from claude_engram.daemon import MemoryDaemon

        source = inspect.getsource(MemoryDaemon._process_request)
        # health_check action should check socket health
        assert "socket_exists" in source, "health_check should report socket_exists status"


class TestSocketIsAlive:
    """Tests for _socket_is_alive() — prevents orphan daemon spawning."""

    @pytest.mark.asyncio()
    async def test_returns_false_when_no_socket(self, tmp_path: Path) -> None:
        """_socket_is_alive returns False when socket file does not exist."""
        socket_path = tmp_path / "nonexistent.sock"
        with patch("claude_engram.daemon.SOCKET_PATH", socket_path):
            assert await MemoryDaemon._socket_is_alive() is False

    @pytest.mark.asyncio()
    async def test_returns_false_for_stale_socket(self, tmp_path: Path) -> None:
        """_socket_is_alive returns False for a socket file with no listener."""
        socket_path = tmp_path / "stale.sock"
        socket_path.touch()
        with patch("claude_engram.daemon.SOCKET_PATH", socket_path):
            assert await MemoryDaemon._socket_is_alive() is False

    @pytest.mark.asyncio()
    async def test_returns_true_for_live_socket(self, tmp_path: Path) -> None:
        """_socket_is_alive returns True when a server is listening."""
        socket_path = tmp_path / "live.sock"

        async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
            writer.close()

        server = await asyncio.start_unix_server(handle_client, path=str(socket_path))
        try:
            with patch("claude_engram.daemon.SOCKET_PATH", socket_path):
                assert await MemoryDaemon._socket_is_alive() is True
        finally:
            server.close()
            await server.wait_closed()

    @pytest.mark.asyncio()
    async def test_start_aborts_when_daemon_already_running(self, tmp_path: Path) -> None:
        """start() calls sys.exit(1) if another daemon is listening."""
        socket_path = tmp_path / "occupied.sock"

        async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
            writer.close()

        server = await asyncio.start_unix_server(handle_client, path=str(socket_path))
        try:
            with (
                patch("claude_engram.daemon.SOCKET_PATH", socket_path),
                patch("claude_engram.daemon.PID_FILE", tmp_path / "daemon.pid"),
                patch("claude_engram.daemon.MemoryStorage") as mock_cls,
            ):
                from claude_engram.storage import MemoryStorage

                real_storage = MemoryStorage(db_path=tmp_path / "abort.db")
                mock_cls.return_value = real_storage
                d = MemoryDaemon()
                d.storage = real_storage
                try:
                    with pytest.raises(SystemExit) as exc_info:
                        await d.start()
                    assert exc_info.value.code == 1
                finally:
                    real_storage.close()
        finally:
            server.close()
            await server.wait_closed()
