"""Tests for Phase E2: auto-consolidation, progressive disclosure, adaptive ranking."""

from __future__ import annotations

import asyncio
import sqlite3
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, patch

import pytest

from claude_engram.daemon import MemoryDaemon
from claude_engram.migration import get_schema_version, migrate_schema
from claude_engram.storage import (
    ACCESS_FREQ_SATURATION,
    CATEGORY_HALF_LIFE,
    CATEGORY_SALIENCE,
    DEFAULT_SALIENCE,
    WEIGHT_ACCESS_FREQ,
    WEIGHT_SALIENCE,
    MemoryStorage,
    _compute_composite_score,
    _compute_recency_decay,
)


@pytest.fixture()
def storage(tmp_path: Path) -> MemoryStorage:
    """Create a MemoryStorage with a temp database."""
    db = tmp_path / "test.db"
    s = MemoryStorage(db_path=db)
    yield s
    s.close()


@pytest.fixture()
async def daemon(tmp_path: Path) -> MemoryDaemon:
    """Create a MemoryDaemon with a temp database."""
    db = tmp_path / "test.db"
    d = MemoryDaemon()
    d.storage = MemoryStorage(db_path=db)
    yield d
    d.storage.close()


# ---------------------------------------------------------------------------
# Part A: Migration v9
# ---------------------------------------------------------------------------


class TestMigrationV9:
    """v9 migration adds access_count and consolidation_meta."""

    def test_access_count_column_exists(self, storage: MemoryStorage) -> None:
        """access_count column should exist after migration."""
        conn = storage._get_conn()
        columns = conn.execute("PRAGMA table_info(memories)").fetchall()
        col_names = {c[1] for c in columns}
        assert "access_count" in col_names

    def test_access_count_default_zero(self, storage: MemoryStorage) -> None:
        """New memories should have access_count=0."""
        mid = storage.store(content="test memory", category="lesson")
        conn = storage._get_conn()
        row = conn.execute("SELECT access_count FROM memories WHERE id = ?", (mid,)).fetchone()
        assert row["access_count"] == 0

    def test_consolidation_meta_table_exists(self, storage: MemoryStorage) -> None:
        """consolidation_meta table should exist after migration."""
        conn = storage._get_conn()
        tables = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' AND name='consolidation_meta'"
        ).fetchall()
        assert len(tables) == 1

    def test_schema_version_is_10(self, storage: MemoryStorage) -> None:
        conn = storage._get_conn()
        assert get_schema_version(conn) == 10

    def test_v8_to_v10_migration(self, tmp_path: Path) -> None:
        """Simulate v8 DB and verify v10 migration adds the column."""
        db_path = tmp_path / "v8.db"
        conn = sqlite3.connect(str(db_path))
        conn.row_factory = sqlite3.Row
        # Create minimal v8 schema
        conn.executescript("""
            CREATE TABLE memories (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                content TEXT NOT NULL,
                category TEXT DEFAULT 'general',
                metadata TEXT DEFAULT '{}',
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL,
                proj_tag TEXT DEFAULT '',
                ttl_tier TEXT DEFAULT 'permanent',
                expires_at TEXT DEFAULT NULL,
                content_hash TEXT DEFAULT NULL,
                summary TEXT DEFAULT NULL,
                token_count INTEGER DEFAULT NULL,
                superseded_by INTEGER DEFAULT NULL,
                is_active INTEGER DEFAULT 1,
                valid_from TEXT DEFAULT NULL,
                valid_to TEXT DEFAULT NULL
            );
            CREATE TABLE memory_relations (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                source_id INTEGER NOT NULL,
                target_id INTEGER NOT NULL,
                relation_type TEXT NOT NULL
            );
            PRAGMA user_version = 8;
        """)
        conn.commit()

        # Now migrate
        migrate_schema(conn)
        assert get_schema_version(conn) == 10

        # Check column exists
        columns = conn.execute("PRAGMA table_info(memories)").fetchall()
        col_names = {c[1] for c in columns}
        assert "access_count" in col_names
        conn.close()


# ---------------------------------------------------------------------------
# Part B: Progressive Disclosure
# ---------------------------------------------------------------------------


