"""Curseur de pagination — un identifiant UUIDv7 encodé en base64url (spec Imports §6).

Extrait du module Sites, qui l'a inauguré, pour que **tous** les listings de l'api parlent le
même dialecte : `next_cursor` est le dernier `id` rendu, et la requête suivante reprend à
`id < cursor` (ordre `id DESC`, l'UUIDv7 étant temporellement croissant). Aucun offset, donc
aucun trou ni doublon quand des lignes s'insèrent entre deux pages.

Le curseur est **opaque** : rien n'y est signé ni chiffré — il ne porte qu'un identifiant que
l'appelant vient de recevoir —, mais il n'a pas à ressembler à une clé primaire pour autant.
Un curseur illisible est une entrée utilisateur invalide (422), jamais un 500.
"""

import base64
import binascii
import uuid

from app.core.errors import AppError


def encode_cursor(row_id: uuid.UUID) -> str:
    return base64.urlsafe_b64encode(row_id.bytes).decode("ascii").rstrip("=")


def decode_cursor(cursor: str) -> uuid.UUID:
    padded = cursor + "=" * (-len(cursor) % 4)
    try:
        raw = base64.urlsafe_b64decode(padded.encode("ascii"))
        return uuid.UUID(bytes=raw)
    except (ValueError, binascii.Error, UnicodeEncodeError) as exc:
        raise AppError(422, "Validation error", "Curseur illisible") from exc


def _decode(cursor: str) -> bytes:
    padded = cursor + "=" * (-len(cursor) % 4)
    try:
        return base64.urlsafe_b64decode(padded.encode("ascii"))
    except (binascii.Error, UnicodeEncodeError) as exc:
        raise AppError(422, "Validation error", "Curseur illisible") from exc


# --- second dialecte : le rang, pour les listings **classés par pertinence** ------------------
#
# Le keyset `id < cursor` suppose que l'ordre est celui de la clé. Une recherche plein texte
# est classée par pertinence (`ts_rank`), et un score flottant ne fait pas une clé de reprise :
# il n'est ni unique, ni stable entre deux versions du corpus. Plutôt qu'un curseur composite
# `(rang, score, id)` — trois colonnes, comparaison de flottants, et une reprise qui saute des
# lignes dès qu'une observation change le score —, la recherche pagine par **rang**.
#
# Le rang est borné (`SEARCH_MAX_ROWS`) : un `OFFSET` profond relit tout le préfixe, et
# personne ne feuillette la 40ᵉ page d'une recherche — on affine la requête. La borne est donc
# une décision de produit autant qu'une protection du plan.
#
# Le préfixe `o` sépare les deux dialectes : un curseur de keyset (16 octets d'UUID) présenté à
# la recherche est refusé (422) au lieu d'être lu comme un rang absurde.
_OFFSET_PREFIX = b"o"

# **La borne vit ici, pas chez l'appelant** (review F2). Un curseur est une entrée utilisateur
# comme une autre : il se forge. `o` suivi de vingt-cinq chiffres décodait en un entier Python
# parfaitement valide, que le driver refusait ensuite de lier à un `bigint` — une `DataError`
# remontée en **500**. Or rien n'est cassé côté serveur : c'est la requête qui est invalide.
# Valider au décodage, à l'endroit exact où la chaîne redevient un nombre, est la seule place
# où l'oubli est impossible ; le laisser à l'appelant, c'est le refaire à chaque listing classé.
SEARCH_MAX_ROWS = 1000


def encode_offset(offset: int) -> str:
    return base64.urlsafe_b64encode(_OFFSET_PREFIX + str(offset).encode()).decode().rstrip("=")


def decode_offset(cursor: str) -> int:
    raw = _decode(cursor)
    #  `isdigit()` avant `int()` : il n'y a ni signe ni exposant à accepter, donc pas d'entier
    #  négatif ni de notation exotique à faire refuser plus loin.
    if not raw.startswith(_OFFSET_PREFIX) or not raw[1:].isdigit():
        raise AppError(422, "Validation error", "Curseur illisible")
    offset = int(raw[1:])
    if offset > SEARCH_MAX_ROWS:
        raise AppError(
            422,
            "Validation error",
            f"Curseur hors limite : une recherche ne pagine que ses {SEARCH_MAX_ROWS} "
            "premiers résultats",
        )
    return offset
