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

namespace MaBibli.Api.Services.Souhaits;

/// <summary>Issue d'un ajout d'envie : soit la ligne créée, soit un message pour l'utilisateur.</summary>
public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur)
{
    public static ResultatSouhait Ok(SouhaitDto souhait) => new(souhait, null);

    public static ResultatSouhait Invalide(string message) => new(null, message);
}

public interface IServiceSouhaits
{
    Task<IReadOnlyList<SouhaitDto>> ListerAsync(string? utilisateur, CancellationToken ct = default);

    Task<ResultatSouhait> AjouterAsync(
        EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default);

    Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default);

    /// <summary>
    /// Réordonne la liste de l'appelant selon la suite d'identifiants fournie.
    /// </summary>
    /// <remarks>
    /// Prend la liste <b>entière</b> plutôt qu'un déplacement unitaire : c'est ce dont a besoin
    /// le glisser-déposer, et les flèches monter/descendre s'y ramènent sans effort. L'opération
    /// est idempotente — réenvoyer le même ordre ne change rien.
    /// </remarks>
    Task<bool> ReordonnerAsync(
        IReadOnlyList<int> idsOrdonnes, string? utilisateur, CancellationToken ct = default);
}

/// <summary>
/// La liste d'envies, <b>personnelle</b>.
/// </summary>
/// <remarks>
/// ⚠️ <b>Toute méthode filtre sur <paramref name="utilisateur"/>, sans exception.</b> C'est la
/// différence de fond avec <c>ServiceCatalogue</c>, dont les lectures ne filtrent jamais sur
/// <c>AjoutePar</c> : le catalogue est commun, la liste d'envies ne l'est pas. Une envie qui
/// fuiterait vers un autre membre du foyer gâcherait exactement ce que la fonctionnalité sert
/// à préparer.
/// <para>
/// Sans identité (ni en-tête SSOwat ni utilisateur simulé), il n'y a pas de liste : on ne rend
/// rien et on n'écrit rien, plutôt que de rattacher des envies à un propriétaire inventé.
/// </para>
/// </remarks>
public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
{
    public async Task<IReadOnlyList<SouhaitDto>> ListerAsync(
        string? utilisateur, CancellationToken ct = default)
    {
        if (utilisateur is null)
        {
            return [];
        }

        // L'ordre est celui que l'utilisateur a choisi, du plus désiré au moins désiré. Il a
        // remplacé un tri par auteur puis titre : classer une liste d'envies par ordre
        // alphabétique répondait à une question que personne ne se pose.
        // L'Id départage les rangs égaux — deux lignes de même rang ne devraient pas exister,
        // mais un tri instable ferait sautiller la liste entre deux affichages.
        var souhaits = await db.LivresSouhaites
            .AsNoTracking()
            .Where(s => s.Utilisateur == utilisateur)
            .OrderBy(s => s.Rang)
            .ThenBy(s => s.Id)
            .ToListAsync(ct);

        var possedes = await CorrespondancesAsync(souhaits, ct);

        return souhaits.Select(s => Projeter(s, Correspondant(possedes, s.Id))).ToList();
    }

    public async Task<ResultatSouhait> AjouterAsync(
        EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default)
    {
        if (utilisateur is null)
        {
            return ResultatSouhait.Invalide(
                "Impossible d'ajouter une envie sans savoir à qui elle appartient.");
        }

        if (string.IsNullOrWhiteSpace(saisie.Titre))
        {
            return ResultatSouhait.Invalide("Le titre est obligatoire.");
        }

        // L'ISBN reste facultatif — on souhaite souvent une œuvre sans avoir choisi son édition —
        // mais s'il est saisi il doit tenir debout, comme pour un livre du catalogue.
        string? isbn = null;
        if (!string.IsNullOrWhiteSpace(saisie.Isbn))
        {
            var normalise = IsbnUtils.Normaliser(saisie.Isbn);
            if (!IsbnUtils.EstValide(normalise))
            {
                return ResultatSouhait.Invalide(
                    $"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si vous ne l'avez pas.");
            }

            isbn = normalise;
        }

        var souhait = new LivreSouhaite
        {
            Utilisateur = utilisateur,
            Titre = saisie.Titre,
            Auteur = saisie.Auteur,
            Editeur = Vide(saisie.Editeur),
            Annee = Vide(saisie.Annee),
            Isbn = isbn,
            CoverUrl = Vide(saisie.CoverUrl),
            Note = Vide(saisie.Note),
            DateAjout = DateTime.UtcNow,
        };

        souhait.RecalculerFormes();

        // Le doublon est refusé avec un message plutôt que laissé à l'index unique : l'écran de
        // bibliographie rend le double clic facile, et « UNIQUE constraint failed » ne veut rien
        // dire pour l'utilisateur.
        var deja = await db.LivresSouhaites.AnyAsync(
            s => s.Utilisateur == utilisateur
                 && s.TitreNormalise == souhait.TitreNormalise
                 && s.AuteurNormalise == souhait.AuteurNormalise,
            ct);

        if (deja)
        {
            return ResultatSouhait.Invalide($"« {souhait.Titre} » est déjà dans votre liste d'envies.");
        }

        // En fin de liste : une envie qu'on vient de noter n'est pas déclarée plus désirable
        // que celles déjà classées. La poser en tête déclasserait le choix de l'utilisateur
        // à chaque ajout.
        var dernierRang = await db.LivresSouhaites
            .Where(s => s.Utilisateur == utilisateur)
            .Select(s => (int?)s.Rang)
            .MaxAsync(ct);

        souhait.Rang = (dernierRang ?? -1) + 1;

        db.LivresSouhaites.Add(souhait);
        await db.SaveChangesAsync(ct);

        var possede = await CorrespondancesAsync([souhait], ct);

        return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id)));
    }

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

        // Le filtre sur l'utilisateur fait partie de la CLÉ de recherche, pas d'une vérification
        // ultérieure : ainsi personne ne peut supprimer l'envie d'un autre en devinant son
        // identifiant. Une envie inexistante et une envie appartenant à un autre sont
        // indiscernables de l'extérieur, ce qui est bien le comportement voulu.
        var souhait = await db.LivresSouhaites
            .FirstOrDefaultAsync(s => s.Id == id && s.Utilisateur == utilisateur, ct);

        if (souhait is null)
        {
            return false;
        }

        db.LivresSouhaites.Remove(souhait);
        await db.SaveChangesAsync(ct);
        return true;
    }

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

        // ⚠️ On relit TOUTE la liste de l'appelant, et on ne se fie pas à ce que le client
        // envoie. Deux raisons : un identifiant appartenant à quelqu'un d'autre ne doit pas
        // pouvoir être renuméroté (le filtre fait partie de la clé, comme pour la suppression),
        // et la liste du client peut être périmée — une envie ajoutée depuis un autre appareil
        // n'y figure pas, et la perdre serait pire que de mal la classer.
        var siennes = await db.LivresSouhaites
            .Where(s => s.Utilisateur == utilisateur)
            .ToListAsync(ct);

        if (siennes.Count == 0)
        {
            return false;
        }

        var parId = siennes.ToDictionary(s => s.Id);
        var rang = 0;

        foreach (var id in idsOrdonnes.Distinct())
        {
            if (parId.Remove(id, out var souhait))
            {
                souhait.Rang = rang++;
            }

            // Un identifiant inconnu — supprimé entre-temps, ou appartenant à un autre — est
            // simplement ignoré : réordonner n'est pas une occasion de découvrir des erreurs.
        }

        // Ce que le client ne connaissait pas se range à la suite, dans son ordre précédent,
        // plutôt que d'être renuméroté au hasard.
        foreach (var oublie in parId.Values.OrderBy(s => s.Rang).ThenBy(s => s.Id))
        {
            oublie.Rang = rang++;
        }

        await db.SaveChangesAsync(ct);
        return true;
    }

    /// <summary>
    /// Pour chaque envie, le livre du catalogue qui lui correspond — quand il y en a un.
    /// </summary>
    /// <remarks>
    /// <b>Ce qui se croise ici, ce sont deux portées différentes</b> : la liste d'envies est
    /// personnelle, le catalogue est commun. Le signalement dit donc « ce livre est <i>dans la
    /// maison</i> », pas « vous l'avez acheté » — et c'est bien l'information utile avant
    /// d'acheter à nouveau.
    /// <para>
    /// Deux critères, comme pour les doublons du catalogue : un ISBN identique tranche seul (il
    /// désigne une édition précise), sinon il faut la même clé d'œuvre <b>et</b> un auteur
    /// commun. Le titre seul confondrait deux recueils homonymes.
    /// </para>
    /// <para>
    /// ⚠️ Le catalogue est chargé en entier, en projection minimale. C'est assumé à l'échelle
    /// d'un foyer et cohérent avec l'instantané hors-ligne, qui l'emporte déjà tout entier dans
    /// le navigateur. Le jour où la bibliothèque compterait des milliers de fiches, c'est ici
    /// qu'il faudrait dégrossir en SQL — pas renoncer au signalement.
    /// </para>
    /// </remarks>
    private async Task<Dictionary<int, int>> CorrespondancesAsync(
        IReadOnlyList<LivreSouhaite> souhaits, CancellationToken ct)
    {
        if (souhaits.Count == 0)
        {
            return [];
        }

        var livres = await db.Livres
            .AsNoTracking()
            .Select(l => new
            {
                l.Id,
                l.Titre,
                l.Isbn,
                Auteurs = l.Auteurs.OrderBy(la => la.Position).Select(la => la.Auteur!.Nom).ToList(),
            })
            .ToListAsync(ct);

        var parCle = livres
            .Select(l => (Cle: CleOeuvre.Cle(l.Titre), Livre: l))
            .Where(x => x.Cle.Length > 0)
            .GroupBy(x => x.Cle)
            .ToDictionary(g => g.Key, g => g.Select(x => x.Livre).ToList());

        var parIsbn = new Dictionary<string, int>();
        foreach (var livre in livres.Where(l => !string.IsNullOrWhiteSpace(l.Isbn)))
        {
            parIsbn.TryAdd(livre.Isbn!, livre.Id);
        }

        var correspondances = new Dictionary<int, int>();

        foreach (var souhait in souhaits)
        {
            if (!string.IsNullOrWhiteSpace(souhait.Isbn) && parIsbn.TryGetValue(souhait.Isbn, out var idIsbn))
            {
                correspondances[souhait.Id] = idIsbn;
                continue;
            }

            // TitreNormalise EST la clé d'œuvre pour une envie (voir LivreSouhaite) : rien à
            // recalculer de ce côté-là.
            if (!parCle.TryGetValue(souhait.TitreNormalise, out var homonymes))
            {
                continue;
            }

            var trouve = homonymes.FirstOrDefault(
                l => RapprochementAuteurs.PartagentUnAuteur(l.Auteurs, [souhait.Auteur]));

            if (trouve is not null)
            {
                correspondances[souhait.Id] = trouve.Id;
            }
        }

        return correspondances;
    }

    /// <summary>
    /// Identifiant du livre correspondant, ou <c>null</c>.
    /// </summary>
    /// <remarks>
    /// ⚠️ Surtout pas <c>GetValueOrDefault</c> : sur un dictionnaire de <c>int</c>, l'absence
    /// rend <b>zéro</b>, pas <c>null</c> — et toute envie se serait dite possédée. Le défaut a
    /// été pris par les tests, pas par le compilateur.
    /// </remarks>
    private static int? Correspondant(IReadOnlyDictionary<int, int> correspondances, int souhaitId) =>
        correspondances.TryGetValue(souhaitId, out var livreId) ? livreId : null;

    private static string? Vide(string? valeur) =>
        string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();

    /// <summary>
    /// Projection vers le DTO. <b>Sans le champ <c>Utilisateur</c></b> : il ne sort jamais de
    /// l'API, pour qu'aucun client ne puisse croire qu'il désigne de qui il parle.
    /// </summary>
    internal static SouhaitDto Projeter(LivreSouhaite souhait, int? livreId = null) => new()
    {
        Id = souhait.Id,
        Titre = souhait.Titre,
        Auteur = souhait.Auteur,
        Editeur = souhait.Editeur,
        Annee = souhait.Annee,
        Isbn = souhait.Isbn,

        // ⚠️ La couverture se déduit de l'ISBN quand elle n'a pas été fournie.
        //
        // Constaté en usage : aucune vignette dans la liste d'envies. La cause n'était pas le
        // composant Couverture mais le chemin d'ajout — seul l'écran « ajouter une envie »
        // remplissait CoverUrl, alors que la plupart des envies arrivent par la bibliographie
        // d'un auteur ou par un tome manquant d'une série, qui transmettent bien un ISBN mais
        // aucune image.
        //
        // Le repli est posé ICI, à la lecture, et non à l'écriture : il vaut alors aussi pour
        // les envies DÉJÀ enregistrées, sans migration ni rattrapage. La règle « sans ISBN,
        // pas de couverture, et on n'en invente pas » est intacte — on n'invente rien, on
        // applique la formule OpenLibrary habituelle à un ISBN qu'on possède déjà.
        CoverUrl = souhait.CoverUrl
                   ?? (souhait.Isbn is null ? null : IsbnUtils.UrlCouverture(souhait.Isbn)),
        Note = souhait.Note,
        DateAjout = souhait.DateAjout,
        Possede = livreId is not null,
        LivreId = livreId,
    };
}