class TestProgressiveDisclosure:
    """detail_level controls what fields are returned."""

    def test_index_level_no_content(self, storage: MemoryStorage) -> None:
        """detail_level=0 (index) should not return content or summary."""
        storage.store(content="Full content here that should not appear", category="lesson")
        results = storage.search(query="full content", detail_level=0)
        assert len(results) >= 1
        mem = results[0]
        assert "content" not in mem
        assert "summary" not in mem
        assert "id" in mem
        assert "category" in mem
        assert "similarity" in mem
        assert "token_count" in mem
        assert mem["detail_level"] == 0

    def test_summary_level_truncated(self, storage: MemoryStorage) -> None:
        """detail_level=1 (summary) should return summary, not full content."""
        long_content = "This is a detailed lesson. " * 50
        storage.store(content=long_content, category="lesson")
        results = storage.search(query="detailed lesson", detail_level=1)
        assert len(results) >= 1
        mem = results[0]
        assert "summary" in mem
        assert "content" not in mem
        assert mem["detail_level"] == 1
        # Summary should be shorter than full content
        assert len(mem["summary"]) < len(long_content)

    def test_full_level_complete_content(self, storage: MemoryStorage) -> None:
        """detail_level=2 (full) should return complete content and metadata."""
        storage.store(
            content="Complete memory content with all details",
            category="lesson",
            metadata={"key": "value"},
        )
        results = storage.search(query="complete memory", detail_level=2)
        assert len(results) >= 1
        mem = results[0]
        assert "content" in mem
        assert "metadata" in mem
        assert mem["detail_level"] == 2

    def test_default_level_is_summary(self, storage: MemoryStorage) -> None:
        """Default detail_level should be 2 (storage default, MCP remaps)."""
        # Storage.search default is still 2 for backward compat
        # MCP layer remaps "summary"->1 as default
        storage.store(content="test", category="lesson")
        results = storage.search(query="test")
        assert len(results) >= 1
        # Default in storage is 2 (full)
        assert results[0]["detail_level"] == 2


class TestDaemonDetailLevel:
    """Daemon search handler should accept string and int detail_level."""

    @pytest.mark.asyncio()
    async def test_string_index_level(self, daemon: MemoryDaemon) -> None:
        """String 'index' should map to int 0."""
        await daemon._process_request(
            {"action": "store", "content": "test memory", "category": "lesson"}
        )
        result = await daemon._process_request(
            {"action": "search", "query": "test memory", "detail_level": "index"}
        )
        assert result["status"] == "ok"
        for mem in result["results"]:
            assert mem["detail_level"] == 0

    @pytest.mark.asyncio()
    async def test_string_summary_level(self, daemon: MemoryDaemon) -> None:
        """String 'summary' should map to int 1."""
        await daemon._process_request(
            {"action": "store", "content": "test memory", "category": "lesson"}
        )
        result = await daemon._process_request(
            {"action": "search", "query": "test memory", "detail_level": "summary"}
        )
        assert result["status"] == "ok"
        for mem in result["results"]:
            assert mem["detail_level"] == 1

    @pytest.mark.asyncio()
    async def test_string_full_level(self, daemon: MemoryDaemon) -> None:
        """String 'full' should map to int 2."""
        await daemon._process_request(
            {"action": "store", "content": "test memory", "category": "lesson"}
        )
        result = await daemon._process_request(
            {"action": "search", "query": "test memory", "detail_level": "full"}
        )
        assert result["status"] == "ok"
        for mem in result["results"]:
            assert mem["detail_level"] == 2
            assert "content" in mem

    @pytest.mark.asyncio()
    async def test_int_backward_compat(self, daemon: MemoryDaemon) -> None:
        """Int detail_level should still work for backward compat."""
        await daemon._process_request(
            {"action": "store", "content": "test memory", "category": "lesson"}
        )
        result = await daemon._process_request(
            {"action": "search", "query": "test memory", "detail_level": 2}
        )
        assert result["status"] == "ok"
        for mem in result["results"]:
            assert "content" in mem

    @pytest.mark.asyncio()
    async def test_default_is_summary(self, daemon: MemoryDaemon) -> None:
        """Default detail_level in daemon should be 1 (summary)."""
        await daemon._process_request(
            {"action": "store", "content": "test memory about cats", "category": "lesson"}
        )
        result = await daemon._process_request(
            {"action": "search", "query": "test memory about cats"}
        )
        assert result["status"] == "ok"
        for mem in result["results"]:
            assert mem["detail_level"] == 1


