using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;

namespace MaBibli.Api.Services.Couvertures;

/// <summary>Résultat d'une demande de couverture : les octets, ou rien.</summary>
public sealed record ResultatCouverture(byte[]? Octets, string? TypeContenu)
{
    public static readonly ResultatCouverture Refusee = new(null, null);

    public bool Trouvee => Octets is not null && TypeContenu is not null;
}

public interface IServiceCouvertures
{
    Task<ResultatCouverture> ObtenirAsync(string? url, CancellationToken ct);
}

/// <summary>
/// Relaie une image de couverture <b>en même origine</b>, pour qu'elle puisse être mise en cache
/// hors-ligne quel que soit son hébergeur.
/// </summary>
/// <remarks>
/// <para>
/// <b>Pourquoi ce service existe.</b> Le cache hors-ligne des couvertures (lot A5) lit les octets
/// par <c>fetch()</c>, ce qui exige un en-tête <c>Access-Control-Allow-Origin</c>. OpenLibrary
/// l'envoie, mais le formulaire livre accepte <b>n'importe quelle URL</b> de couverture : ces
/// images s'affichaient bien (une balise <c>&lt;img&gt;</c> n'a que faire du CORS) sans jamais
/// pouvoir être mises en cache. Relayées par notre propre serveur, elles deviennent de même
/// origine, et la question du CORS disparaît.
/// </para>
/// <para>
/// ⚠️ <b>Un proxy est une surface d'attaque (SSRF), et il est borné par DEUX verrous
/// indépendants</b> — les deux sont nécessaires, aucun ne suffit :
/// </para>
/// <list type="number">
/// <item>
/// <b>L'URL doit déjà exister en base</b> comme couverture d'un livre, d'une envie ou d'un
/// numéro de revue. Le proxy
/// ne va donc jamais chercher une URL choisie au moment de l'appel : seulement une valeur que
/// l'application détient déjà.
/// </item>
/// <item>
/// <b>La connexion n'est ouverte que vers une adresse publiquement routable</b>
/// (<see cref="GardeAdresses"/>). C'est ce second verrou qui tient si quelqu'un enregistre
/// d'abord une fiche portant une URL interne : la valeur serait en base, la connexion serait
/// refusée quand même.
/// </item>
/// </list>
/// <para>
/// ⚠️ <b>Tout refus est indistinct</b> côté appelant (voir l'endpoint) : distinguer « pas en
/// base » de « injoignable » ferait du proxy un oracle qui renseigne sur le réseau du serveur.
/// </para>
/// </remarks>
public sealed class ServiceCouvertures(HttpClient http, MaBibliDbContext db) : IServiceCouvertures
{
    /// <summary>
    /// Plafond de lecture. Une couverture de livre pèse quelques dizaines de kilo-octets ; ce
    /// plafond n'est pas là pour trier les images mais pour qu'une URL pointant sur un très gros
    /// fichier ne puisse pas remplir la mémoire du serveur.
    /// </summary>
    public const int TailleMaximale = 5 * 1024 * 1024;

    public async Task<ResultatCouverture> ObtenirAsync(string? url, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(url)
            || !Uri.TryCreate(url, UriKind.Absolute, out var uri)
            || uri.Scheme != Uri.UriSchemeHttps)
        {
            // https uniquement : l'application est servie en https par YunoHost, une image en
            // clair y serait de toute façon bloquée par le navigateur (contenu mixte).
            return ResultatCouverture.Refusee;
        }

        if (!await EstConnueAsync(url, ct))
        {
            return ResultatCouverture.Refusee;
        }

        try
        {
            using var reponse = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct);

            // Les redirections sont suivies (3 au plus, voir Program.cs) : OpenLibrary répond
            // 302 sur ses couvertures. Chaque saut repasse par le garde d'adresses, qui agit à
            // la connexion — c'est lui qui borne, pas l'interdiction de rediriger.
            if (!reponse.IsSuccessStatusCode)
            {
                return ResultatCouverture.Refusee;
            }

            var type = reponse.Content.Headers.ContentType?.MediaType;
            if (type is null || !type.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
            {
                return ResultatCouverture.Refusee;
            }

            if (reponse.Content.Headers.ContentLength > TailleMaximale)
            {
                return ResultatCouverture.Refusee;
            }

            var octets = await LireBorneAsync(reponse.Content, ct);

            return octets is null ? ResultatCouverture.Refusee : new ResultatCouverture(octets, type);
        }
        catch (Exception)
        {
            // Hôte injoignable, adresse refusée par le garde, délai dépassé : une couverture non
            // relayée reste un agrément perdu, jamais une erreur à propager.
            return ResultatCouverture.Refusee;
        }
    }

    /// <summary>
    /// L'URL est-elle déjà enregistrée comme couverture ? Comparaison <b>exacte</b> : c'est la
    /// valeur stockée qui autorise, pas une ressemblance.
    /// </summary>
    /// <remarks>
    /// ⚠️ <b>Les numéros de revue en font partie</b> depuis qu'ils portent une couverture. Les
    /// oublier ici aurait produit exactement le défaut que ce service existe pour corriger :
    /// une image qui s'affiche en ligne et jamais hors-ligne, l'appel de mise en cache étant
    /// refusé par le relais.
    /// </remarks>
    private async Task<bool> EstConnueAsync(string url, CancellationToken ct) =>
        await db.Livres.AnyAsync(l => l.CoverUrl == url, ct)
        || await db.LivresSouhaites.AnyAsync(s => s.CoverUrl == url, ct)
        || await db.NumerosRevue.AnyAsync(n => n.CoverUrl == url, ct);

    /// <summary>
    /// Lit au plus <see cref="TailleMaximale"/> octets, et rend <c>null</c> si le corps dépasse.
    /// </summary>
    /// <remarks>
    /// ⚠️ Le <c>Content-Length</c> vérifié plus haut ne suffit pas : il est <b>absent</b> d'une
    /// réponse en <i>chunked</i>, et rien n'oblige un serveur distant à dire la vérité.
    /// </remarks>
    private static async Task<byte[]?> LireBorneAsync(HttpContent contenu, CancellationToken ct)
    {
        await using var flux = await contenu.ReadAsStreamAsync(ct);
        using var tampon = new MemoryStream();

        var morceau = new byte[81920];
        int lus;

        while ((lus = await flux.ReadAsync(morceau, ct)) > 0)
        {
            if (tampon.Length + lus > TailleMaximale)
            {
                return null;
            }

            tampon.Write(morceau, 0, lus);
        }

        return tampon.ToArray();
    }
}
