"""Tests for proactive recall — core decay, review, and formatting functions.

Covers:
- _compute_retrievability() — FSRS-6 power-law decay model
- _parse_timestamp() — ISO timestamp parsing with timezone handling
- ProactiveRecall.get_due_for_review() — multi-table decay scanning
- ProactiveRecall.record_review() — spaced repetition stability updates
- ProactiveRecall.get_due_count() — fast count for status line
- ProactiveRecall.format_recall_prompt() — session context formatting
"""
import sqlite3
from datetime import datetime, timedelta, timezone

import pytest

from greymatter_core.recall import (
    C_FLOOR,
    CONFIRM_FACTOR,
    DEFAULT_STABILITY,
    MAX_STABILITY_DAYS,
    MIN_CONFIDENCE_FOR_REVIEW,
    REVIEW_THRESHOLD,
    ProactiveRecall,
    _compute_retrievability,
    _parse_timestamp,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def db_path(tmp_path):
    """Create a temporary database with observations, knowledge_entries, and patterns."""
    path = str(tmp_path / "recall_test.db")
    conn = sqlite3.connect(path)
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS observations (
            id TEXT PRIMARY KEY,
            session_id TEXT NOT NULL DEFAULT '',
            trigger TEXT NOT NULL DEFAULT 'manual',
            category TEXT NOT NULL DEFAULT 'insight',
            title TEXT NOT NULL DEFAULT '',
            content TEXT NOT NULL DEFAULT '',
            raw_context TEXT DEFAULT '',
            confidence REAL DEFAULT 0.5,
            stability_days REAL DEFAULT 45,
            last_confirmed_at TEXT,
            token_count INTEGER DEFAULT 0,
            context_usage_pct REAL DEFAULT 0.0,
            knowledge_id TEXT,
            pattern_id TEXT,
            promoted_at TEXT,
            review_count INTEGER DEFAULT 0,
            last_reviewed_at TEXT,
            review_source TEXT DEFAULT '',
            source_tool TEXT DEFAULT '',
            domains TEXT DEFAULT '',
            invalidated_by TEXT,
            pruned INTEGER DEFAULT 0,
            created_at TEXT DEFAULT (datetime('now')),
            updated_at TEXT DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS knowledge_entries (
            id TEXT PRIMARY KEY,
            entry_type TEXT NOT NULL,
            scope TEXT DEFAULT 'cross-cutting',
            domain TEXT,
            title TEXT NOT NULL,
            content TEXT NOT NULL,
            valence TEXT,
            confidence REAL DEFAULT 0.5,
            applied_count INTEGER DEFAULT 0,
            tags TEXT,
            created_at TEXT DEFAULT (datetime('now')),
            updated_at TEXT DEFAULT (datetime('now')),
            pruned_at TEXT
        );

        CREATE TABLE IF NOT EXISTS patterns (
            id TEXT PRIMARY KEY,
            type TEXT NOT NULL DEFAULT 'process',
            domain TEXT NOT NULL DEFAULT 'general',
            title TEXT NOT NULL DEFAULT '',
            observation TEXT NOT NULL DEFAULT '',
            confidence TEXT DEFAULT 'low',
            confirmations INTEGER DEFAULT 0,
            last_applied_at TEXT,
            created_at TEXT DEFAULT (datetime('now')),
            updated_at TEXT DEFAULT (datetime('now'))
        );
    """)
    conn.commit()
    conn.close()
    return path


@pytest.fixture
def recall(db_path):
    r = ProactiveRecall(db_path=db_path)
    yield r
    r.close()


def _old_timestamp(days_ago: float) -> str:
    """Return an ISO timestamp for `days_ago` days in the past."""
    return (datetime.now(timezone.utc) - timedelta(days=days_ago)).isoformat()


def _insert_knowledge(
    db_path: str,
    entry_id: str = "kn-test1",
    entry_type: str = "expertise",
    title: str = "Test Knowledge",
    content: str = "Some content",
    domain: str = "general",
    applied_count: int = 0,
    days_ago: float = 90,
    pruned: bool = False,
) -> None:
    conn = sqlite3.connect(db_path)
    ts = _old_timestamp(days_ago)
    conn.execute(
        """INSERT INTO knowledge_entries
           (id, entry_type, domain, title, content, applied_count,
            created_at, updated_at, pruned_at)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
        (entry_id, entry_type, domain, title, content, applied_count,
         ts, ts, ts if pruned else None),
    )
    conn.commit()
    conn.close()


def _insert_observation(
    db_path: str,
    obs_id: str = "obs-test1",
    title: str = "Test Observation",
    content: str = "Observation content",
    category: str = "insight",
    confidence: float = 0.6,
    stability_days: float = 45,
    days_ago: float = 90,
    pruned: int = 0,
    review_count: int = 0,
    session_id: str = "sess-1",
    knowledge_id: str | None = None,
    pattern_id: str | None = None,
) -> None:
    conn = sqlite3.connect(db_path)
    ts = _old_timestamp(days_ago)
    conn.execute(
        """INSERT INTO observations
           (id, session_id, category, title, content, confidence,
            stability_days, last_confirmed_at, created_at, pruned,
            review_count, knowledge_id, pattern_id, domains)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'general')""",
        (obs_id, session_id, category, title, content, confidence,
         stability_days, ts, ts, pruned, review_count, knowledge_id, pattern_id),
    )
    conn.commit()
    conn.close()


def _insert_pattern(
    db_path: str,
    pat_id: str = "pat-test1",
    title: str = "Test Pattern",
    observation: str = "Pattern observation",
    domain: str = "general",
    confirmations: int = 0,
    days_ago: float = 90,
) -> None:
    conn = sqlite3.connect(db_path)
    ts = _old_timestamp(days_ago)
    conn.execute(
        """INSERT INTO patterns
           (id, type, domain, title, observation, confirmations,
            last_applied_at, created_at)
           VALUES (?, 'process', ?, ?, ?, ?, ?, ?)""",
        (pat_id, domain, title, observation, confirmations, ts, ts),
    )
    conn.commit()
    conn.close()


# ---------------------------------------------------------------------------
# _compute_retrievability
# ---------------------------------------------------------------------------

class TestComputeRetrievability:
    """Tests for FSRS-6 power-law decay model."""

    def test_zero_elapsed_returns_one(self):
        """At t=0, retrievability should be 1.0."""
        assert _compute_retrievability(0, 45) == pytest.approx(1.0)

    def test_decreases_over_time(self):
        """Retrievability should decrease as time passes."""
        r_10 = _compute_retrievability(10, 45)
        r_50 = _compute_retrievability(50, 45)
        r_200 = _compute_retrievability(200, 45)
        assert r_10 > r_50 > r_200

    def test_higher_stability_decays_slower(self):
        """Higher stability should result in slower decay."""
        r_low = _compute_retrievability(60, 30)
        r_high = _compute_retrievability(60, 120)
        assert r_high > r_low

    def test_floor_is_respected(self):
        """Retrievability should never drop below C_FLOOR."""
        r = _compute_retrievability(100_000, 1)
        assert r >= C_FLOOR

    def test_zero_stability_uses_default(self):
        """Zero stability should use DEFAULT_STABILITY instead."""
        r = _compute_retrievability(30, 0)
        r_default = _compute_retrievability(30, DEFAULT_STABILITY)
        assert r == pytest.approx(r_default)

    def test_negative_stability_uses_default(self):
        """Negative stability should use DEFAULT_STABILITY."""
        r = _compute_retrievability(30, -10)
        r_default = _compute_retrievability(30, DEFAULT_STABILITY)
        assert r == pytest.approx(r_default)

    def test_known_value(self):
        """Verify against hand-computed FSRS-6 value.

        R(90, 45) = 0.05 + 0.95 * (1 + 90/90)^(-2) = 0.05 + 0.95 * 0.25 = 0.2875
        """
        r = _compute_retrievability(90, 45)
        assert r == pytest.approx(0.2875)


# ---------------------------------------------------------------------------
# _parse_timestamp
# ---------------------------------------------------------------------------

class TestParseTimestamp:
    """Tests for ISO timestamp parsing."""

    def test_valid_iso_with_tz(self):
        ts = "2025-06-15T12:30:00+00:00"
        result = _parse_timestamp(ts)
        assert result is not None
        assert result.tzinfo is not None

    def test_valid_iso_without_tz_gets_utc(self):
        ts = "2025-06-15T12:30:00"
        result = _parse_timestamp(ts)
        assert result is not None
        assert result.tzinfo == timezone.utc

    def test_sqlite_datetime_format(self):
        """SQLite datetime('now') format: YYYY-MM-DD HH:MM:SS."""
        ts = "2025-06-15 12:30:00"
        result = _parse_timestamp(ts)
        assert result is not None
        assert result.year == 2025
        assert result.month == 6

    def test_none_returns_none(self):
        assert _parse_timestamp(None) is None

    def test_empty_returns_none(self):
        assert _parse_timestamp("") is None

    def test_invalid_returns_none(self):
        assert _parse_timestamp("not-a-timestamp") is None


# ---------------------------------------------------------------------------
# get_due_for_review
# ---------------------------------------------------------------------------

class TestGetDueForReview:
    """Tests for multi-table decay scanning."""

    def test_empty_db_returns_empty(self, recall):
        assert recall.get_due_for_review() == []

    def test_finds_old_knowledge_entry(self, recall, db_path):
        """Knowledge entries old enough should appear as due for review."""
        _insert_knowledge(db_path, days_ago=120, applied_count=0)
        results = recall.get_due_for_review()
        assert len(results) >= 1
        assert results[0]["id"] == "kn-test1"
        assert results[0]["source_table"] == "knowledge_entries"
        assert results[0]["retrievability"] < REVIEW_THRESHOLD

    def test_skips_recent_knowledge(self, recall, db_path):
        """Recently updated knowledge entries should not appear."""
        _insert_knowledge(db_path, days_ago=1, applied_count=0)
        results = recall.get_due_for_review()
        # With 0 applications, stability is 30 days; 1 day ago is still fresh
        assert len(results) == 0

    def test_skips_pruned_knowledge(self, recall, db_path):
        """Pruned knowledge entries should not appear."""
        _insert_knowledge(db_path, days_ago=120, pruned=True)
        results = recall.get_due_for_review()
        assert len(results) == 0

    def test_finds_old_observation(self, recall, db_path):
        """Old observations with sufficient confidence should appear."""
        _insert_observation(
            db_path, confidence=0.5, stability_days=30, days_ago=100,
        )
        results = recall.get_due_for_review()
        obs_results = [r for r in results if r["source_table"] == "observations"]
        assert len(obs_results) >= 1

    def test_skips_low_confidence_observation(self, recall, db_path):
        """Observations below MIN_CONFIDENCE_FOR_REVIEW should be skipped."""
        _insert_observation(
            db_path, confidence=MIN_CONFIDENCE_FOR_REVIEW - 0.1,
            stability_days=30, days_ago=100,
        )
        results = recall.get_due_for_review()
        obs_results = [r for r in results if r["source_table"] == "observations"]
        assert len(obs_results) == 0

    def test_skips_pruned_observation(self, recall, db_path):
        """Pruned observations should not appear."""
        _insert_observation(db_path, days_ago=100, pruned=1)
        results = recall.get_due_for_review()
        obs_results = [r for r in results if r["source_table"] == "observations"]
        assert len(obs_results) == 0

    def test_finds_old_pattern(self, recall, db_path):
        """Old patterns should appear as due for review."""
        _insert_pattern(db_path, days_ago=200, confirmations=0)
        results = recall.get_due_for_review()
        pat_results = [r for r in results if r["source_table"] == "patterns"]
        assert len(pat_results) >= 1

    def test_respects_limit(self, recall, db_path):
        """Should return at most `limit` entries."""
        for i in range(10):
            _insert_knowledge(
                db_path, entry_id=f"kn-lim{i}",
                title=f"Knowledge {i}", days_ago=120,
            )
        results = recall.get_due_for_review(limit=3)
        assert len(results) == 3

    def test_sorted_by_retrievability_ascending(self, recall, db_path):
        """Results should be sorted by retrievability (most urgent first)."""
        _insert_knowledge(db_path, entry_id="kn-old", days_ago=365, applied_count=0)
        _insert_knowledge(db_path, entry_id="kn-mid", days_ago=60, applied_count=0)
        results = recall.get_due_for_review()
        if len(results) >= 2:
            assert results[0]["retrievability"] <= results[1]["retrievability"]

    def test_strips_category_prefix_from_observation_title(self, recall, db_path):
        """Observation titles with [category] prefix should be stripped."""
        _insert_observation(
            db_path, title="[insight] Important finding",
            confidence=0.5, stability_days=30, days_ago=100,
        )
        results = recall.get_due_for_review()
        obs_results = [r for r in results if r["source_table"] == "observations"]
        if obs_results:
            assert obs_results[0]["title"] == "Important finding"

    def test_knowledge_stability_increases_with_applied_count(self, recall, db_path):
        """Knowledge with more applications should have higher stability."""
        _insert_knowledge(db_path, entry_id="kn-low", days_ago=60, applied_count=0)
        _insert_knowledge(db_path, entry_id="kn-high", days_ago=60, applied_count=5)
        results = recall.get_due_for_review(limit=20)
        low = next((r for r in results if r["id"] == "kn-low"), None)
        high = next((r for r in results if r["id"] == "kn-high"), None)
        # kn-high has stability 30+5*15=105 vs kn-low stability 30
        # At 60 days, kn-high should have higher retrievability (may not be due)
        if low and high:
            assert low["retrievability"] < high["retrievability"]
        elif low and not high:
            # kn-high not due (too stable) — correct behavior
            pass

    def test_mixed_sources(self, recall, db_path):
        """Should aggregate results from all three tables."""
        _insert_knowledge(db_path, entry_id="kn-mix", days_ago=120)
        _insert_observation(
            db_path, obs_id="obs-mix",
            confidence=0.5, stability_days=30, days_ago=100,
        )
        _insert_pattern(db_path, pat_id="pat-mix", days_ago=200)
        results = recall.get_due_for_review(limit=10)
        sources = {r["source_table"] for r in results}
        assert "knowledge_entries" in sources
        assert "observations" in sources
        assert "patterns" in sources


# ---------------------------------------------------------------------------
# record_review
# ---------------------------------------------------------------------------

class TestRecordReview:
    """Tests for spaced repetition review recording."""

    def test_review_knowledge_active(self, recall, db_path):
        """Active review of knowledge should bump applied_count."""
        _insert_knowledge(db_path, applied_count=2)
        result = recall.record_review("kn-test1", source_table="knowledge_entries")
        assert result is True

        conn = recall._get_conn()
        row = dict(conn.execute(
            "SELECT applied_count FROM knowledge_entries WHERE id = 'kn-test1'"
        ).fetchone())
        assert row["applied_count"] == 3

    def test_review_knowledge_passive(self, recall, db_path):
        """Passive review should reset decay clock but not bump applied_count."""
        _insert_knowledge(db_path, applied_count=2)
        result = recall.record_review(
            "kn-test1", source_table="knowledge_entries",
            stability_factor=1.5,
        )
        assert result is True

        conn = recall._get_conn()
        row = dict(conn.execute(
            "SELECT applied_count FROM knowledge_entries WHERE id = 'kn-test1'"
        ).fetchone())
        assert row["applied_count"] == 2  # unchanged

    def test_review_observation(self, recall, db_path):
        """Review of observation should increase stability and confidence."""
        _insert_observation(
            db_path, confidence=0.5, stability_days=45,
            review_count=0,
        )
        result = recall.record_review("obs-test1", source_table="observations")
        assert result is True

        conn = recall._get_conn()
        row = dict(conn.execute(
            "SELECT stability_days, confidence, review_count FROM observations WHERE id = 'obs-test1'"
        ).fetchone())
        assert row["stability_days"] == pytest.approx(45 * CONFIRM_FACTOR)
        assert row["confidence"] == pytest.approx(0.55)
        assert row["review_count"] == 1

    def test_review_observation_caps_confidence(self, recall, db_path):
        """Confidence should be capped at 1.0."""
        _insert_observation(db_path, confidence=0.98, review_count=0)
        recall.record_review("obs-test1", source_table="observations")

        conn = recall._get_conn()
        row = dict(conn.execute(
            "SELECT confidence FROM observations WHERE id = 'obs-test1'"
        ).fetchone())
        assert row["confidence"] <= 1.0

    def test_review_observation_caps_stability(self, recall, db_path):
        """Stability should be capped at MAX_STABILITY_DAYS."""
        _insert_observation(
            db_path, stability_days=MAX_STABILITY_DAYS - 10,
            review_count=0, confidence=0.4,
        )
        recall.record_review("obs-test1", source_table="observations")

        conn = recall._get_conn()
        row = dict(conn.execute(
            "SELECT stability_days FROM observations WHERE id = 'obs-test1'"
        ).fetchone())
        assert row["stability_days"] <= MAX_STABILITY_DAYS

    def test_review_pattern_active(self, recall, db_path):
        """Active review of pattern should bump confirmations."""
        _insert_pattern(db_path, confirmations=3)
        result = recall.record_review("pat-test1", source_table="patterns")
        assert result is True

        conn = recall._get_conn()
        row = dict(conn.execute(
            "SELECT confirmations FROM patterns WHERE id = 'pat-test1'"
        ).fetchone())
        assert row["confirmations"] == 4

    def test_review_pattern_passive(self, recall, db_path):
        """Passive review of pattern should not bump confirmations."""
        _insert_pattern(db_path, confirmations=3)
        recall.record_review(
            "pat-test1", source_table="patterns", stability_factor=1.5,
        )

        conn = recall._get_conn()
        row = dict(conn.execute(
            "SELECT confirmations FROM patterns WHERE id = 'pat-test1'"
        ).fetchone())
        assert row["confirmations"] == 3

    def test_auto_detect_knowledge_prefix(self, recall, db_path):
        """ID prefix kn- should auto-detect knowledge_entries table."""
        _insert_knowledge(db_path)
        result = recall.record_review("kn-test1")
        assert result is True

    def test_auto_detect_ke_prefix(self, recall, db_path):
        """ID prefix ke- should auto-detect knowledge_entries table."""
        _insert_knowledge(db_path, entry_id="ke-test1")
        result = recall.record_review("ke-test1")
        assert result is True

    def test_auto_detect_obs_prefix(self, recall, db_path):
        """ID prefix obs- should auto-detect observations table."""
        _insert_observation(db_path, confidence=0.4, review_count=0)
        result = recall.record_review("obs-test1")
        assert result is True

    def test_auto_detect_pat_prefix(self, recall, db_path):
        """ID prefix pat- should auto-detect patterns table."""
        _insert_pattern(db_path)
        result = recall.record_review("pat-test1")
        assert result is True

    def test_unknown_prefix_returns_false(self, recall, db_path):
        """Unknown ID prefix should return False."""
        result = recall.record_review("xyz-unknown")
        assert result is False

    def test_review_nonexistent_observation(self, recall, db_path):
        """Reviewing a nonexistent observation should return False."""
        result = recall.record_review("obs-ghost", source_table="observations")
        assert result is False

    def test_no_database_returns_false(self, tmp_path):
        """If database doesn't exist, review should return False."""
        r = ProactiveRecall(db_path=str(tmp_path / "nonexistent.db"))
        result = r.record_review("kn-test1")
        assert result is False
        r.close()


# ---------------------------------------------------------------------------
# get_due_count
# ---------------------------------------------------------------------------

class TestGetDueCount:
    """Tests for fast due-count used by the status line."""

    def test_empty_db(self, recall):
        assert recall.get_due_count() == 0

    def test_counts_due_items(self, recall, db_path):
        for i in range(5):
            _insert_knowledge(
                db_path, entry_id=f"kn-cnt{i}",
                title=f"Old knowledge {i}", days_ago=120,
            )
        count = recall.get_due_count()
        assert count == 5

    def test_no_database(self, tmp_path):
        r = ProactiveRecall(db_path=str(tmp_path / "nonexistent.db"))
        assert r.get_due_count() == 0
        r.close()


# ---------------------------------------------------------------------------
# format_recall_prompt
# ---------------------------------------------------------------------------

class TestFormatRecallPrompt:
    """Tests for session context formatting."""

    def test_empty_entries(self, recall):
        assert recall.format_recall_prompt([]) == ""

    def test_contains_header(self, recall):
        entries = [{
            "id": "kn-1", "source_table": "knowledge_entries",
            "entry_type": "expertise", "title": "Test Entry",
            "domain": "testing", "retrievability": 0.4,
            "days_since_review": 60, "content_preview": "Some preview text",
        }]
        prompt = recall.format_recall_prompt(entries)
        assert "GreyMatter Recall" in prompt
        assert "1 item(s)" in prompt

    def test_urgency_critical(self, recall):
        entries = [{
            "id": "kn-1", "source_table": "knowledge_entries",
            "entry_type": "expertise", "title": "Critical Item",
            "domain": "testing", "retrievability": 0.2,
            "days_since_review": 120, "content_preview": "",
        }]
        prompt = recall.format_recall_prompt(entries)
        assert "CRITICAL" in prompt

    def test_urgency_high(self, recall):
        entries = [{
            "id": "kn-1", "source_table": "knowledge_entries",
            "entry_type": "expertise", "title": "High Item",
            "domain": "testing", "retrievability": 0.4,
            "days_since_review": 60, "content_preview": "",
        }]
        prompt = recall.format_recall_prompt(entries)
        assert "HIGH" in prompt

    def test_urgency_due(self, recall):
        entries = [{
            "id": "kn-1", "source_table": "knowledge_entries",
            "entry_type": "expertise", "title": "Due Item",
            "domain": "testing", "retrievability": 0.65,
            "days_since_review": 30, "content_preview": "",
        }]
        prompt = recall.format_recall_prompt(entries)
        assert "DUE" in prompt

    def test_days_format_days(self, recall):
        """Days >= 1 should show as Xd."""
        entries = [{
            "id": "kn-1", "source_table": "knowledge_entries",
            "entry_type": "expertise", "title": "Test",
            "domain": "general", "retrievability": 0.5,
            "days_since_review": 15, "content_preview": "",
        }]
        prompt = recall.format_recall_prompt(entries)
        assert "15d ago" in prompt

    def test_days_format_hours(self, recall):
        """Days < 1 should show as Xh."""
        entries = [{
            "id": "kn-1", "source_table": "knowledge_entries",
            "entry_type": "expertise", "title": "Test",
            "domain": "general", "retrievability": 0.5,
            "days_since_review": 0.5, "content_preview": "",
        }]
        prompt = recall.format_recall_prompt(entries)
        assert "12h ago" in prompt

    def test_content_preview_truncated(self, recall):
        """Content preview should be truncated to 150 chars."""
        long_preview = "x" * 200
        entries = [{
            "id": "kn-1", "source_table": "knowledge_entries",
            "entry_type": "expertise", "title": "Test",
            "domain": "general", "retrievability": 0.5,
            "days_since_review": 10, "content_preview": long_preview,
        }]
        prompt = recall.format_recall_prompt(entries)
        # Should contain truncated preview with "..."
        assert "..." in prompt
        # Should not contain the full 200-char string
        assert long_preview not in prompt

    def test_multiple_entries(self, recall):
        entries = [
            {
                "id": f"kn-{i}", "source_table": "knowledge_entries",
                "entry_type": "expertise", "title": f"Entry {i}",
                "domain": "general", "retrievability": 0.3 + i * 0.1,
                "days_since_review": 30, "content_preview": "",
            }
            for i in range(3)
        ]
        prompt = recall.format_recall_prompt(entries)
        assert "3 item(s)" in prompt
        assert "Entry 0" in prompt
        assert "Entry 2" in prompt


# ---------------------------------------------------------------------------
# ProactiveRecall lifecycle
# ---------------------------------------------------------------------------

class TestRecallLifecycle:
    """Tests for connection management and edge cases."""

    def test_close_idempotent(self, recall):
        """Calling close() multiple times should not raise."""
        recall.close()
        recall.close()

    def test_nonexistent_db_returns_empty(self, tmp_path):
        """ProactiveRecall with nonexistent DB should return empty results."""
        r = ProactiveRecall(db_path=str(tmp_path / "no.db"))
        assert r.get_due_for_review() == []
        r.close()
