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

namespace MaBibli.Api.Services.Prets;

/// <summary>Issue d'une écriture de prêt : soit le prêt, soit un message destiné à l'utilisateur.</summary>
public readonly record struct ResultatPret(PretDto? Pret, string? Erreur)
{
    public static ResultatPret Ok(PretDto pret) => new(pret, null);

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

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

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

public interface IServicePrets
{
    /// <summary>Tous les livres actuellement sortis, du plus ancien prêt au plus récent.</summary>
    Task<IReadOnlyList<PretDto>> EnCoursAsync(CancellationToken ct = default);

    /// <summary>Historique complet d'un livre, du plus récent au plus ancien. <c>null</c> si le livre n'existe pas.</summary>
    Task<IReadOnlyList<PretDto>?> HistoriqueAsync(int livreId, CancellationToken ct = default);

    Task<ResultatPret> PreterAsync(int livreId, EnregistrementPret saisie, CancellationToken ct = default);

    Task<ResultatPret> RendreAsync(int pretId, RetourPret retour, CancellationToken ct = default);
}

/// <summary>
/// Gestion des prêts. Un prêt est <b>ouvert</b> tant que sa date de retour est nulle ; le rendre
/// ne supprime rien, il pose la date. C'est ce qui conserve l'historique.
/// </summary>
/// <remarks>
/// <b>Aucune lecture ni écriture ne filtre sur l'utilisateur courant.</b> La bibliothèque est
/// commune : tout le foyer doit voir qui a emprunté quoi, et n'importe qui doit pouvoir noter le
/// retour d'un livre qu'il a récupéré. C'est aussi pourquoi ce service ne reçoit pas d'identité
/// du tout — il n'a rien à en faire, et ne peut donc pas s'en servir par inadvertance.
/// </remarks>
public sealed class ServicePrets(MaBibliDbContext db) : IServicePrets
{
    /// <summary>
    /// Tolérance sur une date de prêt future.
    /// </summary>
    /// <remarks>
    /// Le client convertit une date locale en UTC avant de l'envoyer, donc « aujourd'hui » ne
    /// devrait jamais dépasser l'instant courant. Un décalage d'horloge d'un fuseau à l'autre
    /// suffirait pourtant à faire rejeter une saisie parfaitement légitime : une journée de marge
    /// absorbe ce cas sans laisser passer de vraie date future.
    /// </remarks>
    private static readonly TimeSpan MargeFutur = TimeSpan.FromDays(1);

    public async Task<IReadOnlyList<PretDto>> EnCoursAsync(CancellationToken ct = default)
    {
        var prets = await Requete()
            .Where(p => p.DateRetour == null)

            // Du plus ancien au plus récent : ce qu'on cherche dans cette vue, c'est le livre
            // parti depuis six mois qu'on avait oublié, pas celui prêté hier.
            .OrderBy(p => p.DatePret)
            .ThenBy(p => p.Id)
            .ToListAsync(ct);

        return prets.Select(Projeter).ToList();
    }

    public async Task<IReadOnlyList<PretDto>?> HistoriqueAsync(int livreId, CancellationToken ct = default)
    {
        // Distinguer « livre inconnu » de « livre jamais prêté » : le premier est un 404, le
        // second une liste vide parfaitement normale.
        if (!await db.Livres.AnyAsync(l => l.Id == livreId, ct))
        {
            return null;
        }

        var prets = await Requete()
            .Where(p => p.LivreId == livreId)
            .OrderByDescending(p => p.DatePret)
            .ThenByDescending(p => p.Id)
            .ToListAsync(ct);

        return prets.Select(Projeter).ToList();
    }

    public async Task<ResultatPret> PreterAsync(
        int livreId, EnregistrementPret saisie, CancellationToken ct = default)
    {
        var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == livreId, ct);
        if (livre is null)
        {
            return ResultatPret.Introuvable;
        }

        // Un ebook n'est qu'une fiche : il n'y a pas d'objet à confier à quelqu'un (CLAUDE.md).
        // Refusé côté serveur et pas seulement grisé dans l'interface, sans quoi la règle ne
        // vaudrait que pour ceux qui passent par l'écran.
        if (livre.Format == Format.Numerique)
        {
            return ResultatPret.Invalide(
                "Un livre numérique ne se prête pas : ce n'est qu'une fiche, il n'y a pas d'exemplaire à confier.");
        }

        var emprunteur = saisie.Emprunteur?.Trim();
        if (string.IsNullOrWhiteSpace(emprunteur))
        {
            return ResultatPret.Invalide("Indiquez à qui vous prêtez ce livre.");
        }

        var ouvert = await db.Prets
            .AsNoTracking()
            .FirstOrDefaultAsync(p => p.LivreId == livreId && p.DateRetour == null, ct);

        if (ouvert is not null)
        {
            return ResultatPret.Invalide(
                $"Ce livre est déjà prêté à {ouvert.Emprunteur}. Marquez-le rendu avant de le prêter à quelqu'un d'autre.");
        }

        var datePret = saisie.DatePret ?? DateTime.UtcNow;
        if (datePret > DateTime.UtcNow + MargeFutur)
        {
            return ResultatPret.Invalide("La date de prêt ne peut pas être dans le futur.");
        }

        var pret = new Pret
        {
            LivreId = livreId,
            Emprunteur = emprunteur,
            DatePret = datePret,
            DateRetour = null,
        };

        db.Prets.Add(pret);
        await db.SaveChangesAsync(ct);

        return ResultatPret.Ok((await ObtenirAsync(pret.Id, ct))!);
    }

    public async Task<ResultatPret> RendreAsync(
        int pretId, RetourPret retour, CancellationToken ct = default)
    {
        var pret = await db.Prets.FirstOrDefaultAsync(p => p.Id == pretId, ct);
        if (pret is null)
        {
            return ResultatPret.Introuvable;
        }

        if (pret.DateRetour is not null)
        {
            // Idempotent en apparence, mais on refuse quand même : réécrire la date écraserait
            // une information exacte par une approximative, et deux personnes qui rangent le même
            // livre ne doivent pas se marcher dessus en silence.
            return ResultatPret.Invalide("Ce prêt est déjà clos : le livre a été rendu.");
        }

        var dateRetour = retour.DateRetour ?? DateTime.UtcNow;

        if (dateRetour > DateTime.UtcNow + MargeFutur)
        {
            return ResultatPret.Invalide("La date de retour ne peut pas être dans le futur.");
        }

        if (dateRetour < pret.DatePret)
        {
            return ResultatPret.Invalide("Le livre ne peut pas être revenu avant d'être parti.");
        }

        pret.DateRetour = dateRetour;
        await db.SaveChangesAsync(ct);

        return ResultatPret.Ok((await ObtenirAsync(pret.Id, ct))!);
    }

    private async Task<PretDto?> ObtenirAsync(int pretId, CancellationToken ct)
    {
        var pret = await Requete().FirstOrDefaultAsync(p => p.Id == pretId, ct);
        return pret is null ? null : Projeter(pret);
    }

    private IQueryable<Pret> Requete() =>
        db.Prets.AsNoTracking().Include(p => p.Livre!).ThenInclude(l => l.Auteurs).ThenInclude(la => la.Auteur);

    /// <summary>
    /// Mise en forme d'un prêt déjà chargé — la projection se fait en mémoire, après la requête :
    /// la concaténation des auteurs n'a pas d'équivalent traduisible en SQL.
    /// </summary>
    /// <remarks>
    /// Le livre est recopié à plat plutôt qu'imbriqué : la vue des prêts en cours n'a besoin que
    /// de l'identifier visuellement, et un <see cref="LivreDto"/> complet traînerait derrière lui
    /// le statut de lecture, qui est personnel et n'a aucun sens ici.
    /// </remarks>
    private static PretDto Projeter(Pret pret)
    {
        var auteurs = pret.Livre?.Auteurs
            .OrderBy(la => la.Position)
            .Where(la => la.Auteur is not null)
            .Select(la => la.Auteur!.Nom)
            .ToList() ?? [];

        return new PretDto
        {
            Id = pret.Id,
            LivreId = pret.LivreId,
            Emprunteur = pret.Emprunteur,
            DatePret = pret.DatePret,
            DateRetour = pret.DateRetour,
            LivreTitre = pret.Livre?.Titre,
            LivreCoverUrl = pret.Livre?.CoverUrl,
            LivreAuteurs = auteurs.Count == 0 ? null : string.Join(", ", auteurs),
        };
    }
}
