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

namespace MaBibli.Api.Services.Catalogue;

/// <summary>Issue d'une écriture : soit le livre, soit un message d'erreur destiné à l'utilisateur.</summary>
public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur)
{
    public static ResultatEcriture Ok(LivreDto livre) => new(livre, null);

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

    /// <summary>Ni livre ni erreur : la ressource demandée n'existe pas.</summary>
    public static readonly ResultatEcriture Introuvable = new(null, null);

    public bool EstIntrouvable => Livre is null && Erreur is null;
}

public interface IServiceCatalogue
{
    Task<IReadOnlyList<LivreDto>> ListerAsync(
        CritereLivres criteres, string? utilisateur, CancellationToken ct = default);

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

    Task<ResultatEcriture> CreerAsync(
        EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);

    Task<ResultatEcriture> ModifierAsync(
        int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);

    Task<LivreDto?> ChangerStatutAsync(
        int id, Statut? statut, string? utilisateur, CancellationToken ct = default);

    Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
}

/// <summary>
/// CRUD du catalogue de livres — physiques et numériques confondus, distingués par
/// <see cref="Format"/>. Il n'y a qu'un seul catalogue.
/// </summary>
/// <remarks>
/// <b>Le paramètre <c>utilisateur</c> ne restreint jamais la liste des livres.</b> Il sert
/// uniquement à savoir de quel statut de lecture on parle : la bibliothèque reste commune, seule
/// la lecture est personnelle.
/// </remarks>
public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteurs) : IServiceCatalogue
{
    public async Task<IReadOnlyList<LivreDto>> ListerAsync(
        CritereLivres criteres, string? utilisateur, CancellationToken ct = default)
    {
        // AsNoTracking : lecture pure. Et surtout : AUCUN filtre sur AjoutePar,
        // la bibliothèque est commune à tout le foyer (CLAUDE.md).
        var requete = FiltreLivres.Appliquer(
            db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur),
            criteres,
            utilisateur);

        var livres = await requete.ToListAsync(ct);

        var ids = livres.Select(l => l.Id).ToList();
        var statuts = await StatutsAsync(ids, utilisateur, ct);
        var prets = await PretsOuvertsAsync(ids, ct);

        return livres
            .Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id), prets.GetValueOrDefault(l.Id)))
            .ToList();
    }

    public async Task<LivreDto?> ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default)
    {
        var livre = await db.Livres
            .AsNoTracking()
            .Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
            .FirstOrDefaultAsync(l => l.Id == id, ct);

        if (livre is null)
        {
            return null;
        }

        var statuts = await StatutsAsync([id], utilisateur, ct);
        var prets = await PretsOuvertsAsync([id], ct);
        return Projeter(livre, statuts.GetValueOrDefault(id), prets.GetValueOrDefault(id));
    }

    public async Task<ResultatEcriture> CreerAsync(
        EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
    {
        if (Valider(saisie, out var isbn, out var erreur) is false)
        {
            return ResultatEcriture.Invalide(erreur!);
        }

        var livre = new Livre
        {
            Isbn = isbn,
            Titre = saisie.Titre,
            Editeur = Vide(saisie.Editeur),
            Format = saisie.Format,
            CoverUrl = Vide(saisie.CoverUrl),
            DateAjout = DateTime.UtcNow,

            // Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client.
            AjoutePar = utilisateur,
        };

        livre.RecalculerFormes();
        db.Livres.Add(livre);

        await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
        await db.SaveChangesAsync(ct);

        // Le statut n'est écrit que si l'utilisateur en a explicitement posé un : un livre sans
        // ligne est « non commencé », et c'est le cas de départ le plus fréquent.
        await AppliquerStatutAsync(livre.Id, saisie.Statut, utilisateur, ct);

        return ResultatEcriture.Ok((await ObtenirAsync(livre.Id, utilisateur, ct))!);
    }

    public async Task<ResultatEcriture> ModifierAsync(
        int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
    {
        var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct);
        if (livre is null)
        {
            return ResultatEcriture.Introuvable;
        }

        if (Valider(saisie, out var isbn, out var erreur) is false)
        {
            return ResultatEcriture.Invalide(erreur!);
        }

        livre.Isbn = isbn;
        livre.Titre = saisie.Titre;
        livre.Editeur = Vide(saisie.Editeur);
        livre.Format = saisie.Format;
        livre.CoverUrl = Vide(saisie.CoverUrl);
        livre.RecalculerFormes();

        // DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie
        // d'origine, pas des champs éditables.

        await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
        await db.SaveChangesAsync(ct);

        // Le statut modifié est celui de la personne qui édite, pas de celle qui a saisi le livre.
        await AppliquerStatutAsync(livre.Id, saisie.Statut, utilisateur, ct);
        await auteurs.SupprimerOrphelinsAsync(ct);

        return ResultatEcriture.Ok((await ObtenirAsync(livre.Id, utilisateur, ct))!);
    }

    public async Task<LivreDto?> ChangerStatutAsync(
        int id, Statut? statut, string? utilisateur, CancellationToken ct = default)
    {
        if (!await db.Livres.AnyAsync(l => l.Id == id, ct))
        {
            return null;
        }

        await AppliquerStatutAsync(id, statut, utilisateur, ct);
        return await ObtenirAsync(id, utilisateur, ct);
    }

    public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default)
    {
        var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct);
        if (livre is null)
        {
            return false;
        }

        // Les liens vers les auteurs partent en cascade, mais les fiches auteur restent : elles
        // sont peut-être partagées avec d'autres livres. Le nettoyage d'après tranche.
        db.Livres.Remove(livre);
        await db.SaveChangesAsync(ct);
        await auteurs.SupprimerOrphelinsAsync(ct);
        return true;
    }

    /// <summary>Statut de <paramref name="utilisateur"/> pour chacun des livres demandés.</summary>
    private async Task<Dictionary<int, Statut?>> StatutsAsync(
        IReadOnlyList<int> livreIds, string? utilisateur, CancellationToken ct)
    {
        // Sans identité, personne n'a de statut : on ne montre surtout pas celui d'un autre.
        if (utilisateur is null || livreIds.Count == 0)
        {
            return [];
        }

        // Le dictionnaire est volontairement typé « Statut? » : sans ça, l'absence de ligne
        // ressortirait comme la valeur 0 de l'énumération — c'est-à-dire « À lire » — et
        // « non commencé » deviendrait indiscernable d'un choix explicite.
        return await db.StatutsLecture
            .AsNoTracking()
            .Where(s => s.Utilisateur == utilisateur && livreIds.Contains(s.LivreId))
            .ToDictionaryAsync(s => s.LivreId, s => (Statut?)s.Statut, ct);
    }

    /// <summary>
    /// Prêt en cours de chacun des livres demandés, quand il y en a un.
    /// </summary>
    /// <remarks>
    /// <b>Aucun paramètre « utilisateur » ici, volontairement</b> : contrairement au statut de
    /// lecture, un livre absent l'est pour tout le foyer. La question « où est ce livre ? » a une
    /// seule réponse, la même pour tout le monde.
    /// <para>
    /// L'index unique partiel sur <c>Prets</c> garantit qu'il n'y a qu'un prêt ouvert par livre :
    /// ce dictionnaire ne peut donc pas avoir à départager deux candidats.
    /// </para>
    /// </remarks>
    private async Task<Dictionary<int, Pret>> PretsOuvertsAsync(
        IReadOnlyList<int> livreIds, CancellationToken ct)
    {
        if (livreIds.Count == 0)
        {
            return [];
        }

        return await db.Prets
            .AsNoTracking()
            .Where(p => p.DateRetour == null && livreIds.Contains(p.LivreId))
            .ToDictionaryAsync(p => p.LivreId, ct);
    }

    /// <summary>
    /// Pose, met à jour ou retire le statut de lecture d'une personne pour un livre.
    /// </summary>
    private async Task AppliquerStatutAsync(
        int livreId, Statut? statut, string? utilisateur, CancellationToken ct)
    {
        if (utilisateur is null)
        {
            // Aucune identité : on n'invente pas de propriétaire pour ce statut. Le livre reste
            // enregistré, simplement sans lecture rattachée à personne.
            return;
        }

        var ligne = await db.StatutsLecture
            .FirstOrDefaultAsync(s => s.LivreId == livreId && s.Utilisateur == utilisateur, ct);

        if (statut is null)
        {
            if (ligne is not null)
            {
                // Retour à « non commencé » : on supprime la ligne au lieu d'inventer une
                // quatrième valeur d'énumération qui aurait fallu migrer.
                db.StatutsLecture.Remove(ligne);
                await db.SaveChangesAsync(ct);
            }

            return;
        }

        if (ligne is null)
        {
            db.StatutsLecture.Add(new StatutLecture
            {
                LivreId = livreId,
                Utilisateur = utilisateur,
                Statut = statut.Value,
                DateMaj = DateTime.UtcNow,
            });
        }
        else
        {
            ligne.Statut = statut.Value;
            ligne.DateMaj = DateTime.UtcNow;
        }

        await db.SaveChangesAsync(ct);
    }

    /// <summary>Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre.</summary>
    private async Task RattacherAuteursAsync(
        Livre livre, IEnumerable<string> noms, CancellationToken ct)
    {
        var resolus = await auteurs.ResoudreAsync(noms, ct);

        // Les liens existants sont retirés puis reconstruits : c'est la façon la plus simple de
        // gérer à la fois l'ajout, le retrait et le réordonnancement des auteurs.
        if (livre.Auteurs.Count > 0)
        {
            db.LivreAuteurs.RemoveRange(livre.Auteurs);
            livre.Auteurs.Clear();
        }

        for (var i = 0; i < resolus.Count; i++)
        {
            livre.Auteurs.Add(new LivreAuteur { Auteur = resolus[i], Position = i });
        }
    }

    /// <summary>
    /// Contrôles minimaux : un titre, et un ISBN qui tienne debout s'il est fourni.
    /// </summary>
    /// <remarks>
    /// L'ISBN est facultatif (livres anciens, tirages sans ISBN) mais, s'il est saisi, il doit
    /// être valide : un ISBN faux est pire que pas d'ISBN, il empêcherait tout rapprochement
    /// futur avec les catalogues.
    /// </remarks>
    private static bool Valider(EnregistrementLivre saisie, out string? isbn, out string? erreur)
    {
        isbn = null;
        erreur = null;

        if (string.IsNullOrWhiteSpace(saisie.Titre))
        {
            erreur = "Le titre est obligatoire.";
            return false;
        }

        if (!string.IsNullOrWhiteSpace(saisie.Isbn))
        {
            var normalise = IsbnUtils.Normaliser(saisie.Isbn);
            if (!IsbnUtils.EstValide(normalise))
            {
                erreur = $"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si le livre n'en a pas.";
                return false;
            }

            isbn = normalise;
        }

        return true;
    }

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

    private static LivreDto Projeter(Livre livre, Statut? statut, Pret? pretOuvert = null) => new()
    {
        Id = livre.Id,
        Isbn = livre.Isbn,
        Titre = livre.Titre,
        Auteurs = livre.Auteurs
            .OrderBy(la => la.Position)
            .Where(la => la.Auteur is not null)
            .Select(la => new AuteurDto { Id = la.Auteur!.Id, Nom = la.Auteur.Nom })
            .ToList(),
        Editeur = livre.Editeur,
        Format = livre.Format,
        Statut = statut,
        CoverUrl = livre.CoverUrl,
        DateAjout = livre.DateAjout,
        AjoutePar = livre.AjoutePar,
        PreteA = pretOuvert?.Emprunteur,
        PreteDepuis = pretOuvert?.DatePret,
    };
}
