"""Plafond de taille au niveau **ASGI**, sur le seul chemin d'upload (spec Imports §6, n°8).

## Pourquoi ce middleware existe

FastAPI lit le corps de la requête **avant** d'appeler l'endpoint : `get_request_handler`
exécute `await request.form()` pour construire les paramètres, donc avant la résolution des
dépendances (`CurrentOrgDep` compris) et avant la première ligne du handler. Toute vérification
écrite *dans* l'endpoint arrive donc après que Starlette a intégralement reçu le multipart et
l'a déversé dans son `SpooledTemporaryFile`. Deux conséquences, l'une et l'autre inacceptables
sur le seul endpoint exposé au navigateur (décision n°8) :

- un `Content-Length` de 10 Go est reçu en entier avant le 413 — le contrôle applicatif protège
  la recopie vers `IMPORT_FILES_DIR`, pas la réception ;
- un client **sans jeton** obtient le même traitement : 10 Go ingérés, puis 401.

Un middleware ASGI, lui, s'interpose avant le routage et avant tout parsing. Il voit les
messages `receive` un par un, ce qui est le seul endroit où l'on puisse **arrêter** une requête
en cours de réception — `Transfer-Encoding: chunked` sans `Content-Length` compris.

## Ce qu'il fait, dans l'ordre

1. **Pré-filtre d'authentification.** Pas d'en-tête `Authorization` sur ce chemin → 401 tout de
   suite, sans que l'application ne lise un octet. Ce n'est **pas** l'authentification : le
   jeton reste vérifié par `CurrentOrgDep` comme partout ailleurs (signature, `iss`, `aud`,
   `exp`, organisation). C'est un refus des requêtes manifestement anonymes avant qu'elles ne
   coûtent un disque.
2. **`Content-Length` déclaré au-dessus du plafond** → 413, corps jamais transmis à
   l'application.
3. **Cumul des octets réellement reçus.** Le `receive` passé à l'application est enveloppé : au
   premier message qui fait franchir le plafond, il lève. La lecture du corps s'arrête là et la
   réponse 413 part — au pire un bloc de trop a été écrit dans le spool de Starlette, jamais le
   fichier entier. C'est ce contrôle-là qui fait foi : un `Content-Length` est déclaratif.

## Ce que ce middleware ne peut pas faire, et comment il s'en approche

**Il ne coupe pas le câble.** Refuser en ASGI garantit que *l'application* cesse de lire — donc
que le disque et la mémoire sont protégés dès le refus — mais pas que le client arrête
d'émettre. HTTP/1.1 veut une requête entièrement lue avant de recycler la connexion, si bien que
le serveur **draine** le corps restant après avoir répondu.

Mesuré sur un vrai uvicorn (macOS, corps `chunked`, plafond ramené à 1 Mio ; script du
scratchpad, contrefactuel joué dans les deux sens) :

| Réponse de refus | Sans `Connection: close` | Avec |
|---|---|---|
| 401 (jeton absent) | 65,6 Mo **tous acceptés** | `BrokenPipeError` à ~467 Ko |
| 413 (plafond franchi) | 65,6 Mo **tous acceptés** | `BrokenPipeError` à ~2,7 Mo |

(65,6 Mo = tout ce que la sonde a pu émettre ; rien n'indiquait une borne.) Les deux réponses de
refus portent donc **`Connection: close`** : le serveur ferme la socket au lieu de la recycler,
et l'émetteur meurt. Le reliquat accepté (quelques centaines de kilo-octets à quelques mégaoctets
selon les tampons de socket de la plateforme, pas selon la taille annoncée) est le prix de la
latence entre le refus et la fermeture — borné, là où l'absence d'en-tête ne l'était pas.

C'est la meilleure garantie disponible depuis ce niveau. L'arrêt franc *avant* Python reste le
rôle du `client_max_body_size` du reverse proxy, noté en checklist.

Le plafond est celui de la recopie (`app.imports.upload.MAX_UPLOAD_BYTES`) : **une** constante,
relue à chaque requête plutôt que figée à la construction — `create_app` ne s'exécute qu'une
fois par session de test, alors que les tests déplacent la constante.
"""

import logging

from fastapi.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send

from app.core.errors import problem

logger = logging.getLogger(__name__)