# ---------------------------------------------------------------------------
# Part C: Adaptive Ranking
# ---------------------------------------------------------------------------


class TestCompositeScoring:
    """Composite score combines similarity, salience, recency, access_freq."""

    def test_pure_similarity(self) -> None:
        """With all other factors at baseline, similarity dominates."""
        now = datetime.now(tz=UTC).isoformat()
        score_high = _compute_composite_score(
            similarity=0.95, category="general", created_at=now, access_count=0
        )
        score_low = _compute_composite_score(
            similarity=0.50, category="general", created_at=now, access_count=0
        )
        assert score_high > score_low

    def test_salience_boost(self) -> None:
        """Decision category (salience=1.0) should score higher than general (0.4)."""
        now = datetime.now(tz=UTC).isoformat()
        decision_score = _compute_composite_score(
            similarity=0.80, category="decision", created_at=now, access_count=0
        )
        general_score = _compute_composite_score(
            similarity=0.80, category="general", created_at=now, access_count=0
        )
        # Difference should be WEIGHT_SALIENCE * (1.0 - 0.4) = 0.2 * 0.6 = 0.12
        assert decision_score > general_score
        diff = decision_score - general_score
        expected_diff = WEIGHT_SALIENCE * (CATEGORY_SALIENCE["decision"] - DEFAULT_SALIENCE)
        assert abs(diff - expected_diff) < 0.01

    def test_access_frequency_boost(self) -> None:
        """Frequently accessed memories should score higher."""
        now = datetime.now(tz=UTC).isoformat()
        popular = _compute_composite_score(
            similarity=0.80, category="lesson", created_at=now, access_count=20
        )
        unknown = _compute_composite_score(
            similarity=0.80, category="lesson", created_at=now, access_count=0
        )
        assert popular > unknown
        diff = popular - unknown
        expected_diff = WEIGHT_ACCESS_FREQ * 1.0  # saturated
        assert abs(diff - expected_diff) < 0.01

    def test_access_freq_saturates(self) -> None:
        """Access frequency should saturate at ACCESS_FREQ_SATURATION."""
        now = datetime.now(tz=UTC).isoformat()
        at_sat = _compute_composite_score(
            similarity=0.80,
            category="lesson",
            created_at=now,
            access_count=ACCESS_FREQ_SATURATION,
        )
        over_sat = _compute_composite_score(
            similarity=0.80,
            category="lesson",
            created_at=now,
            access_count=ACCESS_FREQ_SATURATION * 5,
        )
        assert abs(at_sat - over_sat) < 0.001

    def test_recency_decay_old_checkpoint(self) -> None:
        """Old checkpoint (7d half-life) should decay more than old decision (30d)."""
        old_time = (datetime.now(tz=UTC) - timedelta(days=14)).isoformat()
        checkpoint_score = _compute_composite_score(
            similarity=0.80, category="checkpoint", created_at=old_time, access_count=0
        )
        # Active decisions skip decay
        decision_score = _compute_composite_score(
            similarity=0.80,
            category="decision",
            created_at=old_time,
            access_count=0,
            is_active=True,
        )
        assert decision_score > checkpoint_score

    def test_active_decision_no_decay(self) -> None:
        """Active decisions should not decay."""
        very_old = (datetime.now(tz=UTC) - timedelta(days=90)).isoformat()
        now = datetime.now(tz=UTC).isoformat()
        old_decision = _compute_composite_score(
            similarity=0.80,
            category="decision",
            created_at=very_old,
            access_count=0,
            is_active=True,
        )
        new_decision = _compute_composite_score(
            similarity=0.80,
            category="decision",
            created_at=now,
            access_count=0,
            is_active=True,
        )
        # Both should be equal (active decisions don't decay)
        assert abs(old_decision - new_decision) < 0.001


