using MaBibli.Shared.Dtos;

namespace MaBibli.Api.Services.Isbn;

public interface IBnfClient
{
    /// <summary>
    /// Interroge le SRU de la BnF pour une forme d'ISBN donnée.
    /// Renvoie une liste vide si la BnF ne connaît pas l'ISBN <b>ou</b> si elle est injoignable ;
    /// dans ce second cas <paramref name="avertissement"/> explique pourquoi.
    /// </summary>
    Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherAsync(
        string isbn, string? urlCouverture, CancellationToken ct = default);

    /// <summary>
    /// Interroge le SRU <b>par auteur</b> pour bâtir une bibliographie.
    /// </summary>
    /// <remarks>
    /// Renvoie un résultat vide et un <c>Avertissement</c> si la BnF est injoignable : comme
    /// pour le lookup ISBN, une source indisponible ne doit pas faire échouer l'écran.
    /// </remarks>
    Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync(
        string auteur, CancellationToken ct = default);

    /// <summary>
    /// Interroge le SRU <b>par ISSN</b> pour nommer un périodique.
    /// </summary>
    /// <remarks>
    /// Sert uniquement à dire à l'utilisateur ce qu'il a scanné quand le code-barres porte le
    /// préfixe <c>977</c> : rien n'est catalogué à partir de là. Une BnF injoignable renvoie
    /// <c>null</c> et un avertissement — l'écran dira alors « un magazine, titre inconnu »
    /// plutôt que de laisser croire à un échec de recherche de livre.
    /// </remarks>
    Task<(PeriodiqueDetecte? Periodique, string? Avertissement)> RechercherPeriodiqueAsync(
        string issn, CancellationToken ct = default);

    /// <summary>
    /// Interroge le SRU <b>par titre</b>, éventuellement restreint à un auteur.
    /// </summary>
    /// <remarks>
    /// Sert l'ajout à la liste d'envies quand on n'a pas le livre en main : on souhaite une
    /// œuvre dont on connaît le titre, rarement l'ISBN.
    /// </remarks>
    Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherParTitreAsync(
        string titre, string? auteur, CancellationToken ct = default);
}

/// <summary>
/// Extension de la recherche par auteur utilisée uniquement pour les nouveautés.
/// </summary>
public interface IBnfClientNouveautes
{
    Task<(ResultatBibliographie Resultat, string? Avertissement)>
        RechercherParAuteurNouveautesAsync(string auteur, CancellationToken ct = default);
}

