using MaBibli.Api.Data;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;

namespace MaBibli.Api.Services.Catalogue;

public interface IServiceAuteurs
{
    /// <summary>
    /// Convertit des noms saisis en fiches auteur, en réutilisant l'existant quand c'est sûr.
    /// Les nouvelles fiches sont ajoutées au contexte mais <b>pas</b> enregistrées.
    /// </summary>
    Task<IReadOnlyList<Auteur>> ResoudreAsync(IEnumerable<string> noms, CancellationToken ct = default);

    Task<IReadOnlyList<AuteurDto>> ListerAsync(CancellationToken ct = default);

    Task<AuteurDto?> ObtenirAsync(int id, CancellationToken ct = default);

    /// <summary>Rapprochements ambigus à soumettre à l'utilisateur, refus déjà écartés.</summary>
    Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(CancellationToken ct = default);

    /// <summary>Fusionne <c>source</c> dans <c>cible</c>. <c>source</c> disparaît.</summary>
    Task<bool> FusionnerAsync(int sourceId, int cibleId, CancellationToken ct = default);

    /// <summary>Mémorise que ces deux auteurs sont bien deux personnes différentes.</summary>
    Task<bool> RefuserAsync(int sourceId, int cibleId, CancellationToken ct = default);

    /// <summary>Supprime les auteurs qui ne sont plus rattachés à aucun livre.</summary>
    Task SupprimerOrphelinsAsync(CancellationToken ct = default);
}

