"""Tests for D10 3-tier memory flush: micro, progress, and deep tiers.

Tests use pytest-asyncio to call _process_request directly, with
_store_tier_checkpoint mocked to capture fire-and-forget store calls.
"""

from __future__ import annotations

from pathlib import Path
from unittest.mock import AsyncMock, patch

import pytest
import pytest_asyncio

from claude_engram.daemon import (
    _DEFAULT_PROGRESS_THRESHOLDS,
    _MICRO_RATE_LIMIT,
    _MICRO_TOOLS,
    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()


async def _tick(
    daemon: MemoryDaemon,
    session_id: str = "s1",
    tool_name: str | None = None,
    profile: str = "balanced",
) -> dict:
    """Helper to send a checkpoint_tick request."""
    req: dict = {
        "action": "checkpoint_tick",
        "session_id": session_id,
        "profile": profile,
    }
    if tool_name is not None:
        req["tool_name"] = tool_name
    return await daemon._process_request(req)


# ==============================================================================
# Micro tier tests
# ==============================================================================


class TestMicroTier:
    """Micro tier: checkpoint on Write/Edit/MultiEdit, rate-limited."""

    @pytest.mark.asyncio()
    async def test_micro_fires_on_write_tool(self, daemon: MemoryDaemon) -> None:
        """Write tool triggers micro checkpoint."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            await _tick(daemon, tool_name="Write")
            # Allow fire-and-forget task to complete
            import asyncio

            await asyncio.sleep(0)

            mock_store.assert_called_once()
            call_kwargs = mock_store.call_args
            assert "[micro] Write" in call_kwargs.kwargs.get(
                "content", call_kwargs[1].get("content", "")
            ) or "[micro] Write" in str(call_kwargs)

    @pytest.mark.asyncio()
    async def test_micro_fires_on_edit_tool(self, daemon: MemoryDaemon) -> None:
        """Edit tool triggers micro checkpoint."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            await _tick(daemon, tool_name="Edit")
            import asyncio

            await asyncio.sleep(0)

            mock_store.assert_called_once()
            args = mock_store.call_args
            content = args.kwargs.get("content", "")
            assert "[micro] Edit" in content

    @pytest.mark.asyncio()
    async def test_micro_rate_limited_30s(self, daemon: MemoryDaemon) -> None:
        """Second micro within 30s is suppressed."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            # First call — should fire
            await _tick(daemon, tool_name="Write")
            import asyncio

            await asyncio.sleep(0)
            assert mock_store.call_count == 1

            # Second call immediately after — should be suppressed
            await _tick(daemon, tool_name="Write")
            await asyncio.sleep(0)
            assert mock_store.call_count == 1  # Still 1, not 2

    @pytest.mark.asyncio()
    async def test_micro_rate_limit_expires(self, daemon: MemoryDaemon) -> None:
        """Micro fires again after 30s window."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            await _tick(daemon, tool_name="Write")
            import asyncio

            await asyncio.sleep(0)
            assert mock_store.call_count == 1

            # Simulate 30s passing by manipulating _last_micro_ts
            daemon._last_micro_ts["s1"] -= _MICRO_RATE_LIMIT + 1.0

            await _tick(daemon, tool_name="Write")
            await asyncio.sleep(0)
            assert mock_store.call_count == 2

    @pytest.mark.asyncio()
    async def test_micro_metadata_has_tier(self, daemon: MemoryDaemon) -> None:
        """Metadata includes tier='micro' and tool_name."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            await _tick(daemon, tool_name="Write")
            import asyncio

            await asyncio.sleep(0)

            call_kwargs = mock_store.call_args.kwargs
            metadata = call_kwargs["metadata"]
            assert metadata["tier"] == "micro"
            assert metadata["tool_name"] == "Write"
            assert metadata["session_id"] == "s1"
            assert call_kwargs["category"] == "checkpoint"

    @pytest.mark.asyncio()
    async def test_micro_skips_non_write_tools(self, daemon: MemoryDaemon) -> None:
        """Read/Grep/Glob do NOT trigger micro checkpoint."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            for tool in ("Read", "Grep", "Glob", "Bash", "WebSearch"):
                await _tick(daemon, tool_name=tool, session_id=f"s_{tool}")

            import asyncio

            await asyncio.sleep(0)
            mock_store.assert_not_called()

    @pytest.mark.asyncio()
    async def test_micro_tools_constant(self) -> None:
        """Verify _MICRO_TOOLS contains expected tools."""
        assert "Write" in _MICRO_TOOLS
        assert "Edit" in _MICRO_TOOLS
        assert "MultiEdit" in _MICRO_TOOLS
        assert "Read" not in _MICRO_TOOLS

    @pytest.mark.asyncio()
    async def test_micro_fires_on_multiedit_tool(self, daemon: MemoryDaemon) -> None:
        """MultiEdit tool triggers micro checkpoint."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            await _tick(daemon, tool_name="MultiEdit")
            import asyncio

            await asyncio.sleep(0)
            mock_store.assert_called_once()


# ==============================================================================
# Progress tier tests
# ==============================================================================


class TestProgressTier:
    """Progress tier: periodic checkpoints every N ticks."""

    @pytest.mark.asyncio()
    async def test_progress_fires_at_threshold(self, daemon: MemoryDaemon) -> None:
        """Checkpoint stored at tick = progress_threshold."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            # Suppress micro to isolate progress tier
            daemon._last_micro_ts["s1"] = float("inf")

            # Balanced profile: progress threshold = 5, full threshold = 25
            for _ in range(5):
                await _tick(daemon, tool_name="Bash", session_id="s1")

            import asyncio

            await asyncio.sleep(0)

            # At tick 5, progress should fire
            assert mock_store.call_count == 1
            call_kwargs = mock_store.call_args.kwargs
            assert call_kwargs["metadata"]["tier"] == "progress"
            assert call_kwargs["metadata"]["counter"] == 5

    @pytest.mark.asyncio()
    async def test_progress_balanced_threshold_is_5(self, daemon: MemoryDaemon) -> None:
        """Balanced profile fires at tick 5, 10, 15."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            daemon._last_micro_ts["s1"] = float("inf")

            progress_calls = []
            for i in range(1, 21):
                await _tick(daemon, tool_name="Bash", session_id="s1")

                import asyncio

                await asyncio.sleep(0)

                # Check if a new progress call was made
                current_count = sum(
                    1
                    for call in mock_store.call_args_list
                    if call.kwargs.get("metadata", {}).get("tier") == "progress"
                )
                if current_count > len(progress_calls):
                    progress_calls.append(i)

            # Should fire at ticks 5, 10, 15, 20 (all < 25 threshold)
            assert progress_calls == [5, 10, 15, 20]

    @pytest.mark.asyncio()
    async def test_progress_ultra_threshold_is_3(self, daemon: MemoryDaemon) -> None:
        """Ultra profile fires at tick 3, 6, 9."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            daemon._last_micro_ts["s1"] = float("inf")

            progress_calls = []
            for i in range(1, 13):
                await _tick(daemon, tool_name="Bash", session_id="s1", profile="ultra")

                import asyncio

                await asyncio.sleep(0)

                current_count = sum(
                    1
                    for call in mock_store.call_args_list
                    if call.kwargs.get("metadata", {}).get("tier") == "progress"
                )
                if current_count > len(progress_calls):
                    progress_calls.append(i)

            # Ultra: progress=3, full=15. Fires at 3, 6, 9, 12
            assert progress_calls == [3, 6, 9, 12]

    @pytest.mark.asyncio()
    async def test_progress_includes_tool_summary(self, daemon: MemoryDaemon) -> None:
        """Progress content includes tool usage counts."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            daemon._last_micro_ts["s1"] = float("inf")

            # Send different tools to build up counts
            await _tick(daemon, tool_name="Bash", session_id="s1")
            await _tick(daemon, tool_name="Bash", session_id="s1")
            await _tick(daemon, tool_name="Read", session_id="s1")
            await _tick(daemon, tool_name="Read", session_id="s1")
            await _tick(daemon, tool_name="Grep", session_id="s1")

            import asyncio

            await asyncio.sleep(0)

            # Find the progress call
            progress_calls = [
                c
                for c in mock_store.call_args_list
                if c.kwargs.get("metadata", {}).get("tier") == "progress"
            ]
            assert len(progress_calls) == 1
            content = progress_calls[0].kwargs["content"]
            assert "Progress checkpoint #5" in content
            assert "Bash=2" in content
            assert "Read=2" in content
            assert "Grep=1" in content

    @pytest.mark.asyncio()
    async def test_progress_skips_above_full_threshold(self, daemon: MemoryDaemon) -> None:
        """No progress checkpoint when counter >= full threshold."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            daemon._last_micro_ts["s1"] = float("inf")

            # Use light profile: progress=8, full=40
            # Send 40 ticks — progress should fire at 8,16,24,32 but NOT at 40
            progress_counters = []
            for _ in range(1, 41):
                await _tick(daemon, tool_name="Bash", session_id="s1", profile="light")

                import asyncio

                await asyncio.sleep(0)

                new_calls = [
                    c
                    for c in mock_store.call_args_list
                    if c.kwargs.get("metadata", {}).get("tier") == "progress"
                ]
                if len(new_calls) > len(progress_counters):
                    progress_counters.append(new_calls[-1].kwargs["metadata"]["counter"])

            # 40 should NOT be in the list (it's >= full threshold)
            assert 40 not in progress_counters
            # But 8, 16, 24, 32 should be
            assert progress_counters == [8, 16, 24, 32]

    @pytest.mark.asyncio()
    async def test_progress_metadata_has_tier(self, daemon: MemoryDaemon) -> None:
        """Metadata includes tier='progress' and counter."""
        with patch.object(daemon, "_store_tier_checkpoint", new_callable=AsyncMock) as mock_store:
            daemon._last_micro_ts["s1"] = float("inf")

            for _ in range(5):
                await _tick(daemon, tool_name="Bash", session_id="s1")

            import asyncio

            await asyncio.sleep(0)

            progress_calls = [
                c
                for c in mock_store.call_args_list
                if c.kwargs.get("metadata", {}).get("tier") == "progress"
            ]
            assert len(progress_calls) == 1
            metadata = progress_calls[0].kwargs["metadata"]
            assert metadata["tier"] == "progress"
            assert metadata["counter"] == 5
            assert metadata["session_id"] == "s1"
            assert progress_calls[0].kwargs["category"] == "checkpoint"

    @pytest.mark.asyncio()
    async def test_progress_thresholds_default(self) -> None:
        """Default progress thresholds match spec."""
        assert _DEFAULT_PROGRESS_THRESHOLDS == {"light": 8, "balanced": 5, "ultra": 3}