/// <summary>
/// Client de l'API SRU du catalogue général de la BnF (gratuite, sans clé).
/// </summary>
public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnfClient, IBnfClientNouveautes
{
    /// <summary>Nom du client typé enregistré dans <c>IHttpClientFactory</c>.</summary>
    public const string NomHttpClient = "bnf";

    public const string UrlBase = "https://catalogue.bnf.fr/";

    public async Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherAsync(
        string isbn, string? urlCouverture, CancellationToken ct = default)
    {
        // recordSchema=dublincore et non MARC : dc:title / dc:creator / dc:publisher se mappent
        // directement, là où l'UNIMARC demanderait un décodage complet.
        var url = "api/SRU"
                  + "?version=1.2"
                  + "&operation=searchRetrieve"
                  + $"&query={Uri.EscapeDataString($"bib.isbn all \"{isbn}\"")}"
                  + "&recordSchema=dublincore"
                  + "&maximumRecords=5";

        try
        {
            using var reponse = await http.GetAsync(url, ct);
            if (!reponse.IsSuccessStatusCode)
            {
                logger.LogWarning("BnF a répondu {Code} pour l'ISBN {Isbn}", (int)reponse.StatusCode, isbn);
                return ([], $"La BnF a répondu {(int)reponse.StatusCode} pour {isbn}.");
            }

            var xml = await reponse.Content.ReadAsStringAsync(ct);
            return (BnfSruParser.Parser(xml, isbn, urlCouverture), null);
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
        {
            // Une source indisponible ne doit pas faire échouer la cascade : on bascule sur la suivante.
            logger.LogWarning(ex, "BnF injoignable pour l'ISBN {Isbn}", isbn);
            return ([], $"BnF injoignable ({ex.GetType().Name}) pour {isbn}.");
        }
        catch (System.Xml.XmlException ex)
        {
            logger.LogWarning(ex, "Réponse BnF illisible pour l'ISBN {Isbn}", isbn);
            return ([], $"Réponse BnF illisible pour {isbn}.");
        }
    }

    public async Task<(PeriodiqueDetecte? Periodique, string? Avertissement)> RechercherPeriodiqueAsync(
        string issn, CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(issn))
        {
            return (null, null);
        }

        // bib.issn, vérifié le 2026-08-18 : l'ISSN doit porter son tiret (« 2466-6718 »),
        // forme canonique sous laquelle la BnF l'indexe.
        var url = "api/SRU"
                  + "?version=1.2"
                  + "&operation=searchRetrieve"
                  + $"&query={Uri.EscapeDataString($"bib.issn all \"{issn}\"")}"
                  + "&recordSchema=dublincore"
                  + "&maximumRecords=1";

        try
        {
            using var reponse = await http.GetAsync(url, ct);
            if (!reponse.IsSuccessStatusCode)
            {
                logger.LogWarning("BnF a répondu {Code} pour l'ISSN {Issn}", (int)reponse.StatusCode, issn);
                return (null, $"La BnF a répondu {(int)reponse.StatusCode} pour l'ISSN {issn}.");
            }

            var xml = await reponse.Content.ReadAsStringAsync(ct);
            return (BnfSruParser.ParserPeriodique(xml, issn), null);
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
        {
            logger.LogWarning(ex, "BnF injoignable pour l'ISSN {Issn}", issn);
            return (null, $"BnF injoignable ({ex.GetType().Name}) pour l'ISSN {issn}.");
        }
        catch (System.Xml.XmlException ex)
        {
            logger.LogWarning(ex, "Réponse BnF illisible pour l'ISSN {Issn}", issn);
            return (null, $"Réponse BnF illisible pour l'ISSN {issn}.");
        }
    }

    /// <summary>Notices rendues par une recherche par titre : de quoi choisir sans faire défiler.</summary>
    public const int NoticesRecherche = 20;

    public async Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherParTitreAsync(
        string titre, string? auteur, CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(titre))
        {
            return ([], null);
        }

        // Les deux index se combinent en CQL par « and ». L'auteur est facultatif : sans lui la
        // recherche porte sur le seul titre, ce qui suffit pour un titre un peu distinctif.
        var cql = $"bib.title all \"{Echapper(titre)}\"";
        if (!string.IsNullOrWhiteSpace(auteur))
        {
            cql += $" and bib.author all \"{Echapper(auteur)}\"";
        }

        var url = "api/SRU"
                  + "?version=1.2"
                  + "&operation=searchRetrieve"
                  + $"&query={Uri.EscapeDataString(cql)}"
                  + "&recordSchema=dublincore"
                  + $"&maximumRecords={NoticesRecherche}";

        try
        {
            using var reponse = await http.GetAsync(url, ct);
            if (!reponse.IsSuccessStatusCode)
            {
                logger.LogWarning("BnF a répondu {Code} pour le titre {Titre}", (int)reponse.StatusCode, titre);
                return ([], $"La BnF a répondu {(int)reponse.StatusCode} pour « {titre} ».");
            }

            var xml = await reponse.Content.ReadAsStringAsync(ct);
            return (BnfBibliographieParser.ParserRecherche(xml), null);
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
        {
            logger.LogWarning(ex, "BnF injoignable pour le titre {Titre}", titre);
            return ([], $"BnF injoignable ({ex.GetType().Name}) pour « {titre} ».");
        }
        catch (System.Xml.XmlException ex)
        {
            logger.LogWarning(ex, "Réponse BnF illisible pour le titre {Titre}", titre);
            return ([], $"Réponse BnF illisible pour « {titre} ».");
        }
    }

    /// <summary>
    /// Neutralise les guillemets d'un terme de recherche, qui délimitent la valeur en CQL.
    /// </summary>
    /// <remarks>
    /// Un titre contenant un guillemet fermerait la chaîne et rendrait la requête invalide —
    /// « L'homme qui plantait des arbres » n'en a pas, mais rien ne l'interdit à un utilisateur.
    /// </remarks>
    private static string Echapper(string terme) => terme.Trim().Replace("\"", " ");

    /// <summary>
    /// Notices lues par page, et nombre de pages : au plus <b>200 notices</b>.
    /// </summary>
    /// <remarks>
    /// Ce plafond est un arbitrage, pas une limite de l'API. Mesuré le 2026-08-18 : une requête
    /// coûte ~0,85 s, et les deux pages sont donc lancées <b>en parallèle</b>. Aller au-delà
    /// serait long pour un gain nul dans la plupart des cas — Bernard Werber tient en 203
    /// notices, Amélie Nothomb en 277 — et resterait de toute façon insuffisant pour un classique
    /// très réédité : Émile Zola en compte 2692. Comme le SRU classe par pertinence, les deux
    /// premières pages contiennent l'essentiel de l'œuvre ; le reste est de la réédition.
    /// <para>
    /// L'interface <b>doit</b> signaler que la liste est un extrait quand la BnF en annonce plus
    /// (<c>BibliographieDto.Tronquee</c>) : une bibliographie partielle présentée comme complète
    /// ferait croire à tort qu'un livre n'existe pas.
    /// </para>
    /// </remarks>
    public const int NoticesParPage = 100;

    public const int PagesMaximum = 2;
    public const int PagesMaximumNouveautes = 10;

    public static int NombrePagesNouveautes(int nombreAnnonce) =>
        Math.Min(
            PagesMaximumNouveautes,
            Math.Max(1, (nombreAnnonce + NoticesParPage - 1) / NoticesParPage));

    public async Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync(
        string auteur, CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(auteur))
        {
            return (new ResultatBibliographie(), null);
        }

        try
        {
            // Les pages sont lancées ensemble : à ~0,85 s l'unité, les enchaîner doublerait
            // l'attente de l'écran pour rien.
            var pages = await Task.WhenAll(
                Enumerable.Range(0, PagesMaximum)
                    .Select(i => LirePageAsync(auteur, (i * NoticesParPage) + 1, ct)));

            var lues = pages.Where(p => p is not null).Select(p => p!).ToList();
            if (lues.Count == 0)
            {
                return Echec(EtatSourceBibliographie.Injoignable, auteur);
            }

            return (Fusionner(lues), null);
        }
        // ⚠️ Le délai dépassé se distingue de l'injoignable, et ce n'est pas du détail : c'est
        // le cas remonté en usage, et le seul qui vaille la peine d'être retenté tout de suite.
        // HttpClient l'annonce par une TaskCanceledException dont l'exception interne est un
        // TimeoutException — sans ce test, il se confondrait avec une annulation de l'appelant.
        catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
        {
            logger.LogWarning(ex, "Délai dépassé par la BnF pour l'auteur {Auteur}", auteur);
            return Echec(EtatSourceBibliographie.DelaiDepasse, auteur);
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
        {
            logger.LogWarning(ex, "BnF injoignable pour l'auteur {Auteur}", auteur);
            return Echec(EtatSourceBibliographie.Injoignable, auteur);
        }
        catch (System.Xml.XmlException ex)
        {
            logger.LogWarning(ex, "Réponse BnF illisible pour l'auteur {Auteur}", auteur);
            return Echec(EtatSourceBibliographie.ReponseIllisible, auteur);
        }
    }

    public async Task<(ResultatBibliographie Resultat, string? Avertissement)>
        RechercherParAuteurNouveautesAsync(string auteur, CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(auteur))
        {
            return (new ResultatBibliographie(), null);
        }

        try
        {
            var premiere = await LirePageAsync(auteur, 1, ct);
            if (premiere is null)
            {
                return Echec(EtatSourceBibliographie.Injoignable, auteur);
            }

            var nombrePages = NombrePagesNouveautes(premiere.NombreAnnonce);

            var pages = new List<ResultatBibliographie> { premiere };
            if (nombrePages > 1)
            {
                var suivantes = await Task.WhenAll(
                    Enumerable.Range(1, nombrePages - 1)
                        .Select(i => LirePageAsync(auteur, (i * NoticesParPage) + 1, ct)));
                pages.AddRange(suivantes.Where(p => p is not null).Select(p => p!));
            }

            return (Fusionner(pages), null);
        }
        catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
        {
            logger.LogWarning(ex, "Délai dépassé par la BnF pour les nouveautés de {Auteur}", auteur);
            return Echec(EtatSourceBibliographie.DelaiDepasse, auteur);
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
        {
            logger.LogWarning(ex, "BnF injoignable pour les nouveautés de {Auteur}", auteur);
            return Echec(EtatSourceBibliographie.Injoignable, auteur);
        }
        catch (System.Xml.XmlException ex)
        {
            logger.LogWarning(ex, "Réponse BnF illisible pour les nouveautés de {Auteur}", auteur);
            return Echec(EtatSourceBibliographie.ReponseIllisible, auteur);
        }
    }

    /// <summary>
    /// Motif <b>lisible</b> d'une bibliographie que la BnF n'a pas pu fournir.
    /// </summary>
    /// <remarks>
    /// ⚠️ Public parce que ce texte <b>atteint l'utilisateur</b> : c'est un contrat d'interface,
    /// pas un détail d'implémentation, et il est verrouillé par des tests. Il ne contient aucun
    /// nom de classe .NET — « BnF injoignable (TaskCanceledException) » est ce qui s'affichait
    /// avant, et « TaskCanceledException » ne veut rien dire pour qui range ses livres.
    /// <para>
    /// Les trois motifs se distinguent parce qu'ils n'appellent pas la même chose : un délai
    /// dépassé se retente tout de suite, une réponse illisible non.
    /// </para>
    /// </remarks>
    public static string MotifDeSourceMuette(EtatSourceBibliographie etat, string auteur) =>
        etat switch
        {
            EtatSourceBibliographie.DelaiDepasse =>
                $"La BnF n'a pas répondu à temps pour « {auteur} ». Son catalogue est parfois lent ;"
                + " réessayer suffit le plus souvent.",
            EtatSourceBibliographie.ReponseIllisible =>
                $"La BnF a répondu quelque chose d'inexploitable pour « {auteur} ».",
            _ =>
                $"La BnF n'a pas pu être jointe pour « {auteur} ». Vérifiez votre connexion,"
                + " ou réessayez plus tard.",
        };

    /// <summary>Résultat d'une interrogation qui n'a pas abouti : aucune notice, et son motif.</summary>
    private static (ResultatBibliographie, string?) Echec(EtatSourceBibliographie etat, string auteur) =>
        (new ResultatBibliographie { Etat = etat }, MotifDeSourceMuette(etat, auteur));

    private async Task<ResultatBibliographie?> LirePageAsync(
        string auteur, int premiereNotice, CancellationToken ct)
    {
        // bib.author : index auteur du catalogue général, vérifié le 2026-08-18.
        // « all » exige que tous les mots soient présents — mais sur l'ENSEMBLE des auteurs de la
        // notice, d'où le post-filtre de BnfBibliographieParser.
        var url = "api/SRU"
                  + "?version=1.2"
                  + "&operation=searchRetrieve"
                  + $"&query={Uri.EscapeDataString($"bib.author all \"{auteur}\"")}"
                  + "&recordSchema=dublincore"
                  + $"&maximumRecords={NoticesParPage}"
                  + $"&startRecord={premiereNotice}";

        using var reponse = await http.GetAsync(url, ct);
        if (!reponse.IsSuccessStatusCode)
        {
            logger.LogWarning(
                "BnF a répondu {Code} pour l'auteur {Auteur} (notice {Debut})",
                (int)reponse.StatusCode, auteur, premiereNotice);
            return null;
        }

        var xml = await reponse.Content.ReadAsStringAsync(ct);
        return BnfBibliographieParser.Parser(xml, auteur);
    }

    /// <summary>Recolle les pages en un seul résultat, en additionnant les compteurs.</summary>
    private static ResultatBibliographie Fusionner(IReadOnlyList<ResultatBibliographie> pages) =>
        new()
        {
            Notices = pages.SelectMany(p => p.Notices).ToList(),

            // Le total annoncé est le même sur chaque page : on le prend, on ne l'additionne pas.
            NombreAnnonce = pages.Max(p => p.NombreAnnonce),
            NombreLu = pages.Sum(p => p.NombreLu),
            EcarteesTypeNonLivre = pages.Sum(p => p.EcarteesTypeNonLivre),
            EcarteesAutreAuteur = pages.Sum(p => p.EcarteesAutreAuteur),
        };
}