# Sur un refus, la socket est fermée au lieu d'être recyclée. Sans cela, HTTP/1.1 exige que la
# requête soit entièrement lue avant réutilisation de la connexion : le serveur draine tout le
# corps restant **après** avoir répondu, sans borne observable (65,6 Mo tous acceptés à la
# mesure). Voir le tableau de l'en-tête du module.
_CLOSE = {"Connection": "close"}


class _BodyTooLarge(Exception):
    """Levée depuis le `receive` enveloppé : remonte à travers le parseur multipart.

    Aucun gestionnaire d'exception de l'application ne la connaît (elle n'est pas enregistrée),
    elle traverse donc `ExceptionMiddleware` intacte et revient ici, où la réponse est écrite.
    """


class UploadSizeGuard:
    """Plafonne le corps d'un chemin exact, avant routage, parsing et authentification."""

    def __init__(self, app: ASGIApp, *, path: str) -> None:
        self._app = app
        self._path = path

    @staticmethod
    def _max_bytes() -> int:
        from app.imports import upload  # importé ici : `app.imports` dépend de `app.core`

        return upload.MAX_UPLOAD_BYTES

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if (
            scope["type"] != "http"
            or scope.get("path") != self._path
            or scope.get("method") != "POST"
        ):
            await self._app(scope, receive, send)
            return

        headers = {key.lower(): value for key, value in scope.get("headers") or ()}
        max_bytes = self._max_bytes()

        if b"authorization" not in headers:
            #  Pré-filtre, pas une authentification : voir l'en-tête du module.
            response = problem(
                401,
                "Unauthorized",
                "Jeton manquant",
                {"WWW-Authenticate": "Bearer", **_CLOSE},
            )
            await response(scope, receive, send)
            return

        if _declared_too_large(headers.get(b"content-length"), max_bytes):
            await _refuse(max_bytes)(scope, receive, send)
            return

        received = 0
        exceeded = False
        answered = False
        forwarded = False

        async def guarded_receive() -> Message:
            nonlocal received, exceeded
            message = await receive()
            if message["type"] == "http.request":
                received += len(message.get("body", b""))
                if received > max_bytes:
                    exceeded = True
                    #  Lever arrête la lecture du corps tout de suite. La réponse, elle, est
                    #  écrite par `guarded_send` : **on ne peut pas compter sur la remontée de
                    #  cette exception**, FastAPI enveloppe `await request.form()` dans un
                    #  `try/except Exception` et rendrait un 400 « error parsing the body ».
                    raise _BodyTooLarge
            return message

        async def guarded_send(message: Message) -> None:
            nonlocal answered, forwarded
            if not exceeded:
                if message["type"] == "http.response.start":
                    forwarded = True
                await send(message)
                return
            if forwarded:
                #  L'application avait déjà commencé à répondre avant le dépassement : sa
                #  réponse est en vol, on ne peut plus la remplacer — mais **il faut la laisser
                #  se terminer**, sinon le client attend un corps qui n'arrive jamais.
                #  Inatteignable sur le chemin d'upload (le multipart est lu avant toute
                #  réponse) ; le garde étant générique et paramétré par `path`, la branche est
                #  écrite pour le prochain montage.
                await send(message)
                return
            #  Le plafond a sauté : quoi que l'application ait décidé de répondre (400 de
            #  FastAPI, 500, peu importe), c'est le 413 qui dit la vérité au client.
            if answered:
                return
            answered = True
            await _refuse(max_bytes)(scope, receive, send)

        try:
            await self._app(scope, guarded_receive, guarded_send)
        except _BodyTooLarge:
            #  Chemin des applications qui laissent remonter l'erreur de lecture.
            pass
        if exceeded:
            logger.warning("upload refusé : plus de %d octets reçus sur %s", max_bytes, self._path)
            if not answered and not forwarded:
                await _refuse(max_bytes)(scope, receive, send)


def _declared_too_large(declared: bytes | None, max_bytes: int) -> bool:
    if declared is None:
        return False
    try:
        return int(declared) > max_bytes
    except ValueError:
        #  `Content-Length` illisible : on ne tranche pas ici, le cumul réel s'en chargera.
        return False


def _refuse(max_bytes: int) -> JSONResponse:
    return problem(
        413,
        "Payload Too Large",
        f"Fichier trop volumineux : le plafond est de {max_bytes // (1024 * 1024)} Mio",
        _CLOSE,
    )
