# -*- coding: utf-8 -*-
"""
Les outils mis à disposition du modèle.

Quatre outils, pas plus : écrire, lire, lister, terminer. Aucun shell, aucun
accès réseau. Tout ce que le modèle écrit atterrit dans le dossier site/ du
projet courant, et nulle part ailleurs.

La liste OUTILS doit rester stable octet pour octet d'un appel à l'autre :
elle est en tête du préfixe mis en cache, et la moindre variation invaliderait
le cache pour toute la requête.
"""

import os
import re

import config

# Un composant de chemin acceptable : lettres, chiffres, point, tiret, souligné.
# Refuse au passage « .. », « . » et les suites de points qui créeraient des
# dossiers parasites dans l'arborescence livrée.
_COMPOSANT = re.compile(r"^(?!\.+$)[A-Za-z0-9._-]+$")


OUTILS = [
    {
        "name": "ecrire_fichier",
        "description": (
            "Écrit un fichier du site. Le chemin est relatif à la racine du site "
            "(exemples : index.html, assets/css/style.css, tools/build_elementor.py). "
            "Les dossiers manquants sont créés. Un fichier existant est remplacé : "
            "pour modifier un fichier, relis-le d'abord avec lire_fichier, puis "
            "réécris-le en entier."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "chemin": {
                    "type": "string",
                    "description": "Chemin relatif à la racine du site.",
                },
                "contenu": {
                    "type": "string",
                    "description": "Contenu complet du fichier.",
                },
            },
            "required": ["chemin", "contenu"],
            "additionalProperties": False,
        },
        "strict": True,
    },
    {
        "name": "lire_fichier",
        "description": (
            "Relit un fichier déjà écrit, pour garder la cohérence entre les pages "
            "ou avant de le modifier."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "chemin": {
                    "type": "string",
                    "description": "Chemin relatif à la racine du site.",
                },
            },
            "required": ["chemin"],
            "additionalProperties": False,
        },
        "strict": True,
    },
    {
        "name": "lister_fichiers",
        "description": "Liste les fichiers du site déjà écrits, avec leur taille.",
        "input_schema": {
            "type": "object",
            "properties": {},
            "required": [],
            "additionalProperties": False,
        },
        "strict": True,
    },
    {
        "name": "terminer",
        "description": (
            "À appeler quand le travail demandé est terminé et que tous les "
            "fichiers sont écrits. Le résumé est affiché à l'opérateur."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "resume": {
                    "type": "string",
                    "description": (
                        "Ce qui a été produit, les fichiers provisoires à remplacer, "
                        "et les points à valider avec le client."
                    ),
                },
            },
            "required": ["resume"],
            "additionalProperties": False,
        },
        "strict": True,
    },
]


class RefusOutil(Exception):
    """Le modèle a demandé quelque chose que l'on refuse d'exécuter."""


def _resoudre(racine_site, chemin_relatif):
    """Ramène un chemin fourni par le modèle à un chemin réel sous racine_site.

    Le chemin vient du modèle : c'est une donnée non fiable. On refuse tout ce
    qui sort du dossier du projet, y compris par lien symbolique.
    """
    if not isinstance(chemin_relatif, str) or not chemin_relatif.strip():
        raise RefusOutil("Chemin vide.")

    chemin_relatif = chemin_relatif.strip().lstrip("/")
    if "\x00" in chemin_relatif or "\\" in chemin_relatif:
        raise RefusOutil("Chemin invalide.")

    for composant in chemin_relatif.split("/"):
        if not _COMPOSANT.match(composant):
            raise RefusOutil(
                "Composant de chemin invalide : %r. Un chemin s'écrit "
                "assets/css/style.css : lettres, chiffres, point, tiret et "
                "souligné uniquement, sans « .. »." % composant
            )

    racine_reelle = os.path.realpath(racine_site)
    vise = os.path.realpath(os.path.join(racine_reelle, chemin_relatif))

    if vise != racine_reelle and not vise.startswith(racine_reelle + os.sep):
        raise RefusOutil(
            "Chemin hors du dossier du site. Utilise un chemin relatif "
            "comme assets/css/style.css."
        )
    return vise


def _verifier_extension(chemin_reel):
    extension = os.path.splitext(chemin_reel)[1].lower()
    nom = os.path.basename(chemin_reel)
    if nom == ".htaccess":
        return
    if extension not in config.EXTENSIONS_AUTORISEES:
        raise RefusOutil(
            "Extension %s non autorisée. Les fichiers binaires (images, polices) "
            "sont fournis par l'opérateur, pas écrits par toi : contente-toi de "
            "les référencer dans le HTML." % (extension or "(aucune)")
        )


def ecrire_fichier(racine_site, chemin, contenu):
    chemin_reel = _resoudre(racine_site, chemin)
    _verifier_extension(chemin_reel)

    if not isinstance(contenu, str):
        raise RefusOutil("Le contenu doit être du texte.")

    octets = contenu.encode("utf-8")
    if len(octets) > config.MAX_OCTETS_FICHIER:
        raise RefusOutil(
            "Fichier trop volumineux (%d octets, maximum %d). Découpe-le."
            % (len(octets), config.MAX_OCTETS_FICHIER)
        )

    os.makedirs(os.path.dirname(chemin_reel) or racine_site, exist_ok=True)
    with open(chemin_reel, "w", encoding="utf-8", newline="\n") as f:
        f.write(contenu)

    lignes = contenu.count("\n") + (0 if contenu.endswith("\n") else 1)
    return "Écrit : %s (%d lignes, %d octets)." % (
        os.path.relpath(chemin_reel, os.path.realpath(racine_site)),
        lignes,
        len(octets),
    )


def lire_fichier(racine_site, chemin):
    chemin_reel = _resoudre(racine_site, chemin)
    if not os.path.isfile(chemin_reel):
        raise RefusOutil(
            "Ce fichier n'existe pas encore. Utilise lister_fichiers pour voir "
            "ce qui a été écrit."
        )
    with open(chemin_reel, encoding="utf-8", errors="replace") as f:
        return f.read()


def lister_fichiers(racine_site):
    racine_reelle = os.path.realpath(racine_site)
    trouves = []
    for dossier, _, fichiers in os.walk(racine_reelle):
        for nom in fichiers:
            complet = os.path.join(dossier, nom)
            trouves.append(
                (os.path.relpath(complet, racine_reelle), os.path.getsize(complet))
            )
    if not trouves:
        return "Aucun fichier écrit pour l'instant."
    trouves.sort()
    return "\n".join("%s — %d octets" % (c, t) for c, t in trouves)


def executer(racine_site, nom, entree):
    """Exécute un outil. Renvoie (texte_resultat, est_une_erreur).

    Une erreur n'interrompt jamais la génération : elle est renvoyée au modèle
    comme résultat d'outil en erreur, à charge pour lui de se corriger.
    """
    entree = entree or {}
    try:
        if nom == "ecrire_fichier":
            return ecrire_fichier(racine_site, entree.get("chemin"), entree.get("contenu")), False
        if nom == "lire_fichier":
            return lire_fichier(racine_site, entree.get("chemin")), False
        if nom == "lister_fichiers":
            return lister_fichiers(racine_site), False
        if nom == "terminer":
            return "Travail clos.", False
        return "Outil inconnu : %s" % nom, True
    except RefusOutil as e:
        return str(e), True
    except OSError as e:
        return "Erreur disque : %s" % e, True
