"""Integration tests: knowledge lifecycle across export, import, recall, and dedup.

Tests the full data lifecycle:
- Knowledge creation → export → import into fresh DB → verify integrity
- Knowledge + observation decay → recall identification → review → stability boost
- Observation dedup → merge → content annotation → FTS cleanup
- Export with filters → selective import → duplicate detection
- Recall formatting and urgency classification
- Round-trip: create → export → import → export again → compare
"""

import json
import sqlite3
from datetime import datetime, timedelta, timezone

import pytest

from greymatter_core.db import SQLiteDatabase
from greymatter_core.export import KnowledgeExporter, KnowledgeImporter
from greymatter_core.knowledge import KnowledgeStore
from greymatter_core.recall import (
    C_FLOOR,
    CONFIRM_FACTOR,
    DEFAULT_STABILITY,
    MAX_STABILITY_DAYS,
    REVIEW_THRESHOLD,
    ProactiveRecall,
    _compute_retrievability,
)
from greymatter_core.dedup import (
    dedup_observations,
    increment_observation,
    should_deduplicate,
)


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

@pytest.fixture
def db():
    """In-memory SQLiteDatabase for core integration tests."""
    database = SQLiteDatabase(":memory:")
    yield database
    database.close()


@pytest.fixture
def knowledge(db):
    return KnowledgeStore(db)


