"""Pure cursor-pagination helper for the "fetch limit + 1" idiom.

Every paginated list endpoint over a SQL store repeats the same
shape: fetch ``limit + 1`` rows to detect whether a next page exists
*without* a separate ``COUNT(*)``, slice back to ``limit``, and derive
the ``next_cursor`` from the last included row. The pattern is small
(~5 lines) but it is repeated across routes and is easy to drift on —
a single endpoint forgetting the ``rows[:limit]`` slice silently
returns one row too many and leaks the "a row exists at this boundary"
bit to the client.

:func:`paginate_cursor` centralises the slice so the footgun lives in
one tested place. It is a pure function — no session, no I/O, no
SQLAlchemy import — so it composes with any query shape (the caller
still builds its own statement with whatever ``ORDER BY`` / ``LATERAL``
joins it needs; this helper only owns the tuple shape).

Pairs with :func:`mgf.sqlalchemy.tenant_session` — the RLS-scoping
context manager that wires the read — to cover the two patterns a
Postgres-RLS multi-tenant consumer copy-pastes across every read
route (federation PAPER-94).
"""

from __future__ import annotations

from typing import TYPE_CHECKING, TypeVar

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence

# T — the row type (an ORM entity, a Row, a dict, whatever the caller
# fetched). C — the cursor value type extracted from a row (a datetime,
# an int, a tuple for composite cursors, …). Kept generic so the helper
# never hard-codes a column name or a cursor type.
T = TypeVar("T")
C = TypeVar("C")


def paginate_cursor(
    rows: Sequence[T],
    *,
    limit: int,
    cursor_of: Callable[[T], C],
) -> tuple[list[T], bool, C | None]:
    """Slice ``rows`` (fetched with ``LIMIT = limit + 1``) to one page.

    Given the over-fetched rows, return ``(items, truncated, next_cursor)``:

      - ``items`` — the rows the caller returns to the client (``≤ limit``).
      - ``truncated`` — ``True`` when at least one row was dropped, i.e.
        more rows are available beyond this page.
      - ``next_cursor`` — when ``truncated``, the cursor value of the
        **last included** row, produced by ``cursor_of``. The caller's
        next request passes this back as ``cursor=`` and resumes with
        ``WHERE <col> < :cursor`` (for ``ORDER BY <col> DESC``)
        immediately after this page. ``None`` when not truncated.

    The "fetch ``limit + 1``" idiom avoids a separate ``COUNT(*)`` query —
    cheaper at the database and atomic with the page read. The catch is
    drift: forgetting the ``rows[:limit]`` slice leaks the
    ``(limit + 1)``-th row. Centralising the slice removes that footgun.

    ``cursor_of`` is the cursor-value extractor, kept as a callable so
    each caller defines its own cursor semantics without this helper
    hard-coding a column — e.g. ``lambda r: r.created_at`` for a
    created-at cursor, ``lambda r: r.id`` for an id cursor, or
    ``lambda r: (r.created_at, r.id)`` for a composite tie-break cursor.
    It is only ever called on a row that is being **returned**, so a
    cursor derived from a dropped row can never leak.

    ``limit`` MUST be ``>= 1``. A ``limit <= 0`` raises ``ValueError`` —
    a non-positive page size is a caller bug (an empty page is expressed
    by an empty ``rows``, not by ``limit == 0``), and silently returning
    everything-or-nothing would mask it.

    Note on over-fetch: the caller is responsible for actually issuing
    ``LIMIT limit + 1`` in SQL. If the caller fetches exactly ``limit``
    rows, this helper reports ``truncated=False`` even when a next page
    exists — it can only observe truncation from the extra row. If the
    caller fetches *more* than ``limit + 1`` rows, the surplus is
    dropped and ``truncated`` is still ``True`` (the contract is
    "``len(rows) > limit`` ⇒ truncated"), but ``next_cursor`` points at
    the ``limit``-th row, so resumption stays correct.

    Use as::

        stmt = (
            select(Widget)
            .where(Widget.created_at < cursor)
            .order_by(Widget.created_at.desc())
            .limit(page_size + 1)
        )
        fetched = (await scoped.scalars(stmt)).all()
        items, truncated, next_cursor = paginate_cursor(
            fetched, limit=page_size, cursor_of=lambda w: w.created_at
        )
    """
    if limit <= 0:
        raise ValueError(
            f"paginate_cursor: `limit` must be >= 1, got {limit!r}. "
            f"An empty page is expressed by an empty `rows`, not by a "
            f"non-positive limit."
        )

    if len(rows) > limit:
        items = list(rows[:limit])
        # `items` is non-empty here: len(rows) > limit >= 1.
        next_cursor = cursor_of(items[-1])
        return items, True, next_cursor

    return list(rows), False, None


__all__ = ["paginate_cursor"]
