"""Réception du fichier d'import : spool disque, sha256 au vol, plafond de taille (§6, n°8).

Cet endpoint est le **seul** de l'api appelé directement par le navigateur (décision n°8). Il
n'écrit rien en base : il pose un fichier sur disque, sous un nom qui est son propre condensé,
et rend ce condensé. Tout le reste du wizard (aperçu, création de l'import) repart de là.

Trois propriétés :

1. **Rien n'est chargé en mémoire.** Starlette déverse déjà la partie « fichier » d'un
   multipart dans un `SpooledTemporaryFile` qui bascule sur disque au-delà de 1 Mo ; la recopie
   vers `IMPORT_FILES_DIR` se fait ensuite par blocs, le condensé se calculant au passage.
2. **Le plafond n'est pas gardé ici.** Il ne *peut pas* l'être : FastAPI lit le corps avant
   d'appeler l'endpoint, donc tout contrôle écrit dans ce module arriverait après la réception.
   C'est `app.core.upload_guard.UploadSizeGuard`, un middleware ASGI posé sur ce seul chemin,
   qui refuse en amont — `Content-Length` déclaré trop gros, puis cumul des octets reçus. Le
   contrôle ci-dessous, dans la boucle de recopie, n'est plus qu'une **ceinture** : il protège
   `IMPORT_FILES_DIR` si quelqu'un appelait `_spool` sans passer par la route (un test, un
   futur import API), et rend le module correct isolément.
3. **Le chemin est construit, jamais concaténé.** `pipeline.import_file_path` refuse tout
   `org_id` ou sha256 qui ne soit pas alphanumérique (§3.12) ; le nom de fichier d'origine
   n'entre **jamais** dans le chemin — il n'est qu'une étiquette rendue à l'utilisateur.
"""

import asyncio
import hashlib
import os
import tempfile
from pathlib import Path
from typing import BinaryIO

from fastapi import UploadFile

from app.core.errors import AppError
from app.keywords.pipeline import import_file_path

# Plafond de taille explicite (§6). Un export Semrush Positions de 50 000 lignes pèse quelques
# mégaoctets ; la fixture de 500 000 lignes du benchmark en pèse ~60. 256 Mio laisse donc une
# marge confortable au CSV générique le plus gros tout en bornant le disque et le temps de
# parse. Constante applicative, pas un réglage d'environnement : la spec la veut « côté FastAPI ».
MAX_UPLOAD_BYTES = 256 * 1024 * 1024
# Blocs de recopie : assez gros pour ne pas multiplier les appels système, assez petits pour
# que la mémoire du process ne dépende pas de la taille du fichier.
CHUNK = 1 << 20


def _too_large() -> AppError:
    return AppError(
        413,
        "Payload Too Large",
        f"Fichier trop volumineux : le plafond est de {MAX_UPLOAD_BYTES // (1024 * 1024)} Mio",
    )


def _spool(source: BinaryIO, org_id: str) -> tuple[str, int]:
    """Recopie **synchrone** de `source` vers `<IMPORT_FILES_DIR>/<org_id>/<sha256>.csv`.

    Le fichier est d'abord écrit sous un nom temporaire **dans le répertoire de destination**
    — son nom définitif n'est connu qu'une fois le dernier octet lu —, puis renommé. Le
    renommage étant atomique sur un même système de fichiers, aucun lecteur ne peut voir un
    `<sha256>.csv` partiel : le nom promet le contenu, il ne doit jamais mentir.
    """
    #  Chemin final calculé tout de suite avec un condensé factice : c'est lui qui valide
    #  `org_id` (§3.12) avant qu'un seul octet ne soit écrit.
    directory = import_file_path(org_id, "0" * 64).parent
    directory.mkdir(parents=True, exist_ok=True)

    digest = hashlib.sha256()
    size = 0
    handle = tempfile.NamedTemporaryFile(
        mode="wb", dir=directory, prefix=".upload-", suffix=".part", delete=False
    )
    temporary = Path(handle.name)
    try:
        source.seek(0)
        with handle:
            while chunk := source.read(CHUNK):
                size += len(chunk)
                if size > MAX_UPLOAD_BYTES:
                    raise _too_large()
                digest.update(chunk)
                handle.write(chunk)
        sha256 = digest.hexdigest()
        #  `os.replace` écrase un fichier de même condensé : même contenu, donc rejeu
        #  inoffensif — et le re-téléversement après une purge repose simplement le fichier.
        os.replace(temporary, import_file_path(org_id, sha256))
    except BaseException:
        temporary.unlink(missing_ok=True)
        raise
    return sha256, size


async def spool(upload: UploadFile, org_id: str) -> tuple[str, int]:
    """`(sha256, taille)` du fichier téléversé, écrit sur disque — **hors de la boucle**.

    Le multipart est déjà déversé par Starlette dans un `SpooledTemporaryFile` (bascule sur
    disque au-delà de 1 Mo) : la recopie lit donc un fichier local, ce qui est du travail
    purement bloquant. Un seul saut de thread pour toute la copie, plutôt qu'un par bloc.
    """
    return await asyncio.to_thread(_spool, upload.file, org_id)


def safe_filename(raw: str | None) -> str:
    """Étiquette d'affichage du fichier d'origine — **jamais** un composant de chemin.

    Le nom vient du navigateur : il peut contenir n'importe quoi, séparateurs compris. Il est
    réduit à son dernier segment et borné, uniquement pour être rendu dans la liste des imports.
    """
    name = (raw or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
    return name[:255] or "import.csv"