class TestCategoryHalfLives:
    """Category-specific half-lives for recency decay."""

    def test_decision_half_life_30d(self) -> None:
        assert CATEGORY_HALF_LIFE["decision"] == 30.0

    def test_lesson_permanent(self) -> None:
        """Lessons are in PERMANENT_CATEGORIES, so they never decay."""
        old_time = (datetime.now(tz=UTC) - timedelta(days=365)).isoformat()
        decay = _compute_recency_decay(old_time, "lesson")
        assert decay == 1.0

    def test_checkpoint_half_life_7d(self) -> None:
        """14-day-old checkpoint should have ~25% weight (2 half-lives)."""
        old_time = (datetime.now(tz=UTC) - timedelta(days=14)).isoformat()
        decay = _compute_recency_decay(old_time, "checkpoint")
        # 0.5^(14/7) = 0.25
        assert 0.20 < decay < 0.30


class TestAccessCountIncrement:
    """Search should increment access_count for returned memories."""

    def test_search_increments_access_count(self, storage: MemoryStorage) -> None:
        """Searching should bump access_count by 1 for each hit."""
        mid = storage.store(content="unique test memory for counting", category="lesson")

        # Search should return this memory and increment its count
        storage.search(query="unique test memory for counting", detail_level=2)

        conn = storage._get_conn()
        row = conn.execute("SELECT access_count FROM memories WHERE id = ?", (mid,)).fetchone()
        assert row["access_count"] == 1

    def test_multiple_searches_accumulate(self, storage: MemoryStorage) -> None:
        """Multiple searches should accumulate access_count (cache disabled)."""
        mid = storage.store(content="repeated search target", category="lesson")

        for _ in range(3):
            storage.search(query="repeated search target", detail_level=2, enable_cache=False)

        conn = storage._get_conn()
        row = conn.execute("SELECT access_count FROM memories WHERE id = ?", (mid,)).fetchone()
        assert row["access_count"] == 3


# ---------------------------------------------------------------------------
# Part A: Auto-consolidation
# ---------------------------------------------------------------------------


class TestAutoConsolidation:
    """Daemon auto-consolidation trigger and startup check."""

    @pytest.mark.asyncio()
    async def test_store_counter_increments(self, daemon: MemoryDaemon) -> None:
        """Each store should increment the internal counter."""
        assert daemon._store_count == 0
        await daemon._process_request(
            {"action": "store", "content": "test memory 1", "category": "lesson"}
        )
        assert daemon._store_count == 1

    @pytest.mark.asyncio()
    async def test_consolidation_triggers_at_threshold(self, daemon: MemoryDaemon) -> None:
        """Auto-consolidation should fire when store_count reaches threshold."""
        daemon._consolidation_threshold = 3
        triggered = False

        async def mock_consolidate():
            nonlocal triggered
            triggered = True

        daemon._auto_consolidate = mock_consolidate

        for i in range(3):
            await daemon._process_request(
                {"action": "store", "content": f"memory {i}", "category": "lesson"}
            )

        # Give the task a chance to run
        await asyncio.sleep(0.1)
        assert triggered
        assert daemon._store_count == 0  # Reset after trigger

    @pytest.mark.asyncio()
    async def test_consolidation_due_action(self, daemon: MemoryDaemon) -> None:
        """consolidation_due action should report status."""
        result = await daemon._process_request({"action": "consolidation_due"})
        assert result["status"] == "ok"
        assert "due" in result
        assert "store_count" in result
        assert "threshold" in result

    def test_consolidation_overdue_fresh_db(self, daemon: MemoryDaemon) -> None:
        """Fresh DB with no consolidation_meta should be overdue."""
        assert daemon._consolidation_overdue() is True

    def test_consolidation_not_overdue_after_update(self, daemon: MemoryDaemon) -> None:
        """After updating timestamp, should not be overdue."""
        daemon._update_consolidation_timestamp()
        assert daemon._consolidation_overdue() is False

    @pytest.mark.asyncio()
    async def test_startup_consolidation_runs_when_overdue(self, daemon: MemoryDaemon) -> None:
        """Startup should trigger consolidation if overdue."""
        with patch.object(daemon, "_auto_consolidate", new_callable=AsyncMock) as mock:
            await daemon._startup_consolidation_check()
            mock.assert_called_once()

    @pytest.mark.asyncio()
    async def test_startup_consolidation_skips_when_recent(self, daemon: MemoryDaemon) -> None:
        """Startup should skip consolidation if recently run."""
        daemon._update_consolidation_timestamp()
        with patch.object(daemon, "_auto_consolidate", new_callable=AsyncMock) as mock:
            await daemon._startup_consolidation_check()
            mock.assert_not_called()
