"""Contrôles de sécurité des fichiers importés : type réel, cohérence d'extension, archives, PDF actifs.

Ne fait jamais confiance à l'extension, au nom ni au type MIME annoncé par le navigateur.
"""
from __future__ import annotations

import io
import re
import unicodedata
import zipfile
from dataclasses import dataclass, field

PNG_SIG = b"\x89PNG\r\n\x1a\n"
JPEG_SIG = b"\xff\xd8\xff"
OLE2_SIG = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
ODS_MIME = b"application/vnd.oasis.opendocument.spreadsheet"

FORMATS = {
    "png": ("image/png", {".png"}),
    "jpeg": ("image/jpeg", {".jpg", ".jpeg"}),
    "pdf": ("application/pdf", {".pdf"}),
    "xlsx": ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", {".xlsx"}),
    "ods": ("application/vnd.oasis.opendocument.spreadsheet", {".ods"}),
    "csv": ("text/csv", {".csv"}),
}
ACCEPTED_EXTENSIONS = sorted({e for _, exts in FORMATS.values() for e in exts})
LABELS = {"png": "image PNG", "jpeg": "image JPEG", "pdf": "PDF", "xlsx": "classeur Excel (XLSX)",
          "ods": "classeur LibreOffice (ODS)", "csv": "fichier CSV", "xls": "ancien classeur Excel (XLS)",
          "zip": "archive ZIP", "inconnu": "format inconnu"}

ZIP_MAX_ENTRIES = 2000
ZIP_MAX_TOTAL = 80 * 1024 * 1024
ZIP_MAX_ENTRY = 50 * 1024 * 1024
ZIP_MAX_RATIO = 150
FORBIDDEN_PARTS = (
    re.compile(r"(^|/)vbaProject\.bin$", re.I), re.compile(r"^xl/activeX/", re.I), re.compile(r"^xl/embeddings/", re.I),
    re.compile(r"^xl/macrosheets/", re.I), re.compile(r"^xl/dialogsheets/", re.I), re.compile(r"^Basic/", re.I),
    re.compile(r"^Scripts/", re.I), re.compile(r"^Object \d+", re.I), re.compile(r"\.(exe|dll|js|vbs|ps1|bat|cmd|sh)$", re.I),
)
PDF_FORBIDDEN = re.compile(rb"/(JavaScript|JS|Launch|EmbeddedFile|RichMedia|XFA|SubmitForm|GoToR)\b")
# Marqueurs longs et sensibles à la casse : un faux positif dans des données compressées est improbable.
POLYGLOT_MARKERS = (b"%PDF-1.", b"<?php", b"<script", b"<SCRIPT", b"<html", b"<HTML", b"<!DOCTYPE", b"PK\x03\x04\x14\x00")


class FileRejected(ValueError):
    pass


@dataclass
class Detected:
    format: str
    mime: str
    warnings: list[str] = field(default_factory=list)
    info: dict[str, object] = field(default_factory=dict)


def safe_display_name(name: str | None) -> str:
    """Nom affichable uniquement (jamais utilisé comme chemin) : sans répertoire ni caractère de contrôle."""
    name = (name or "fichier").replace("\\", "/").split("/")[-1]
    name = unicodedata.normalize("NFC", name)
    name = "".join(c for c in name if c.isprintable() and c not in '<>:"|?*\x00')
    name = re.sub(r"\s+", " ", name).strip(" .") or "fichier"
    return name[:150]


def extension(name: str) -> str:
    m = re.search(r"(\.[A-Za-z0-9]{1,6})$", name)
    return m.group(1).lower() if m else ""


