using MaBibli.Api.Data;
using MaBibli.Api.Services.Isbn;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;

namespace MaBibli.Api.Services.Catalogue;

public interface IServiceBibliographie
{
    /// <summary>
    /// Bibliographie d'un auteur du catalogue, confrontée à ce que l'on possède et à ce que
    /// l'appelant souhaite déjà. <c>null</c> si l'auteur n'existe pas.
    /// </summary>
    Task<BibliographieDto?> ObtenirAsync(int auteurId, string? utilisateur, CancellationToken ct = default);
}

/// <summary>
/// Bibliographie d'un auteur : ce que la BnF connaît de lui, mis en regard de l'étagère.
/// </summary>
/// <remarks>
/// Trois sources se rencontrent ici, et elles n'ont pas la même portée :
/// <list type="bullet">
/// <item>la <b>BnF</b>, source externe, ni possédée ni personnelle ;</item>
/// <item>le <b>catalogue</b>, commun au foyer — « possédé » veut dire possédé par tout le
/// monde ;</item>
/// <item>la <b>liste d'envies</b>, personnelle — « déjà souhaité » ne vaut que pour l'appelant.</item>
/// </list>
/// Deux membres du foyer voient donc la même colonne « possédé » et deux colonnes « souhaité »
/// différentes. C'est voulu, et c'est ce qui permet d'offrir un livre à l'autre.
/// </remarks>
public sealed class ServiceBibliographie(
    MaBibliDbContext db, IBnfClient bnf, IServiceAuteurs auteurs) : IServiceBibliographie
{
    public async Task<BibliographieDto?> ObtenirAsync(
        int auteurId, string? utilisateur, CancellationToken ct = default)
    {
        var auteur = await auteurs.ObtenirAsync(auteurId, ct);
        if (auteur is null)
        {
            return null;
        }

        var (resultat, avertissement) = await bnf.RechercherParAuteurAsync(auteur.Nom, ct);

        // Ce que l'on possède DE CET AUTEUR. On ne compare pas à tout le catalogue : deux auteurs
        // peuvent avoir écrit deux livres homonymes, et griser l'un à cause de l'autre serait un
        // faux positif — bien plus gênant qu'un oubli, puisqu'il détourne d'un achat justifié.
        var possedes = await db.LivreAuteurs
            .AsNoTracking()
            .Where(la => la.AuteurId == auteurId)
            .Select(la => new { la.LivreId, la.Livre!.Titre, la.Livre.Isbn })
            .ToListAsync(ct);

        var parTitre = new Dictionary<string, int>();
        var parIsbn = new Dictionary<string, int>();

        foreach (var livre in possedes)
        {
            var cle = CleOeuvre.Cle(livre.Titre);
            if (cle.Length > 0)
            {
                parTitre.TryAdd(cle, livre.LivreId);
            }

            // L'ISBN ne sert qu'en confirmation : il ne rapproche que la même édition exacte,
            // alors que le titre rapproche l'œuvre. Il rattrape en revanche le cas où le titre
            // saisi à la main diverge de celui de la BnF.
            if (!string.IsNullOrWhiteSpace(livre.Isbn))
            {
                parIsbn.TryAdd(livre.Isbn, livre.LivreId);
            }
        }

        var souhaits = await SouhaitsAsync(utilisateur, ct);

        var oeuvres = Regrouper(resultat)
            .Select(o => Confronter(o, parTitre, parIsbn, souhaits))
            .OrderByDescending(o => AnneeTriable(o.Annee))
            .ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase)
            .ToList();

        return new BibliographieDto
        {
            Auteur = auteur,
            Oeuvres = oeuvres,
            NoticesAnnoncees = resultat.NombreAnnonce,
            NoticesLues = resultat.NombreLu,
            NoticesEcartees = resultat.EcarteesAutreAuteur + resultat.EcarteesTypeNonLivre,
            Avertissement = avertissement,
        };
    }

    /// <summary>Clés d'œuvre déjà souhaitées par l'appelant, avec l'identifiant de l'envie.</summary>
    private async Task<Dictionary<string, int>> SouhaitsAsync(string? utilisateur, CancellationToken ct)
    {
        if (utilisateur is null)
        {
            return [];
        }

        var lignes = await db.LivresSouhaites
            .AsNoTracking()
            .Where(s => s.Utilisateur == utilisateur)
            .Select(s => new { s.Id, s.TitreNormalise })
            .ToListAsync(ct);

        var index = new Dictionary<string, int>();
        foreach (var ligne in lignes)
        {
            index.TryAdd(ligne.TitreNormalise, ligne.Id);
        }

        return index;
    }

    /// <summary>
    /// Réunit les rééditions sous une seule œuvre.
    /// </summary>
    /// <remarks>
    /// C'est le cœur de l'utilisabilité de l'écran : sans regroupement, Bernard Werber occupe
    /// 183 lignes dont « Les fourmis » onze fois. Après regroupement, 51 lignes.
    /// <para>
    /// Le représentant retenu est la notice la <b>plus ancienne</b> : c'est l'édition originale,
    /// donc l'année qui situe l'œuvre dans la carrière de l'auteur, et non celle du dernier
    /// retirage en poche. Le titre et l'éditeur suivent la même notice pour rester cohérents,
    /// mais l'ISBN est pris sur <b>n'importe quelle</b> notice qui en porte un : les éditions
    /// anciennes n'en ont souvent aucun, et un ISBN de réédition reste utile pour commander.
    /// </para>
    /// </remarks>
    public static IEnumerable<OeuvreBibliographie> Regrouper(ResultatBibliographie resultat) =>
        resultat.Notices
            .Where(n => n.CleTitre.Length > 0)
            .GroupBy(n => n.CleTitre)
            .Select(groupe =>
            {
                var ordonnees = groupe
                    .OrderBy(n => AnneeTriable(n.Annee) ?? int.MaxValue)
                    .ToList();

                var reference = ordonnees[0];

                return new OeuvreBibliographie
                {
                    Titre = reference.Titre,
                    Annee = reference.Annee,
                    Editeur = reference.Editeur,
                    Isbn = ordonnees.Select(n => n.Isbn).FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)),
                    NombreEditions = groupe.Count(),
                };
            });

    private static OeuvreBibliographie Confronter(
        OeuvreBibliographie oeuvre,
        IReadOnlyDictionary<string, int> parTitre,
        IReadOnlyDictionary<string, int> parIsbn,
        IReadOnlyDictionary<string, int> souhaits)
    {
        var cle = CleOeuvre.Cle(oeuvre.Titre);

        int? livreId = parTitre.TryGetValue(cle, out var idTitre) ? idTitre : null;

        if (livreId is null
            && !string.IsNullOrWhiteSpace(oeuvre.Isbn)
            && parIsbn.TryGetValue(oeuvre.Isbn, out var idIsbn))
        {
            livreId = idIsbn;
        }

        int? souhaitId = souhaits.TryGetValue(cle, out var idSouhait) ? idSouhait : null;

        return oeuvre with
        {
            Possede = livreId is not null,
            LivreId = livreId,
            Souhaite = souhaitId is not null,
            SouhaitId = souhaitId,
        };
    }

    /// <summary>
    /// Année exploitable pour le tri, extraite d'une date BnF.
    /// </summary>
    /// <remarks>
    /// <c>dc:date</c> n'est pas un nombre : on rencontre « 2012 », mais aussi « [DL 2012] »,
    /// « cop. 1991 » ou « 19..? ». On prend la première suite de quatre chiffres, et
    /// <c>null</c> s'il n'y en a pas — une date illisible ne doit pas devenir l'an zéro et
    /// remonter en tête de liste.
    /// </remarks>
    public static int? AnneeTriable(string? date)
    {
        if (string.IsNullOrWhiteSpace(date))
        {
            return null;
        }

        for (var i = 0; i + 4 <= date.Length; i++)
        {
            var tranche = date.AsSpan(i, 4);
            if (int.TryParse(tranche, out var annee) && annee is >= 1000 and <= 2999)
            {
                return annee;
            }
        }

        return null;
    }
}