@pytest.fixture
def recall_db(tmp_path):
    """Standalone SQLite DB with full schema for recall integration tests.

    Uses file-based DB because ProactiveRecall checks os.path.exists().
    Seeds knowledge_entries, observations, and patterns tables.
    """
    path = str(tmp_path / "recall_test.db")
    conn = sqlite3.connect(path)
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS knowledge_entries (
            id TEXT PRIMARY KEY,
            entry_type TEXT DEFAULT 'reference',
            title TEXT NOT NULL,
            content TEXT DEFAULT '',
            domain TEXT DEFAULT 'general',
            scope TEXT DEFAULT 'cross-cutting',
            tags TEXT,
            valence TEXT DEFAULT 'neutral',
            applied_count INTEGER DEFAULT 0,
            confidence TEXT DEFAULT 'medium',
            pruned_at TEXT,
            created_at TEXT DEFAULT (datetime('now')),
            updated_at TEXT DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS observations (
            id TEXT PRIMARY KEY,
            session_id TEXT DEFAULT 'test',
            trigger TEXT DEFAULT 'test',
            category TEXT DEFAULT 'insight',
            title TEXT NOT NULL,
            content TEXT DEFAULT '',
            domains TEXT DEFAULT 'general',
            confidence REAL DEFAULT 0.5,
            stability_days REAL DEFAULT 45.0,
            last_confirmed_at TEXT,
            review_count INTEGER DEFAULT 0,
            last_reviewed_at TEXT,
            review_source TEXT,
            knowledge_id TEXT,
            pattern_id TEXT,
            promoted_at TEXT,
            pruned INTEGER DEFAULT 0,
            created_at TEXT DEFAULT (datetime('now')),
            updated_at TEXT DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS patterns (
            id TEXT PRIMARY KEY,
            type TEXT DEFAULT 'process',
            title TEXT NOT NULL,
            domain TEXT DEFAULT 'general',
            observation TEXT 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.close()
    yield path


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _insert_knowledge(db, entry_id: str, title: str, content: str,
                      entry_type: str = "pattern", domain: str = "general",
                      created_days_ago: int = 0):
    """Insert a knowledge entry directly into the DB."""
    created = datetime.now() - timedelta(days=created_days_ago)
    db._conn.execute(
        """INSERT INTO knowledge_entries (id, entry_type, title, content, domain,
           scope, created_at, updated_at)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
        (entry_id, entry_type, title, content, domain, "cross-cutting",
         created.isoformat(), created.isoformat()),
    )
    db._conn.commit()


def _insert_observation(db, obs_id: str, title: str, content: str,
                        category: str = "insight"):
    """Insert an observation directly into the DB."""
    db._conn.execute(
        """INSERT INTO observations (id, session_id, trigger, title, content, category)
           VALUES (?, 'test', 'test', ?, ?, ?)""",
        (obs_id, title, content, category),
    )
    db._conn.commit()


def _seed_recall_db(path: str, days_old: int = 120):
    """Seed a recall DB with entries old enough to trigger decay."""
    conn = sqlite3.connect(path)
    old_ts = (datetime.now(timezone.utc) - timedelta(days=days_old)).isoformat()

    conn.execute(
        """INSERT INTO knowledge_entries (id, title, content, domain, created_at, updated_at)
           VALUES (?, ?, ?, ?, ?, ?)""",
        ("kn-decay-001", "WAL mode benefits", "Write-ahead logging improves concurrency",
         "database", old_ts, old_ts),
    )
    conn.execute(
        """INSERT INTO observations (id, title, content, confidence, stability_days,
           last_confirmed_at, created_at)
           VALUES (?, ?, ?, ?, ?, ?, ?)""",
        ("obs-decay-001", "Always validate JWT exp", "Check token expiration before trusting",
         0.6, 45.0, old_ts, old_ts),
    )
    conn.execute(
        """INSERT INTO patterns (id, title, domain, observation, confirmations,
           last_applied_at, created_at)
           VALUES (?, ?, ?, ?, ?, ?, ?)""",
        ("pat-decay-001", "TDD First", "testing", "Write tests before code",
         0, old_ts, old_ts),
    )
    conn.commit()
    conn.close()


# ---------------------------------------------------------------------------
# 1. Export → Import Round-Trip
# ---------------------------------------------------------------------------

class TestExportImportRoundTrip:
    """Full export → import lifecycle with data integrity verification."""

    def test_json_round_trip_preserves_fields(self, db, knowledge):
        """Exported knowledge imports into a fresh DB with all fields intact."""
        entry = knowledge.create(
            entry_type="gotcha",
            title="SQLite PRAGMA ordering",
            content="PRAGMA journal_mode must be set before any writes",
            domain="database",
        )

        exporter = KnowledgeExporter(db)
        data = exporter.to_json(domain="database")

        assert data["format"] == "greymatter-export"
        assert data["counts"]["knowledge_entries"] >= 1

        target_db = SQLiteDatabase(":memory:")
        try:
            importer = KnowledgeImporter(target_db)
            result = importer.from_json(data)

            assert result["imported"] == 1
            assert result["skipped"] == 0
            assert result["errors"] == 0

            imported_rows = target_db.execute(
                "SELECT title, content, domain, entry_type FROM knowledge_entries"
            )
            assert len(imported_rows) == 1
            row = imported_rows[0]
            assert row["title"] == "SQLite PRAGMA ordering"
            assert row["content"] == "PRAGMA journal_mode must be set before any writes"
            assert row["domain"] == "database"
            assert row["entry_type"] == "gotcha"
        finally:
            target_db.close()

    def test_duplicate_detection_on_reimport(self, db, knowledge):
        """Importing the same data twice skips duplicates."""
        knowledge.create(
            entry_type="expertise",
            title="Async TaskGroup",
            content="Use asyncio.TaskGroup for structured concurrency",
            domain="python",
        )

        exporter = KnowledgeExporter(db)
        data = exporter.to_json()

        importer = KnowledgeImporter(db)
        result = importer.from_json(data)
        assert result["skipped"] >= 1
        assert result["imported"] == 0

    def test_selective_export_by_domain(self, db, knowledge):
        """Export filtered by domain excludes other domains."""
        knowledge.create(
            entry_type="reference", title="Python typing",
            content="Use TypeAlias for complex types", domain="python",
        )
        knowledge.create(
            entry_type="reference", title="Go interfaces",
            content="Accept interfaces, return structs", domain="go",
        )

        exporter = KnowledgeExporter(db)
        python_data = exporter.to_json(domain="python")
        assert python_data["counts"]["knowledge_entries"] == 1

        all_data = exporter.to_json()
        assert all_data["counts"]["knowledge_entries"] == 2

    def test_export_reimport_reexport_consistency(self, db, knowledge):
        """Export → import → re-export produces equivalent content."""
        for i in range(3):
            knowledge.create(
                entry_type="gotcha",
                title=f"Gotcha #{i}",
                content=f"Description of gotcha {i}",
                domain="testing",
            )

        exporter = KnowledgeExporter(db)
        first_export = exporter.to_json(domain="testing")

        target_db = SQLiteDatabase(":memory:")
        try:
            importer = KnowledgeImporter(target_db)
            importer.from_json(first_export)

            re_exporter = KnowledgeExporter(target_db)
            second_export = re_exporter.to_json()

            first_titles = sorted(
                e["title"] for e in first_export["knowledge_entries"]
            )
            second_titles = sorted(
                e["title"] for e in second_export["knowledge_entries"]
            )
            assert first_titles == second_titles
        finally:
            target_db.close()

    def test_invalid_format_rejected(self, db):
        """Importer rejects payloads with wrong format marker."""
        importer = KnowledgeImporter(db)
        result = importer.from_json({"format": "wrong-format", "knowledge_entries": []})
        assert "error" in result

    def test_markdown_export_contains_all_entries(self, db, knowledge):
        """Markdown export includes all entries grouped by domain."""
        knowledge.create(
            entry_type="expertise", title="WAL Mode",
            content="Write-ahead logging for concurrent access", domain="database",
        )
        knowledge.create(
            entry_type="gotcha", title="FTS5 Triggers",
            content="FTS5 needs manual trigger sync", domain="database",
        )

        exporter = KnowledgeExporter(db)
        md = exporter.to_markdown(domain="database")

        assert "WAL Mode" in md
        assert "FTS5 Triggers" in md
        assert "## database" in md

    def test_export_file_round_trip(self, db, knowledge, tmp_path):
        """Export to file → import from file preserves data."""
        knowledge.create(
            entry_type="reference", title="File Round Trip",
            content="Tests file I/O path", domain="testing",
        )

        exporter = KnowledgeExporter(db)
        json_path = str(tmp_path / "export.json")
        counts = exporter.to_json_file(json_path)
        assert counts["knowledge_entries"] == 1

        target_db = SQLiteDatabase(":memory:")
        try:
            importer = KnowledgeImporter(target_db)
            result = importer.from_json_file(json_path)
            assert result["imported"] == 1
        finally:
            target_db.close()


# ---------------------------------------------------------------------------
# 2. Recall Decay + Review Lifecycle
# ---------------------------------------------------------------------------

class TestRecallDecayLifecycle:
    """Tests the full recall lifecycle: decay → due → review → stability boost."""

    def test_old_entries_appear_due_for_review(self, recall_db):
        """Entries older than their stability window are flagged for review."""
        _seed_recall_db(recall_db, days_old=120)

        recall = ProactiveRecall(db_path=recall_db)
        try:
            due = recall.get_due_for_review(limit=10)
            assert len(due) >= 1

            ids = {item["id"] for item in due}
            assert "kn-decay-001" in ids or "obs-decay-001" in ids or "pat-decay-001" in ids

            for item in due:
                assert item["retrievability"] < REVIEW_THRESHOLD
                assert item["days_since_review"] > 0
                assert item["source_table"] in (
                    "knowledge_entries", "observations", "patterns"
                )
        finally:
            recall.close()

    def test_fresh_entries_not_due(self, recall_db):
        """Recently created entries should not appear as due for review."""
        conn = sqlite3.connect(recall_db)
        now = datetime.now(timezone.utc).isoformat()
        conn.execute(
            """INSERT INTO knowledge_entries (id, title, content, created_at, updated_at)
               VALUES (?, ?, ?, ?, ?)""",
            ("kn-fresh-001", "Fresh entry", "Just created", now, now),
        )
        conn.commit()
        conn.close()

        recall = ProactiveRecall(db_path=recall_db)
        try:
            due = recall.get_due_for_review(limit=50)
            due_ids = {item["id"] for item in due}
            assert "kn-fresh-001" not in due_ids
        finally:
            recall.close()

    def test_review_increases_stability(self, recall_db):
        """Recording a review increases observation stability by CONFIRM_FACTOR."""
        _seed_recall_db(recall_db, days_old=120)

        recall = ProactiveRecall(db_path=recall_db)
        try:
            conn = sqlite3.connect(recall_db)
            before = conn.execute(
                "SELECT stability_days FROM observations WHERE id = 'obs-decay-001'"
            ).fetchone()
            original_stability = before[0]
            conn.close()

            success = recall.record_review("obs-decay-001", source_table="observations")
            assert success is True

            conn = sqlite3.connect(recall_db)
            after = conn.execute(
                "SELECT stability_days, review_count FROM observations WHERE id = 'obs-decay-001'"
            ).fetchone()
            conn.close()

            assert after[0] == pytest.approx(
                min(original_stability * CONFIRM_FACTOR, MAX_STABILITY_DAYS)
            )
            assert after[1] == 1
        finally:
            recall.close()

    def test_knowledge_review_bumps_applied_count(self, recall_db):
        """Active review of knowledge entry increments applied_count."""
        _seed_recall_db(recall_db, days_old=120)

        recall = ProactiveRecall(db_path=recall_db)
        try:
            success = recall.record_review("kn-decay-001", source_table="knowledge_entries")
            assert success is True

            conn = sqlite3.connect(recall_db)
            row = conn.execute(
                "SELECT applied_count FROM knowledge_entries WHERE id = 'kn-decay-001'"
            ).fetchone()
            conn.close()
            assert row[0] == 1
        finally:
            recall.close()

    def test_passive_review_resets_clock_without_boost(self, recall_db):
        """Passive review (factor < CONFIRM_FACTOR) resets decay clock only."""
        _seed_recall_db(recall_db, days_old=120)

        recall = ProactiveRecall(db_path=recall_db)
        try:
            conn = sqlite3.connect(recall_db)
            before = conn.execute(
                "SELECT applied_count FROM knowledge_entries WHERE id = 'kn-decay-001'"
            ).fetchone()
            conn.close()

            success = recall.record_review(
                "kn-decay-001",
                source_table="knowledge_entries",
                stability_factor=1.5,
            )
            assert success is True

            conn = sqlite3.connect(recall_db)
            after = conn.execute(
                "SELECT applied_count FROM knowledge_entries WHERE id = 'kn-decay-001'"
            ).fetchone()
            conn.close()
            # Passive review: applied_count should NOT increase
            assert after[0] == before[0]
        finally:
            recall.close()

    def test_pattern_review_increments_confirmations(self, recall_db):
        """Active review of a pattern increments its confirmations count."""
        _seed_recall_db(recall_db, days_old=120)

        recall = ProactiveRecall(db_path=recall_db)
        try:
            success = recall.record_review("pat-decay-001", source_table="patterns")
            assert success is True

            conn = sqlite3.connect(recall_db)
            row = conn.execute(
                "SELECT confirmations FROM patterns WHERE id = 'pat-decay-001'"
            ).fetchone()
            conn.close()
            assert row[0] == 1
        finally:
            recall.close()

    def test_review_then_recheck_reduces_urgency(self, recall_db):
        """After review, the entry's retrievability should improve."""
        _seed_recall_db(recall_db, days_old=120)

        recall = ProactiveRecall(db_path=recall_db)
        try:
            due_before = recall.get_due_for_review(limit=10)
            obs_before = [d for d in due_before if d["id"] == "obs-decay-001"]

            recall.record_review("obs-decay-001", source_table="observations")

            due_after = recall.get_due_for_review(limit=10)
            obs_after = [d for d in due_after if d["id"] == "obs-decay-001"]

            if obs_before and obs_after:
                assert obs_after[0]["retrievability"] > obs_before[0]["retrievability"]
            elif obs_before and not obs_after:
                pass  # Entry no longer due — even better
        finally:
            recall.close()

    def test_format_recall_prompt_urgency_levels(self, recall_db):
        """Recall prompt formatting assigns correct urgency based on retrievability."""
        recall = ProactiveRecall(db_path=recall_db)
        try:
            entries = [
                {"id": "kn-1", "source_table": "knowledge_entries",
                 "entry_type": "gotcha", "title": "Critical item",
                 "domain": "db", "retrievability": 0.2,
                 "days_since_review": 200, "stability": 30,
                 "content_preview": "Very old entry"},
                {"id": "obs-1", "source_table": "observations",
                 "entry_type": "insight", "title": "High urgency",
                 "domain": "testing", "retrievability": 0.45,
                 "days_since_review": 90, "stability": 45,
                 "content_preview": "Moderately old"},
                {"id": "pat-1", "source_table": "patterns",
                 "entry_type": "pattern", "title": "Due item",
                 "domain": "coding", "retrievability": 0.65,
                 "days_since_review": 30, "stability": 60,
                 "content_preview": "Recently approaching threshold"},
            ]

            prompt = recall.format_recall_prompt(entries)
            assert "[CRITICAL]" in prompt
            assert "[HIGH]" in prompt
            assert "[DUE]" in prompt
            assert "3 item(s)" in prompt
            assert "Critical item" in prompt
        finally:
            recall.close()

    def test_empty_recall_prompt(self, recall_db):
        """Empty entries list produces empty prompt."""
        recall = ProactiveRecall(db_path=recall_db)
        try:
            assert recall.format_recall_prompt([]) == ""
        finally:
            recall.close()

    def test_auto_detect_source_table_from_id_prefix(self, recall_db):
        """record_review auto-detects source table from ID prefix."""
        _seed_recall_db(recall_db, days_old=120)

        recall = ProactiveRecall(db_path=recall_db)
        try:
            assert recall.record_review("kn-decay-001") is True
            assert recall.record_review("obs-decay-001") is True
            assert recall.record_review("pat-decay-001") is True
            # Unknown prefix
            assert recall.record_review("unk-000") is False
        finally:
            recall.close()


# ---------------------------------------------------------------------------
# 3. Observation Dedup Pipeline
# ---------------------------------------------------------------------------

class TestDedupIntegration:
    """Tests dedup across the full observation pipeline."""

    @pytest.fixture
    def dedup_conn(self):
        """In-memory DB with observations + FTS for dedup tests."""
        conn = sqlite3.connect(":memory:")
        conn.executescript("""
            CREATE TABLE observations (
                id TEXT PRIMARY KEY,
                title TEXT NOT NULL,
                content TEXT NOT NULL DEFAULT '',
                created_at TEXT DEFAULT (datetime('now')),
                updated_at TEXT DEFAULT (datetime('now')),
                pruned INTEGER DEFAULT 0
            );

            CREATE VIRTUAL TABLE observations_fts USING fts5(
                title, content, content='observations', content_rowid='rowid'
            );

            CREATE TRIGGER observations_ai AFTER INSERT ON observations BEGIN
                INSERT INTO observations_fts(rowid, title, content)
                VALUES (new.rowid, new.title, new.content);
            END;
        """)
        yield conn
        conn.close()

    def test_dedup_merges_same_title_within_window(self, dedup_conn):
        """Duplicate observations with same title within window are merged."""
        now = datetime.now(timezone.utc)
        for i in range(3):
            ts = (now + timedelta(minutes=i)).isoformat()
            dedup_conn.execute(
                """INSERT INTO observations (id, title, content, created_at, updated_at)
                   VALUES (?, ?, ?, ?, ?)""",
                (f"obs-dup-{i}", "Same Title", json.dumps({"msg": f"version {i}"}),
                 ts, ts),
            )
        dedup_conn.commit()

        deleted = dedup_observations(dedup_conn, window_minutes=10)
        assert deleted == 2

        remaining = dedup_conn.execute("SELECT * FROM observations").fetchall()
        assert len(remaining) == 1
        assert remaining[0][0] == "obs-dup-0"  # Earliest kept

        content = json.loads(remaining[0][2])
        assert content["dedup_count"] == 3
        assert len(content["dedup_merged_ids"]) == 2

    def test_dedup_dry_run_no_modification(self, dedup_conn):
        """Dry run calculates merge count without modifying data."""
        now = datetime.now(timezone.utc)
        for i in range(2):
            ts = (now + timedelta(minutes=i)).isoformat()
            dedup_conn.execute(
                """INSERT INTO observations (id, title, content, created_at, updated_at)
                   VALUES (?, ?, ?, ?, ?)""",
                (f"obs-dry-{i}", "Dry Run", json.dumps({"test": True}), ts, ts),
            )
        dedup_conn.commit()

        deleted = dedup_observations(dedup_conn, window_minutes=10, dry_run=True)
        assert deleted == 1

        count = dedup_conn.execute("SELECT COUNT(*) FROM observations").fetchone()[0]
        assert count == 2  # Nothing deleted

    def test_dedup_respects_window_boundary(self, dedup_conn):
        """Observations outside the time window are not merged."""
        now = datetime.now(timezone.utc)
        t1 = now.isoformat()
        t2 = (now + timedelta(minutes=30)).isoformat()

        dedup_conn.execute(
            """INSERT INTO observations (id, title, content, created_at, updated_at)
               VALUES (?, ?, ?, ?, ?)""",
            ("obs-early", "Window Test", '{"v": 1}', t1, t1),
        )
        dedup_conn.execute(
            """INSERT INTO observations (id, title, content, created_at, updated_at)
               VALUES (?, ?, ?, ?, ?)""",
            ("obs-late", "Window Test", '{"v": 2}', t2, t2),
        )
        dedup_conn.commit()

        deleted = dedup_observations(dedup_conn, window_minutes=5)
        assert deleted == 0

    def test_should_deduplicate_finds_recent_match(self, dedup_conn):
        """should_deduplicate returns existing ID for recent same-title observation."""
        now = datetime.now(timezone.utc).isoformat()
        dedup_conn.execute(
            """INSERT INTO observations (id, title, content, created_at)
               VALUES (?, ?, ?, ?)""",
            ("obs-existing", "Duplicate Check", '{}', now),
        )
        dedup_conn.commit()

        result = should_deduplicate(dedup_conn, "Duplicate Check", window_minutes=5)
        assert result == "obs-existing"

    def test_should_deduplicate_returns_none_for_new(self, dedup_conn):
        """should_deduplicate returns None when no matching title exists."""
        result = should_deduplicate(dedup_conn, "Brand New Title", window_minutes=5)
        assert result is None

    def test_increment_observation_updates_count(self, dedup_conn):
        """increment_observation bumps dedup_count and updates content."""
        dedup_conn.execute(
            """INSERT INTO observations (id, title, content)
               VALUES (?, ?, ?)""",
            ("obs-inc", "Increment Test", json.dumps({"data": "original"})),
        )
        dedup_conn.commit()

        increment_observation(
            dedup_conn, "obs-inc",
            json.dumps({"data": "updated", "extra": "field"}),
        )

        row = dedup_conn.execute(
            "SELECT content FROM observations WHERE id = 'obs-inc'"
        ).fetchone()
        content = json.loads(row[0])
        assert content["dedup_count"] == 2
        assert content["data"] == "updated"
        assert content["extra"] == "field"

    def test_dedup_then_should_deduplicate_integration(self, dedup_conn):
        """After batch dedup, should_deduplicate still works correctly."""
        now = datetime.now(timezone.utc)
        for i in range(3):
            ts = (now + timedelta(seconds=i * 10)).isoformat()
            dedup_conn.execute(
                """INSERT INTO observations (id, title, content, created_at, updated_at)
                   VALUES (?, ?, ?, ?, ?)""",
                (f"obs-batch-{i}", "Batch Test", json.dumps({"n": i}), ts, ts),
            )
        dedup_conn.commit()

        dedup_observations(dedup_conn, window_minutes=10)

        remaining = dedup_conn.execute("SELECT id FROM observations").fetchall()
        assert len(remaining) == 1

        result = should_deduplicate(dedup_conn, "Batch Test", window_minutes=60)
        assert result == "obs-batch-0"


# ---------------------------------------------------------------------------
# 4. Cross-Pipeline Integration: Knowledge → Export → Recall
# ---------------------------------------------------------------------------

class TestKnowledgeExportRecallPipeline:
    """Tests that span knowledge creation, export, and recall systems."""

    def test_exported_knowledge_decays_in_target_db(self, db, knowledge, tmp_path):
        """Knowledge exported and imported into a new DB is subject to recall decay."""
        knowledge.create(
            entry_type="expertise", title="Spaced Repetition",
            content="FSRS-6 model for memory retention", domain="ml",
        )

        exporter = KnowledgeExporter(db)
        data = exporter.to_json()

        target_path = str(tmp_path / "target.db")
        target_db = SQLiteDatabase(target_path)
        try:
            importer = KnowledgeImporter(target_db)
            result = importer.from_json(data)
            assert result["imported"] == 1

            # Backdate the entry to simulate aging
            conn = sqlite3.connect(target_path)
            old_ts = (datetime.now() - timedelta(days=200)).isoformat()
            conn.execute(
                "UPDATE knowledge_entries SET created_at = ?, updated_at = ?",
                (old_ts, old_ts),
            )
            conn.commit()
            conn.close()

            recall = ProactiveRecall(db_path=target_path)
            try:
                due = recall.get_due_for_review(limit=10)
                due_titles = [d["title"] for d in due]
                assert "Spaced Repetition" in due_titles
            finally:
                recall.close()
        finally:
            target_db.close()

    def test_multiple_reviews_increase_stability_progressively(self, recall_db):
        """Multiple reviews compound stability increases via spaced repetition."""
        _seed_recall_db(recall_db, days_old=120)

        recall = ProactiveRecall(db_path=recall_db)
        try:
            conn = sqlite3.connect(recall_db)

            for i in range(3):
                before = conn.execute(
                    "SELECT stability_days FROM observations WHERE id = 'obs-decay-001'"
                ).fetchone()
                recall.record_review("obs-decay-001", source_table="observations")
                after = conn.execute(
                    "SELECT stability_days FROM observations WHERE id = 'obs-decay-001'"
                ).fetchone()
                assert after[0] > before[0], f"Review {i+1} should increase stability"

            final = conn.execute(
                "SELECT stability_days, review_count FROM observations WHERE id = 'obs-decay-001'"
            ).fetchone()
            conn.close()

            assert final[1] == 3
            expected = min(45.0 * (CONFIRM_FACTOR ** 3), MAX_STABILITY_DAYS)
            assert final[0] == pytest.approx(expected, rel=0.01)
        finally:
            recall.close()

    def test_due_count_matches_due_list(self, recall_db):
        """get_due_count() result is consistent with get_due_for_review()."""
        _seed_recall_db(recall_db, days_old=120)

        recall = ProactiveRecall(db_path=recall_db)
        try:
            count = recall.get_due_count()
            due = recall.get_due_for_review(limit=99)
            assert count == len(due)
        finally:
            recall.close()


# ---------------------------------------------------------------------------
# 5. Decay Math Verification
# ---------------------------------------------------------------------------

class TestDecayMathIntegration:
    """Verify the FSRS-6 decay model produces expected behavior."""

    def test_fresh_entry_full_retrievability(self):
        """An entry with 0 days elapsed should have retrievability near 1.0."""
        r = _compute_retrievability(0, DEFAULT_STABILITY)
        assert r == pytest.approx(1.0)

    def test_decay_below_threshold_at_expected_time(self):
        """Entry decays below REVIEW_THRESHOLD within expected timeframe."""
        # With default stability of 45 days, find when R < 0.7
        for days in range(1, 500):
            r = _compute_retrievability(days, DEFAULT_STABILITY)
            if r < REVIEW_THRESHOLD:
                assert days > 10, "Should not decay too fast"
                assert days < 200, "Should not take too long to decay"
                break

    def test_higher_stability_decays_slower(self):
        """Entries with higher stability retain better at the same elapsed time."""
        days = 100
        r_low = _compute_retrievability(days, 30)
        r_high = _compute_retrievability(days, 300)
        assert r_high > r_low

    def test_retrievability_bounded(self):
        """Retrievability is always between C_FLOOR and 1.0."""
        for days in (0, 1, 10, 100, 1000, 10000):
            for stability in (1, 10, 100, 1000):
                r = _compute_retrievability(days, stability)
                assert C_FLOOR <= r <= 1.0

    def test_zero_stability_uses_default(self):
        """Stability of 0 falls back to DEFAULT_STABILITY."""
        r = _compute_retrievability(30, 0)
        r_default = _compute_retrievability(30, DEFAULT_STABILITY)
        assert r == pytest.approx(r_default)
