using MaBibli.Api.Data;
using MaBibli.Api.Services.Isbn;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
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);

    Task<BibliographieDto?> ObtenirNouveautesAsync(
        int auteurId, string? utilisateur, CancellationToken ct = default);

    Task<bool> MasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default);

    Task<bool> DemasquerAsync(int auteurId, string titre, 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 masquees = await MasqueesAsync(auteurId, utilisateur, ct);

        var oeuvres = Regrouper(resultat)
            .Select(o => Confronter(o, parTitre, parIsbn, souhaits, masquees))
            .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,

            // Transmis tel quel : c'est l'écran qui décide quoi en dire, mais lui seul ne
            // pourrait pas deviner qu'une liste vide vient d'une panne.
            Etat = resultat.Etat,
        };
    }

    public async Task<BibliographieDto?> ObtenirNouveautesAsync(
        int auteurId, string? utilisateur, CancellationToken ct = default)
    {
        var auteur = await auteurs.ObtenirAsync(auteurId, ct);
        if (auteur is null)
        {
            return null;
        }

        var recherche = bnf is IBnfClientNouveautes etendu
            ? await etendu.RechercherParAuteurNouveautesAsync(auteur.Nom, ct)
            : await bnf.RechercherParAuteurAsync(auteur.Nom, ct);
        var resultat = recherche.Resultat;

        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);
            }

            if (!string.IsNullOrWhiteSpace(livre.Isbn))
            {
                parIsbn.TryAdd(livre.Isbn, livre.LivreId);
            }
        }

        var souhaits = await SouhaitsAsync(utilisateur, ct);
        var masquees = await MasqueesAsync(auteurId, utilisateur, ct);
        var oeuvres = Regrouper(resultat)
            .Select(o => Confronter(o, parTitre, parIsbn, souhaits, masquees))
            .ToList();
        var seuil = AnneeSeuilNouveautes(
            possedes.Select(l => (l.Titre, l.Isbn)), resultat.Notices);

        // Une notice d'édition originale ne date pas la dernière édition de l'œuvre. Le
        // regroupement conserve donc AnneeDerniereEdition, qui est la seule date pertinente
        // pour répondre à « paru depuis ».
        var nouveautes = FiltrerNouveautes(oeuvres, seuil);

        return new BibliographieDto
        {
            Auteur = auteur,
            Oeuvres = nouveautes,
            NoticesAnnoncees = resultat.NombreAnnonce,
            NoticesLues = resultat.NombreLu,
            NoticesEcartees = resultat.EcarteesAutreAuteur + resultat.EcarteesTypeNonLivre,
            Avertissement = recherche.Avertissement,
            Etat = resultat.Etat,
            EstNouveautes = true,
            AnneeSeuilNouveautes = seuil,
            SeuilNouveautesInconnu = seuil is null && possedes.Count > 0,
        };
    }

    public async Task<bool> MasquerAsync(
        int auteurId, string titre, string? utilisateur, CancellationToken ct = default)
    {
        if (utilisateur is null)
        {
            return false;
        }

        var cle = CleOeuvre.Cle(titre);
        if (cle.Length == 0)
        {
            return false;
        }

        var existe = await db.BibliographiesMasquees.AnyAsync(
            m => m.AuteurId == auteurId && m.Utilisateur == utilisateur && m.TitreNormalise == cle, ct);
        if (existe)
        {
            return true;
        }

        db.BibliographiesMasquees.Add(new BibliographieMasquee
        {
            AuteurId = auteurId,
            Utilisateur = utilisateur,
            TitreNormalise = cle,
        });
        await db.SaveChangesAsync(ct);
        return true;
    }

    public async Task<bool> DemasquerAsync(
        int auteurId, string titre, string? utilisateur, CancellationToken ct = default)
    {
        if (utilisateur is null)
        {
            return false;
        }

        var cle = CleOeuvre.Cle(titre);
        var ligne = await db.BibliographiesMasquees.FirstOrDefaultAsync(
            m => m.AuteurId == auteurId && m.Utilisateur == utilisateur && m.TitreNormalise == cle, ct);
        if (ligne is null)
        {
            return false;
        }

        db.BibliographiesMasquees.Remove(ligne);
        await db.SaveChangesAsync(ct);
        return true;
    }

    /// <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;
    }

    private async Task<HashSet<string>> MasqueesAsync(
        int auteurId, string? utilisateur, CancellationToken ct)
    {
        if (utilisateur is null)
        {
            return [];
        }

        var cles = await db.BibliographiesMasquees
            .AsNoTracking()
            .Where(m => m.AuteurId == auteurId && m.Utilisateur == utilisateur)
            .Select(m => m.TitreNormalise)
            .ToListAsync(ct);
        return cles.ToHashSet(StringComparer.Ordinal);
    }

    /// <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,
                    AnneeDerniereEdition = ordonnees
                        .Select(n => AnneeTriable(n.Annee))
                        .Where(a => a is not null)
                        .Select(a => a!.Value)
                        .DefaultIfEmpty()
                        .Max() is var annee && annee > 0 ? annee : null,
                    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,
        IReadOnlySet<string> masquees)
    {
        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,
            Masquee = masquees.Contains(cle),
        };
    }

    public static int? AnneeSeuilNouveautes(
        IEnumerable<(string Titre, string? Isbn)> possedes,
        IReadOnlyList<NoticeAuteur> notices)
    {
        var annees = new List<int>();
        foreach (var livre in possedes)
        {
            var correspondantes = !string.IsNullOrWhiteSpace(livre.Isbn)
                ? notices.Where(n => MemesIsbn(n.Isbn, livre.Isbn))
                : notices.Where(n => n.CleTitre == CleOeuvre.Cle(livre.Titre));

            annees.AddRange(correspondantes
                .Select(n => AnneeTriable(n.Annee))
                .Where(a => a is not null)
                .Select(a => a!.Value));
        }

        return annees.Count == 0 ? null : annees.Max();
    }

    private static bool MemesIsbn(string? gauche, string? droite)
    {
        var a = IsbnUtils.Normaliser(gauche);
        var b = IsbnUtils.Normaliser(droite);
        if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
        {
            return false;
        }

        if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        return IsbnUtils.TryConvertirEnIsbn10(a, out var a10)
            && string.Equals(a10, b, StringComparison.OrdinalIgnoreCase)
            || IsbnUtils.TryConvertirEnIsbn10(b, out var b10)
                && string.Equals(b10, a, StringComparison.OrdinalIgnoreCase);
    }

    public static IReadOnlyList<OeuvreBibliographie> FiltrerNouveautes(
        IEnumerable<OeuvreBibliographie> oeuvres, int? seuil)
    {
        if (seuil is null)
        {
            return [];
        }

        return oeuvres
            .Where(o => o.AnneeDerniereEdition > seuil && o.ADecouvrir)
            .OrderByDescending(o => o.AnneeDerniereEdition)
            .ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase)
            .ToList();
    }

    /// <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;
    }
}
