#!/usr/bin/env python3
"""Tests for spaced retrieval scheduler box progression logic.

Verifies Leitner-box advance/regress mechanics in isolation using
an in-memory SQLite database — no real brain_search calls.
"""
import json
import sqlite3
import sys
import unittest
from datetime import datetime, timezone, timedelta
from pathlib import Path
from unittest.mock import patch

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from pulse.daemons.consolidation.retrieval_scheduler import (
    INTERVALS_DAYS,
    MAX_BOX,
    _advance_box,
    _regress_box,
    _initialize_unscheduled,
    _get_due_items,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_conn():
    """In-memory SQLite with the schema used by the scheduler."""
    conn = sqlite3.connect(":memory:")
    conn.row_factory = sqlite3.Row
    for tbl in ("lessons", "failures", "patterns"):
        conn.execute(f"""
            CREATE TABLE {tbl} (
                id       INTEGER PRIMARY KEY,
                title    TEXT,
                content  TEXT,
                confidence REAL DEFAULT 0.5,
                salience   REAL DEFAULT 1.0,
                validity   TEXT DEFAULT 'valid',
                metadata   TEXT
            )
        """)
    conn.commit()
    return conn


def _insert(conn, table: str, title: str, content: str,
            confidence: float = 0.6, salience: float = 1.0,
            metadata: dict | None = None) -> int:
    meta_str = json.dumps(metadata) if metadata else None
    cur = conn.execute(
        f"INSERT INTO {table} (title,content,confidence,salience,metadata) VALUES (?,?,?,?,?)",
        (title, content, confidence, salience, meta_str),
    )
    conn.commit()
    return cur.lastrowid


def _fetch(conn, table: str, row_id: int) -> dict:
    row = conn.execute(
        f"SELECT * FROM {table} WHERE id=?", (row_id,)
    ).fetchone()
    return dict(row)


NOW = datetime(2026, 3, 17, 12, 0, 0, tzinfo=timezone.utc)


# ---------------------------------------------------------------------------
# Box constants sanity
# ---------------------------------------------------------------------------
class TestConstants(unittest.TestCase):
    def test_interval_count(self):
        self.assertEqual(len(INTERVALS_DAYS), 6)

    def test_max_box(self):
        self.assertEqual(MAX_BOX, 5)

    def test_intervals_ascending(self):
        for i in range(1, len(INTERVALS_DAYS)):
            self.assertGreater(INTERVALS_DAYS[i], INTERVALS_DAYS[i - 1])

    def test_first_interval(self):
        self.assertEqual(INTERVALS_DAYS[0], 1)

    def test_last_interval(self):
        self.assertEqual(INTERVALS_DAYS[-1], 60)


# ---------------------------------------------------------------------------
# _advance_box
# ---------------------------------------------------------------------------
class TestAdvanceBox(unittest.TestCase):
    def setUp(self):
        self.conn = _make_conn()

    def _item(self, box: int, table: str = "lessons") -> dict:
        meta = {"spaced_box": box, "next_review_at": NOW.isoformat(),
                "review_count": 0, "last_reviewed": None}
        row_id = _insert(self.conn, table, "title text here", "content body here",
                         metadata=meta)
        row = _fetch(self.conn, table, row_id)
        row["tbl"] = table
        return row

    def _run(self, item: dict) -> dict:
        _advance_box(self.conn, item, NOW)
        self.conn.commit()
        return json.loads(_fetch(self.conn, item["tbl"], item["id"])["metadata"])

    def test_box0_advances_to_1(self):
        meta = self._run(self._item(0))
        self.assertEqual(meta["spaced_box"], 1)

    def test_box1_advances_to_2(self):
        meta = self._run(self._item(1))
        self.assertEqual(meta["spaced_box"], 2)

    def test_box4_advances_to_5(self):
        meta = self._run(self._item(4))
        self.assertEqual(meta["spaced_box"], 5)

    def test_box5_clamped_at_max(self):
        """At MAX_BOX, advance must NOT go to box 6."""
        meta = self._run(self._item(5))
        self.assertEqual(meta["spaced_box"], MAX_BOX)

    def test_interval_matches_new_box(self):
        """next_review_at must reflect INTERVALS_DAYS[new_box]."""
        item = self._item(0)
        meta = self._run(item)
        expected_dt = NOW + timedelta(days=INTERVALS_DAYS[1])
        actual_dt = datetime.fromisoformat(meta["next_review_at"])
        # Allow 1-second tolerance
        self.assertAlmostEqual(
            actual_dt.timestamp(), expected_dt.timestamp(), delta=1
        )

    def test_interval_at_max_box(self):
        """Clamped advance still uses INTERVALS_DAYS[MAX_BOX]."""
        item = self._item(MAX_BOX)
        meta = self._run(item)
        expected_dt = NOW + timedelta(days=INTERVALS_DAYS[MAX_BOX])
        actual_dt = datetime.fromisoformat(meta["next_review_at"])
        self.assertAlmostEqual(
            actual_dt.timestamp(), expected_dt.timestamp(), delta=1
        )

    def test_review_count_incremented(self):
        item = self._item(2)
        meta = self._run(item)
        self.assertEqual(meta["review_count"], 1)

    def test_last_result_pass(self):
        item = self._item(1)
        meta = self._run(item)
        self.assertEqual(meta["last_result"], "pass")

    def test_last_reviewed_set(self):
        item = self._item(0)
        meta = self._run(item)
        self.assertIsNotNone(meta["last_reviewed"])

    def test_works_on_failures_table(self):
        item = self._item(0, "failures")
        meta = self._run(item)
        self.assertEqual(meta["spaced_box"], 1)

    def test_works_on_patterns_table(self):
        item = self._item(0, "patterns")
        meta = self._run(item)
        self.assertEqual(meta["spaced_box"], 1)


# ---------------------------------------------------------------------------
# _regress_box
# ---------------------------------------------------------------------------
class TestRegressBox(unittest.TestCase):
    def setUp(self):
        self.conn = _make_conn()

    def _item(self, box: int, salience: float = 1.0, table: str = "lessons") -> dict:
        meta = {"spaced_box": box, "next_review_at": NOW.isoformat(),
                "review_count": 0, "last_reviewed": None}
        row_id = _insert(self.conn, table, "title text here", "content body",
                         salience=salience, metadata=meta)
        row = _fetch(self.conn, table, row_id)
        row["tbl"] = table
        return row

    def _run(self, item: dict) -> tuple[dict, dict]:
        _regress_box(self.conn, item, NOW)
        self.conn.commit()
        db_row = _fetch(self.conn, item["tbl"], item["id"])
        meta = json.loads(db_row["metadata"])
        return meta, db_row

    def test_box3_regresses_to_2(self):
        meta, _ = self._run(self._item(3))
        self.assertEqual(meta["spaced_box"], 2)

    def test_box1_regresses_to_0(self):
        meta, _ = self._run(self._item(1))
        self.assertEqual(meta["spaced_box"], 0)

    def test_box0_clamped_at_0(self):
        """At box 0, regress must NOT go to -1."""
        meta, _ = self._run(self._item(0))
        self.assertEqual(meta["spaced_box"], 0)

    def test_interval_matches_new_box(self):
        item = self._item(3)
        meta, _ = self._run(item)
        expected_dt = NOW + timedelta(days=INTERVALS_DAYS[2])
        actual_dt = datetime.fromisoformat(meta["next_review_at"])
        self.assertAlmostEqual(
            actual_dt.timestamp(), expected_dt.timestamp(), delta=1
        )

    def test_interval_at_box0_clamp(self):
        """Clamped regress uses INTERVALS_DAYS[0]."""
        item = self._item(0)
        meta, _ = self._run(item)
        expected_dt = NOW + timedelta(days=INTERVALS_DAYS[0])
        actual_dt = datetime.fromisoformat(meta["next_review_at"])
        self.assertAlmostEqual(
            actual_dt.timestamp(), expected_dt.timestamp(), delta=1
        )

    def test_salience_boosted(self):
        item = self._item(2, salience=1.0)
        _, row = self._run(item)
        self.assertAlmostEqual(row["salience"], 1.5, places=2)

    def test_salience_capped_at_7(self):
        item = self._item(2, salience=6.8)
        _, row = self._run(item)
        self.assertAlmostEqual(row["salience"], 7.0, places=2)

    def test_salience_at_max_stays_at_7(self):
        item = self._item(2, salience=7.0)
        _, row = self._run(item)
        self.assertAlmostEqual(row["salience"], 7.0, places=2)

    def test_review_count_incremented(self):
        item = self._item(2)
        meta, _ = self._run(item)
        self.assertEqual(meta["review_count"], 1)

    def test_last_result_fail(self):
        item = self._item(2)
        meta, _ = self._run(item)
        self.assertEqual(meta["last_result"], "fail")


# ---------------------------------------------------------------------------
# _initialize_unscheduled
# ---------------------------------------------------------------------------
class TestInitializeUnscheduled(unittest.TestCase):
    def setUp(self):
        self.conn = _make_conn()

    def test_adds_metadata_to_new_item(self):
        _insert(self.conn, "lessons", "title text", "body content", confidence=0.6)
        count = _initialize_unscheduled(self.conn, NOW)
        self.assertGreater(count, 0)
        row = self.conn.execute("SELECT metadata FROM lessons").fetchone()
        meta = json.loads(row["metadata"])
        self.assertIn("spaced_box", meta)
        self.assertEqual(meta["spaced_box"], 0)

    def test_skips_already_scheduled_items(self):
        meta = {"spaced_box": 2, "next_review_at": "2026-04-01T00:00:00+00:00",
                "review_count": 1, "last_reviewed": None}
        _insert(self.conn, "lessons", "title text", "body content",
                confidence=0.6, metadata=meta)
        count = _initialize_unscheduled(self.conn, NOW)
        self.assertEqual(count, 0)

    def test_skips_low_confidence_items(self):
        _insert(self.conn, "lessons", "title text", "body content", confidence=0.3)
        count = _initialize_unscheduled(self.conn, NOW)
        self.assertEqual(count, 0)

    def test_initial_box_is_0(self):
        _insert(self.conn, "lessons", "title text", "body content", confidence=0.5)
        _initialize_unscheduled(self.conn, NOW)
        row = self.conn.execute("SELECT metadata FROM lessons").fetchone()
        meta = json.loads(row["metadata"])
        self.assertEqual(meta["spaced_box"], 0)

    def test_next_review_in_1_day(self):
        _insert(self.conn, "lessons", "title text", "body content", confidence=0.5)
        _initialize_unscheduled(self.conn, NOW)
        row = self.conn.execute("SELECT metadata FROM lessons").fetchone()
        meta = json.loads(row["metadata"])
        expected = (NOW + timedelta(days=1)).isoformat()
        self.assertEqual(meta["next_review_at"], expected)

    def test_initial_review_count_is_0(self):
        _insert(self.conn, "lessons", "title text", "body content", confidence=0.6)
        _initialize_unscheduled(self.conn, NOW)
        row = self.conn.execute("SELECT metadata FROM lessons").fetchone()
        meta = json.loads(row["metadata"])
        self.assertEqual(meta["review_count"], 0)

    def test_initial_last_reviewed_is_none(self):
        _insert(self.conn, "lessons", "title text", "body content", confidence=0.6)
        _initialize_unscheduled(self.conn, NOW)
        row = self.conn.execute("SELECT metadata FROM lessons").fetchone()
        meta = json.loads(row["metadata"])
        self.assertIsNone(meta["last_reviewed"])

    def test_all_three_tables_initialized(self):
        """Items in all three tables get scheduled in one call."""
        _insert(self.conn, "lessons", "title text", "body content", confidence=0.6)
        _insert(self.conn, "failures", "title text", "body content", confidence=0.6)
        _insert(self.conn, "patterns", "title text", "body content", confidence=0.6)
        count = _initialize_unscheduled(self.conn, NOW)
        self.assertEqual(count, 3)

    def test_confidence_at_threshold_included(self):
        """Confidence exactly at 0.4 must be scheduled (>= not >)."""
        _insert(self.conn, "lessons", "title text", "body content", confidence=0.4)
        count = _initialize_unscheduled(self.conn, NOW)
        self.assertEqual(count, 1)

    def test_skips_invalid_validity(self):
        """Items with validity != 'valid' must not be scheduled."""
        conn = self.conn
        conn.execute(
            "INSERT INTO lessons (title, content, confidence, salience, validity, metadata) VALUES (?,?,?,?,?,?)",
            ("title text", "body content", 0.6, 1.0, "invalid", None),
        )
        conn.commit()
        count = _initialize_unscheduled(conn, NOW)
        self.assertEqual(count, 0)

    def test_returns_correct_count(self):
        """Return value equals the number of newly scheduled items."""
        for i in range(3):
            _insert(self.conn, "lessons", f"title {i}", "body content", confidence=0.7)
        count = _initialize_unscheduled(self.conn, NOW)
        self.assertEqual(count, 3)


# ---------------------------------------------------------------------------
# _get_due_items
# ---------------------------------------------------------------------------
class TestGetDueItems(unittest.TestCase):
    def setUp(self):
        self.conn = _make_conn()

    def _scheduled(self, table: str, review_at: datetime, box: int = 0) -> int:
        meta = {"spaced_box": box,
                "next_review_at": review_at.isoformat(),
                "review_count": 0, "last_reviewed": None}
        return _insert(self.conn, table, "title text", "body content",
                       confidence=0.6, metadata=meta)

    def test_due_item_returned(self):
        past = NOW - timedelta(hours=1)
        self._scheduled("lessons", past)
        due = _get_due_items(self.conn, NOW.isoformat(), 20)
        self.assertEqual(len(due), 1)

    def test_future_item_not_returned(self):
        future = NOW + timedelta(days=2)
        self._scheduled("lessons", future)
        due = _get_due_items(self.conn, NOW.isoformat(), 20)
        self.assertEqual(len(due), 0)

    def test_limit_respected(self):
        past = NOW - timedelta(hours=1)
        for _ in range(5):
            self._scheduled("lessons", past)
        due = _get_due_items(self.conn, NOW.isoformat(), 3)
        self.assertEqual(len(due), 3)

    def test_items_across_tables(self):
        past = NOW - timedelta(hours=1)
        self._scheduled("lessons", past)
        self._scheduled("failures", past)
        self._scheduled("patterns", past)
        due = _get_due_items(self.conn, NOW.isoformat(), 20)
        tables = {d["tbl"] for d in due}
        self.assertEqual(tables, {"lessons", "failures", "patterns"})

    def test_item_has_tbl_field(self):
        past = NOW - timedelta(hours=1)
        self._scheduled("lessons", past)
        due = _get_due_items(self.conn, NOW.isoformat(), 20)
        self.assertIn("tbl", due[0])
        self.assertEqual(due[0]["tbl"], "lessons")


# ---------------------------------------------------------------------------
# Full advance/regress cycle
# ---------------------------------------------------------------------------
class TestFullCycle(unittest.TestCase):
    """Simulate a multi-cycle sequence to check box monotonicity."""

    def setUp(self):
        self.conn = _make_conn()
        meta = {"spaced_box": 0, "next_review_at": NOW.isoformat(),
                "review_count": 0, "last_reviewed": None}
        self.row_id = _insert(
            self.conn, "lessons", "title text here", "some body content",
            confidence=0.7, metadata=meta
        )

    def _item(self) -> dict:
        row = _fetch(self.conn, "lessons", self.row_id)
        row["tbl"] = "lessons"
        return row

    def test_six_advances_reach_max(self):
        t = NOW
        for _ in range(6):
            _advance_box(self.conn, self._item(), t)
            self.conn.commit()
        meta = json.loads(_fetch(self.conn, "lessons", self.row_id)["metadata"])
        self.assertEqual(meta["spaced_box"], MAX_BOX)

    def test_advance_then_regress_returns_to_original(self):
        t = NOW
        item = self._item()
        initial_box = json.loads(item["metadata"])["spaced_box"]  # 0
        _advance_box(self.conn, item, t)
        self.conn.commit()
        _regress_box(self.conn, self._item(), t)
        self.conn.commit()
        meta = json.loads(_fetch(self.conn, "lessons", self.row_id)["metadata"])
        self.assertEqual(meta["spaced_box"], initial_box)

    def test_repeated_regress_stays_at_0(self):
        t = NOW
        for _ in range(5):
            _regress_box(self.conn, self._item(), t)
            self.conn.commit()
        meta = json.loads(_fetch(self.conn, "lessons", self.row_id)["metadata"])
        self.assertEqual(meta["spaced_box"], 0)


if __name__ == "__main__":
    unittest.main(verbosity=2)