def _inspect_zip(data: bytes) -> tuple[str, list[str], dict[str, object]]:
    warnings: list[str] = []
    try:
        zf = zipfile.ZipFile(io.BytesIO(data))
        infos = zf.infolist()
    except zipfile.BadZipFile:
        raise FileRejected("Archive bureautique corrompue ou illisible.") from None
    if len(infos) > ZIP_MAX_ENTRIES:
        raise FileRejected("Archive refusée : trop d'éléments internes.")
    total = 0
    names = []
    for i in infos:
        n = i.filename
        if n.startswith("/") or ".." in n.split("/") or "\\" in n or "\x00" in n or re.match(r"^[A-Za-z]:", n):
            raise FileRejected("Archive refusée : chemin interne suspect (traversée de répertoire).")
        if i.flag_bits & 0x1:
            raise FileRejected("Fichier chiffré ou protégé par mot de passe : impossible de l'analyser.")
        if i.file_size > ZIP_MAX_ENTRY:
            raise FileRejected("Archive refusée : élément interne trop volumineux (protection « zip bomb »).")
        if i.file_size > 1024 * 1024 and i.compress_size and i.file_size / i.compress_size > ZIP_MAX_RATIO:
            raise FileRejected("Archive refusée : taux de compression anormal (protection « zip bomb »).")
        total += i.file_size
        if total > ZIP_MAX_TOTAL:
            raise FileRejected("Archive refusée : contenu décompressé trop volumineux (protection « zip bomb »).")
        for pat in FORBIDDEN_PARTS:
            if pat.search(n):
                raise FileRejected("Fichier refusé : il contient des macros, des scripts ou des objets incorporés.")
        names.append(n)
    if "mimetype" in names and zf.read("mimetype").strip() == ODS_MIME:
        fmt = "ods"
    elif "[Content_Types].xml" in names and "xl/workbook.xml" in names:
        fmt = "xlsx"
        ct = zf.read("[Content_Types].xml")
        if b"macroEnabled" in ct or b"vbaProject" in ct:
            raise FileRejected("Classeur à macros refusé (.xlsm déguisé).")
    else:
        return "zip", warnings, {}
    for i in infos:
        if i.filename.lower().endswith((".xml", ".rels")):
            head = zf.read(i.filename)
            low = head.lower()
            if b"<!doctype" in low or b"<!entity" in low:
                raise FileRejected("Fichier refusé : déclaration XML interdite (protection XXE).")
    info: dict[str, object] = {"entries": len(infos), "uncompressed": total}
    if any(n.lower().startswith("xl/externallinks/") for n in names):
        warnings.append("Le classeur contient des liens vers d'autres fichiers : ils ne sont jamais suivis.")
        info["external_links"] = True
    if fmt == "ods":
        content = zf.read("content.xml") if "content.xml" in names else b""
        if b"xlink:href=\"http" in content or b"table:table-source" in content:
            warnings.append("Le classeur contient des liens externes : ils ne sont jamais suivis.")
            info["external_links"] = True
    return fmt, warnings, info


def sniff(data: bytes, filename: str) -> Detected:
    if not data:
        raise FileRejected("Fichier vide.")
    ext = extension(filename)
    if ext == ".xls" or data[:8] == OLE2_SIG:
        raise FileRejected("Le format .xls (Excel 97-2003) n'est pas accepté pour des raisons de sécurité : "
                           "enregistrez le fichier en .xlsx ou .ods puis réessayez.")
    warnings: list[str] = []
    info: dict[str, object] = {}
    if data.startswith(PNG_SIG):
        fmt = "png"
    elif data.startswith(JPEG_SIG):
        fmt = "jpeg"
    elif data.startswith(b"%PDF-"):
        fmt = "pdf"
    elif data.startswith(b"PK\x03\x04"):
        fmt, warnings, info = _inspect_zip(data)
    else:
        if b"\x00" in data[:65536]:
            fmt = "inconnu"
        else:
            sample = data[:65536]
            try:
                text = sample.decode("utf-8")
            except UnicodeDecodeError:
                text = sample.decode("cp1252", errors="replace")
            printable = sum(1 for c in text if c.isprintable() or c in "\r\n\t")
            fmt = "csv" if text and printable / len(text) > 0.97 else "inconnu"
    if fmt in {"zip", "inconnu"}:
        raise FileRejected(f"Format non accepté ({LABELS[fmt]}). Formats acceptés : "
                           + ", ".join(ACCEPTED_EXTENSIONS) + ".")
    mime, exts = FORMATS[fmt]
    if ext not in exts:
        raise FileRejected(f"L'extension « {ext or 'aucune'} » ne correspond pas au contenu réel du fichier "
                           f"({LABELS[fmt]}). Fichier refusé.")
    if fmt in {"png", "jpeg"}:
        if any(m in data for m in POLYGLOT_MARKERS):
            raise FileRejected("Image refusée : elle contient d'autres types de contenu (fichier polyglotte suspect).")
    if fmt == "pdf":
        if b"/Encrypt" in data:
            raise FileRejected("PDF chiffré ou protégé : impossible de l'analyser.")
        m = PDF_FORBIDDEN.search(data)
        if m:
            raise FileRejected(f"PDF refusé : il contient du contenu actif ({m.group(1).decode()}).")
        if data.count(b"%PDF-") > 1:
            warnings.append("Le PDF contient plusieurs en-têtes (mises à jour successives) : vérifiez son origine.")
    if fmt == "csv" and data.lstrip()[:1] in {b"<", b"{"}:
        raise FileRejected("Le contenu ne ressemble pas à un tableau CSV.")
    return Detected(fmt, mime, warnings, info)
