"""Tests for ``mgf.sqlalchemy._pagination`` — the pure cursor-pagination
helper (federation PAPER-94).

``paginate_cursor`` is pure (no session, no I/O), so these tests need
no DB fixture. They pin the ``(items, truncated, next_cursor)`` contract
against the boundary cases that the "fetch limit + 1" idiom is prone to
drift on.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import UTC, datetime

import pytest

from mgf.sqlalchemy import paginate_cursor


@dataclass(frozen=True)
class _Row:
    """Stand-in for a fetched ORM row with a cursor-able column."""

    id: int
    created_at: datetime


def _rows(n: int) -> list[_Row]:
    base = datetime(2026, 1, 1, tzinfo=UTC)
    # Descending created_at to mirror the common ORDER BY <col> DESC shape.
    return [_Row(id=i, created_at=base.replace(second=n - i)) for i in range(n)]


class TestTruncationDetection:
    def test_over_fetch_by_one_is_truncated(self) -> None:
        """len(rows) == limit + 1 ⇒ truncated, surplus row dropped."""
        rows = _rows(6)
        items, truncated, next_cursor = paginate_cursor(
            rows, limit=5, cursor_of=lambda r: r.created_at
        )
        assert len(items) == 5
        assert items == rows[:5]
        assert truncated is True
        # next_cursor is the LAST INCLUDED row's cursor (index 4),
        # never the dropped row (index 5).
        assert next_cursor == rows[4].created_at

    def test_exactly_limit_is_not_truncated(self) -> None:
        """len(rows) == limit ⇒ not truncated, next_cursor is None.

        Pins the documented caveat: the helper can only observe
        truncation from the extra row. A caller that fetched exactly
        `limit` (no over-fetch) gets truncated=False even if a next
        page exists in the DB — that's the caller's contract to honour
        by issuing LIMIT limit + 1.
        """
        rows = _rows(5)
        items, truncated, next_cursor = paginate_cursor(
            rows, limit=5, cursor_of=lambda r: r.created_at
        )
        assert items == rows
        assert truncated is False
        assert next_cursor is None

    def test_under_limit_is_not_truncated(self) -> None:
        rows = _rows(2)
        items, truncated, next_cursor = paginate_cursor(
            rows, limit=5, cursor_of=lambda r: r.created_at
        )
        assert items == rows
        assert truncated is False
        assert next_cursor is None

    def test_empty_rows(self) -> None:
        items, truncated, next_cursor = paginate_cursor(
            [], limit=5, cursor_of=lambda r: r.created_at
        )
        assert items == []
        assert truncated is False
        assert next_cursor is None

    def test_surplus_beyond_one_extra_still_resumes_correctly(self) -> None:
        """len(rows) > limit + 1 ⇒ truncated, next_cursor at index
        limit-1 so resumption stays correct even when the caller
        over-fetched more than one extra row."""
        rows = _rows(10)
        items, truncated, next_cursor = paginate_cursor(
            rows, limit=5, cursor_of=lambda r: r.created_at
        )
        assert len(items) == 5
        assert truncated is True
        assert next_cursor == rows[4].created_at


class TestCursorExtraction:
    def test_cursor_of_only_called_on_returned_rows(self) -> None:
        """The dropped row's cursor must never be computed — a cursor
        derived from a row the client never sees would leak it."""
        seen: list[int] = []

        def cursor_of(r: _Row) -> int:
            seen.append(r.id)
            return r.id

        rows = _rows(6)
        items, _, next_cursor = paginate_cursor(rows, limit=5, cursor_of=cursor_of)
        # cursor_of is called exactly once (on the last included row),
        # and only on a row that is in `items`.
        assert next_cursor == items[-1].id
        assert seen == [items[-1].id]
        assert rows[5].id not in seen

    def test_composite_cursor(self) -> None:
        """cursor_of may return a tuple for tie-break cursors."""
        rows = _rows(6)
        _, truncated, next_cursor = paginate_cursor(
            rows, limit=5, cursor_of=lambda r: (r.created_at, r.id)
        )
        assert truncated is True
        assert next_cursor == (rows[4].created_at, rows[4].id)

    def test_returns_a_list_not_the_input_sequence(self) -> None:
        """The non-truncated path returns a fresh list, never aliases
        the caller's input — mutating the result must not mutate input."""
        rows = _rows(3)
        items, _, _ = paginate_cursor(rows, limit=5, cursor_of=lambda r: r.id)
        assert items == rows
        assert items is not rows


class TestEmptyPageAfterCursor:
    """Resumption edge case: a non-null cursor whose page came back EMPTY.

    ``paginate_cursor`` is pure — it never sees the cursor itself; the
    caller applies ``WHERE col < :cursor`` in SQL and passes the fetched
    rows here. When a client resumes with a non-null cursor that points
    PAST the last row (the previous page was the final one, or rows were
    deleted since), the query returns zero rows. The helper must then
    return a *terminal* result — empty items, ``truncated=False``, and a
    ``None`` next-cursor — so the client stops cleanly instead of looping
    on a bogus cursor. ``cursor_of`` must NOT be invoked (there is no row
    to derive a cursor from; a fabricated cursor would be a phantom-page
    bug).
    """

    def test_empty_page_after_non_null_cursor_is_terminal(self) -> None:
        """The resumption query returned nothing ⇒ empty + null next-cursor."""

        def cursor_of(_r: _Row) -> datetime:
            raise AssertionError(
                "cursor_of must not be called on an empty page — a "
                "fabricated next-cursor would loop the client forever."
            )

        # Mimic the resume call: caller already applied `WHERE created_at <
        # <previous_cursor>` in SQL and got nothing back.
        empty: list[_Row] = []
        items, truncated, next_cursor = paginate_cursor(empty, limit=5, cursor_of=cursor_of)
        assert items == []
        assert truncated is False
        # Terminal: a null next-cursor is the unambiguous "stop" signal.
        assert next_cursor is None

    def test_empty_page_after_cursor_is_consistent_with_no_cursor(self) -> None:
        """The empty-after-cursor result is identical to a first-page-empty
        result — the helper has no hidden cursor state that could diverge
        the two and emit a non-null terminal cursor on one path."""
        empty: list[_Row] = []
        first_page_empty = paginate_cursor(empty, limit=10, cursor_of=lambda r: r.id)
        resumed_empty = paginate_cursor(empty, limit=10, cursor_of=lambda r: r.id)
        assert first_page_empty == resumed_empty == ([], False, None)


class TestLimitValidation:
    @pytest.mark.parametrize("bad_limit", [0, -1, -100])
    def test_non_positive_limit_raises(self, bad_limit: int) -> None:
        with pytest.raises(ValueError, match="must be >= 1"):
            paginate_cursor(_rows(3), limit=bad_limit, cursor_of=lambda r: r.id)

    def test_limit_one(self) -> None:
        """Smallest valid page size: limit=1 with an over-fetch."""
        rows = _rows(2)
        items, truncated, next_cursor = paginate_cursor(rows, limit=1, cursor_of=lambda r: r.id)
        assert items == [rows[0]]
        assert truncated is True
        assert next_cursor == rows[0].id