/// <summary>
/// Gestion de la table des auteurs : résolution des noms, regroupement et fusions.
/// </summary>
/// <remarks>
/// <b>Tout se joue sur une table volontairement petite.</b> Une bibliothèque de foyer compte
/// quelques centaines d'auteurs : les comparaisons mot à mot (initiales, ordre inversé) sont
/// faites en mémoire, parce qu'elles ne s'expriment pas en SQL. Le premier essai — la clé de
/// regroupement — reste, lui, un simple index en base et couvre l'écrasante majorité des cas.
/// </remarks>
public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
{
    public async Task<IReadOnlyList<Auteur>> ResoudreAsync(
        IEnumerable<string> noms, CancellationToken ct = default)
    {
        var resolus = new List<Auteur>();

        foreach (var brut in noms)
        {
            var nom = brut?.Trim();
            if (string.IsNullOrEmpty(nom))
            {
                continue;
            }

            var cle = RapprochementAuteurs.Cle(nom);
            if (cle.Length == 0)
            {
                continue; // Une saisie sans la moindre lettre ne désigne pas un auteur.
            }

            // Déjà résolu dans ce même enregistrement : « Zola » saisi deux fois ne doit pas
            // produire deux liens vers le même auteur (la clé primaire du lien s'y opposerait).
            if (resolus.Any(a => a.CleRegroupement == cle))
            {
                continue;
            }

            var auteur = await TrouverAsync(nom, cle, ct);

            if (auteur is null)
            {
                auteur = new Auteur { Nom = nom };
                auteur.RecalculerFormes();
                db.Auteurs.Add(auteur);
            }

            resolus.Add(auteur);
        }

        return resolus;
    }

    /// <summary>
    /// Cherche l'auteur existant qui correspond <b>sûrement</b> à ce nom, ou <c>null</c>.
    /// </summary>
    private async Task<Auteur?> TrouverAsync(string nom, string cle, CancellationToken ct)
    {
        // 1. Clé identique : même mots normalisés, ordre indifférent. Résolu par l'index unique.
        //    Le Local est consulté aussi, sinon deux auteurs identiques créés dans la même
        //    requête violeraient l'unicité au SaveChanges.
        var parCle = db.Auteurs.Local.FirstOrDefault(a => a.CleRegroupement == cle)
                     ?? await db.Auteurs.FirstOrDefaultAsync(a => a.CleRegroupement == cle, ct);

        if (parCle is not null)
        {
            return parCle;
        }

        // 2. Variantes que la clé ne capture pas : les initiales abrégées. Il faut aligner les
        //    mots un à un, ce qui n'a pas d'équivalent SQL — d'où le balayage en mémoire.
        var candidats = await db.Auteurs.ToListAsync(ct);
        candidats.AddRange(db.Auteurs.Local.Where(a => a.Id == 0));

        var surs = candidats
            .Where(a => RapprochementAuteurs.SontLeMemeAuteur(nom, a.Nom))
            .OrderBy(a => a.Id)
            .ToList();

        // Plusieurs correspondances sûres signifient que la table contient déjà des doublons
        // (ils se seraient rapprochés entre eux). On prend la plus ancienne plutôt que d'en
        // créer une troisième : l'utilisateur les réunira depuis l'écran des auteurs.
        return surs.FirstOrDefault();
    }

    public async Task<IReadOnlyList<AuteurDto>> ListerAsync(CancellationToken ct = default) =>
        await db.Auteurs
            .AsNoTracking()
            .OrderBy(a => a.NomNormalise)
            .Select(a => new AuteurDto
            {
                Id = a.Id,
                Nom = a.Nom,
                NombreDeLivres = a.Livres.Count,
            })
            .ToListAsync(ct);

    public async Task<AuteurDto?> ObtenirAsync(int id, CancellationToken ct = default) =>
        await db.Auteurs
            .AsNoTracking()
            .Where(a => a.Id == id)
            .Select(a => new AuteurDto
            {
                Id = a.Id,
                Nom = a.Nom,
                NombreDeLivres = a.Livres.Count,
            })
            .FirstOrDefaultAsync(ct);

    public async Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(
        CancellationToken ct = default)
    {
        var auteurs = await db.Auteurs
            .AsNoTracking()
            .Select(a => new { a.Id, a.Nom, Livres = a.Livres.Count })
            .OrderBy(a => a.Id)
            .ToListAsync(ct);

        var refuses = await db.RapprochementsRefuses
            .AsNoTracking()
            .Select(r => new { r.AuteurAId, r.AuteurBId })
            .ToListAsync(ct);

        var ecartes = refuses.Select(r => (r.AuteurAId, r.AuteurBId)).ToHashSet();

        var suggestions = new List<SuggestionRapprochement>();

        // Comparaison de toutes les paires : quadratique, mais sur quelques centaines d'auteurs
        // c'est instantané, et le résultat n'est calculé qu'à l'ouverture de l'écran des auteurs.
        for (var i = 0; i < auteurs.Count; i++)
        {
            for (var j = i + 1; j < auteurs.Count; j++)
            {
                var a = auteurs[i];
                var b = auteurs[j];

                if (RapprochementAuteurs.Comparer(a.Nom, b.Nom) != NiveauRapprochement.Suggere)
                {
                    continue;
                }

                if (ecartes.Contains(Ordonner(a.Id, b.Id)))
                {
                    continue; // L'utilisateur a déjà dit non : ne pas le lui redemander.
                }

                // La fusion fait disparaître la source : c'est le nom le moins complet qui
                // s'efface, pour ne pas perdre « Peter F. Hamilton » au profit de « Hamilton ».
                var (source, cible) =
                    NormalisationTexte.Mots(a.Nom).Length <= NormalisationTexte.Mots(b.Nom).Length
                        ? (a, b)
                        : (b, a);

                suggestions.Add(new SuggestionRapprochement
                {
                    Source = new AuteurDto { Id = source.Id, Nom = source.Nom, NombreDeLivres = source.Livres },
                    Cible = new AuteurDto { Id = cible.Id, Nom = cible.Nom, NombreDeLivres = cible.Livres },
                });
            }
        }

        return suggestions;
    }

    public async Task<bool> FusionnerAsync(int sourceId, int cibleId, CancellationToken ct = default)
    {
        if (sourceId == cibleId)
        {
            return false;
        }

        var source = await db.Auteurs.Include(a => a.Livres).FirstOrDefaultAsync(a => a.Id == sourceId, ct);
        var cible = await db.Auteurs.Include(a => a.Livres).FirstOrDefaultAsync(a => a.Id == cibleId, ct);

        if (source is null || cible is null)
        {
            return false;
        }

        var dejaLies = cible.Livres.Select(l => l.LivreId).ToHashSet();

        foreach (var lien in source.Livres.ToList())
        {
            // Un livre co-signé par les deux fiches ne doit pas donner deux liens identiques :
            // la clé primaire du lien est (LivreId, AuteurId).
            if (!dejaLies.Add(lien.LivreId))
            {
                db.LivreAuteurs.Remove(lien);
                continue;
            }

            db.LivreAuteurs.Remove(lien);
            db.LivreAuteurs.Add(new LivreAuteur
            {
                LivreId = lien.LivreId,
                AuteurId = cible.Id,
                Position = lien.Position,
            });
        }

        // Les refus qui portaient sur la fiche absorbée n'ont plus d'objet.
        var refusObsoletes = await db.RapprochementsRefuses
            .Where(r => r.AuteurAId == sourceId || r.AuteurBId == sourceId)
            .ToListAsync(ct);
        db.RapprochementsRefuses.RemoveRange(refusObsoletes);

        db.Auteurs.Remove(source);
        await db.SaveChangesAsync(ct);
        return true;
    }

    public async Task<bool> RefuserAsync(int sourceId, int cibleId, CancellationToken ct = default)
    {
        if (sourceId == cibleId)
        {
            return false;
        }

        var (a, b) = Ordonner(sourceId, cibleId);

        var existants = await db.Auteurs.CountAsync(x => x.Id == a || x.Id == b, ct);
        if (existants != 2)
        {
            return false;
        }

        var deja = await db.RapprochementsRefuses.AnyAsync(r => r.AuteurAId == a && r.AuteurBId == b, ct);
        if (!deja)
        {
            db.RapprochementsRefuses.Add(new RapprochementRefuse { AuteurAId = a, AuteurBId = b });
            await db.SaveChangesAsync(ct);
        }

        return true;
    }

    public async Task SupprimerOrphelinsAsync(CancellationToken ct = default)
    {
        // Après une édition ou une suppression de livre, une fiche auteur peut n'avoir plus aucun
        // livre. La garder polluerait l'écran des auteurs et les suggestions de rapprochement.
        var orphelins = await db.Auteurs.Where(a => a.Livres.Count == 0).ToListAsync(ct);
        if (orphelins.Count == 0)
        {
            return;
        }

        var ids = orphelins.Select(a => a.Id).ToList();
        var refus = await db.RapprochementsRefuses
            .Where(r => ids.Contains(r.AuteurAId) || ids.Contains(r.AuteurBId))
            .ToListAsync(ct);

        db.RapprochementsRefuses.RemoveRange(refus);
        db.Auteurs.RemoveRange(orphelins);
        await db.SaveChangesAsync(ct);
    }

    /// <summary>Couple rangé, pour qu'un refus vaille dans les deux sens.</summary>
    private static (int, int) Ordonner(int x, int y) => x < y ? (x, y) : (y, x);
}
