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

namespace MaBibli.Api.Services.Series;

/// <summary>Issue d'une écriture sur une série : la série, ou un message pour l'utilisateur.</summary>
public readonly record struct ResultatSerie(SerieDto? Serie, string? Erreur)
{
    public static ResultatSerie Ok(SerieDto serie) => new(serie, null);

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

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

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

public interface IServiceSeries
{
    Task<IReadOnlyList<SerieDto>> ListerAsync(CancellationToken ct = default);

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

    Task<ResultatSerie> ModifierAsync(
        int id, EnregistrementSerie saisie, CancellationToken ct = default);

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

    Task<ResultatSerie> AjouterElementAsync(
        int serieId, AjoutElementSerie saisie, CancellationToken ct = default);

    Task<ResultatSerie> RattacherLivreAsync(int elementId, int? livreId, CancellationToken ct = default);

    Task<bool> RetirerElementAsync(int elementId, CancellationToken ct = default);

    Task<ResultatSerie> ReordonnerAsync(
        int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default);

    Task<ResultatSerie> ReordonnerSousSeriesAsync(
        int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default);

    /// <summary>Bascule un tome manquant vers la liste d'envies <b>de l'appelant</b>.</summary>
    Task<ResultatSouhait> MettreEnEnviesAsync(
        int elementId, string? utilisateur, CancellationToken ct = default);
}

/// <summary>
/// Sagas, cycles et séries : ce qui se lit dans un ordre, et ce qui manque pour le lire.
/// </summary>
/// <remarks>
/// ⚠️ <b>Portée COMMUNE au foyer</b>, comme le catalogue et les prêts. Aucune méthode ne prend
/// d'utilisateur pour lire ou écrire une série — l'ordre de lecture d'une saga ne change pas
/// selon qui regarde. La seule exception est <see cref="MettreEnEnviesAsync"/>, qui écrit dans
/// une liste d'envies, et qui est donc <b>obligée</b> de savoir de qui elle parle : c'est là que
/// les deux portées se rencontrent, et nulle part ailleurs.
/// </remarks>
public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits) : IServiceSeries
{
    public async Task<IReadOnlyList<SerieDto>> ListerAsync(CancellationToken ct = default)
    {
        // AUCUN filtre sur AjoutePar : les séries sont communes, comme le catalogue.
        var series = await db.Series
            .AsNoTracking()
            .Include(s => s.Elements).ThenInclude(e => e.Livre).ThenInclude(l => l!.Auteurs)
            .ThenInclude(la => la.Auteur)
            .ToListAsync(ct);

        var livreIds = series
            .SelectMany(s => s.Elements)
            .Select(e => e.LivreId)
            .OfType<int>()
            .ToList();

        var prets = await PretsOuvertsAsync(livreIds, ct);

        return series
            .OrderBy(s => s.Position)
            .ThenBy(s => s.TitreNormalise, StringComparer.Ordinal)
            .Select(s => Projeter(s, prets))
            .ToList();
    }

    public async Task<ResultatSerie> CreerAsync(
        EnregistrementSerie saisie, string? utilisateur, CancellationToken ct = default)
    {
        var titre = saisie.Titre?.Trim();
        if (string.IsNullOrWhiteSpace(titre))
        {
            return ResultatSerie.Invalide("Le titre de la série est obligatoire.");
        }

        var normalise = NormalisationTexte.Normaliser(titre);
        if (await db.Series.AnyAsync(s => s.TitreNormalise == normalise, ct))
        {
            // Message plutôt qu'index unique brut : « UNIQUE constraint failed » ne veut rien
            // dire pour l'utilisateur. L'index reste là comme garantie de dernier recours.
            return ResultatSerie.Invalide($"La série « {titre} » existe déjà.");
        }

        if (saisie.SerieParenteId is { } parenteId
            && !await db.Series.AnyAsync(s => s.Id == parenteId, ct))
        {
            return ResultatSerie.Invalide("La série parente n'existe pas.");
        }

        var serie = new Serie
        {
            Titre = titre,
            SerieParenteId = saisie.SerieParenteId,
            DateAjout = DateTime.UtcNow,

            // Trace de saisie, jamais un cloisonnement — comme Livre.AjoutePar.
            AjoutePar = utilisateur,
        };

        serie.RecalculerFormes();
        serie.Position = await ProchainePositionAsync(saisie.SerieParenteId, ct);

        db.Series.Add(serie);
        await db.SaveChangesAsync(ct);

        return await RelireAsync(serie.Id, ct);
    }

    public async Task<ResultatSerie> ModifierAsync(
        int id, EnregistrementSerie saisie, CancellationToken ct = default)
    {
        var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id, ct);
        if (serie is null)
        {
            return ResultatSerie.Introuvable;
        }

        var titre = saisie.Titre?.Trim();
        if (string.IsNullOrWhiteSpace(titre))
        {
            return ResultatSerie.Invalide("Le titre de la série est obligatoire.");
        }

        var normalise = NormalisationTexte.Normaliser(titre);
        if (await db.Series.AnyAsync(s => s.Id != id && s.TitreNormalise == normalise, ct))
        {
            return ResultatSerie.Invalide($"La série « {titre} » existe déjà.");
        }

        if (saisie.SerieParenteId is { } parenteId)
        {
            if (!await db.Series.AnyAsync(s => s.Id == parenteId, ct))
            {
                return ResultatSerie.Invalide("La série parente n'existe pas.");
            }

            if (await EstDescendanteAsync(parenteId, id, ct))
            {
                // Sans ce garde-fou, une série deviendrait sa propre ancêtre : l'arbre se
                // refermerait sur lui-même et l'affichage boucherait à l'infini.
                return ResultatSerie.Invalide(
                    "Une série ne peut pas être rangée dans elle-même, ni dans l'une des siennes.");
            }
        }

        if (serie.SerieParenteId != saisie.SerieParenteId)
        {
            serie.SerieParenteId = saisie.SerieParenteId;
            serie.Position = await ProchainePositionAsync(saisie.SerieParenteId, ct);
        }

        serie.Titre = titre;
        serie.RecalculerFormes();

        await db.SaveChangesAsync(ct);
        return await RelireAsync(id, ct);
    }

    public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default)
    {
        var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id, ct);
        if (serie is null)
        {
            return false;
        }

        // Les éléments partent en cascade — ce ne sont que des positions. Les sous-séries, elles,
        // remontent à la racine (SetNull) : ce sont de vraies séries de vrais livres.
        db.Series.Remove(serie);
        await db.SaveChangesAsync(ct);
        return true;
    }

    public async Task<ResultatSerie> AjouterElementAsync(
        int serieId, AjoutElementSerie saisie, CancellationToken ct = default)
    {
        var serie = await db.Series.Include(s => s.Elements).FirstOrDefaultAsync(s => s.Id == serieId, ct);
        if (serie is null)
        {
            return ResultatSerie.Introuvable;
        }

        var titre = saisie.Titre?.Trim();

        if (saisie.LivreId is { } livreId)
        {
            var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == livreId, ct);
            if (livre is null)
            {
                return ResultatSerie.Invalide("Ce livre n'existe plus.");
            }

            if (serie.Elements.Any(e => e.LivreId == livreId))
            {
                return ResultatSerie.Invalide($"« {livre.Titre} » est déjà dans cette série.");
            }

            // Le titre du tome est copié du livre : il doit survivre à sa suppression, faute de
            // quoi la saga se retrouverait avec une place muette.
            titre = string.IsNullOrWhiteSpace(titre) ? livre.Titre : titre;
        }

        if (string.IsNullOrWhiteSpace(titre))
        {
            return ResultatSerie.Invalide("Le titre du tome est obligatoire.");
        }

        var element = new ElementSerie
        {
            SerieId = serieId,
            LivreId = saisie.LivreId,
            Titre = titre,
            Position = serie.Elements.Count == 0 ? 0 : serie.Elements.Max(e => e.Position) + 1,
        };

        element.RecalculerFormes();
        db.ElementsSerie.Add(element);
        await db.SaveChangesAsync(ct);

        return await RelireAsync(serieId, ct);
    }

    public async Task<ResultatSerie> RattacherLivreAsync(
        int elementId, int? livreId, CancellationToken ct = default)
    {
        var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct);
        if (element is null)
        {
            return ResultatSerie.Introuvable;
        }

        if (livreId is { } id)
        {
            var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id, ct);
            if (livre is null)
            {
                return ResultatSerie.Invalide("Ce livre n'existe plus.");
            }

            var deja = await db.ElementsSerie.AnyAsync(
                e => e.SerieId == element.SerieId && e.LivreId == id && e.Id != elementId, ct);

            if (deja)
            {
                return ResultatSerie.Invalide($"« {livre.Titre} » occupe déjà une place dans cette série.");
            }
        }

        // Le titre saisi est conservé : c'est celui du tome, pas celui de l'exemplaire. Détacher
        // un livre laisse donc un trou nommé, et non une ligne vide.
        element.LivreId = livreId;
        await db.SaveChangesAsync(ct);

        return await RelireAsync(element.SerieId, ct);
    }

    public async Task<bool> RetirerElementAsync(int elementId, CancellationToken ct = default)
    {
        var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct);
        if (element is null)
        {
            return false;
        }

        db.ElementsSerie.Remove(element);
        await db.SaveChangesAsync(ct);
        return true;
    }

    public async Task<ResultatSerie> ReordonnerAsync(
        int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default)
    {
        var elements = await db.ElementsSerie.Where(e => e.SerieId == serieId).ToListAsync(ct);
        if (elements.Count == 0)
        {
            return await db.Series.AnyAsync(s => s.Id == serieId, ct)
                ? await RelireAsync(serieId, ct)
                : ResultatSerie.Introuvable;
        }

        // Même prudence que pour la liste d'envies : on relit les éléments DE CETTE SÉRIE et on
        // ne se fie pas à ce que le client envoie. Un identifiant étranger est ignoré, et un
        // tome absent de l'ordre reçu est conservé à la suite — la liste du client peut être
        // périmée, et mal classé vaut infiniment mieux que disparu.
        var parId = elements.ToDictionary(e => e.Id);
        var position = 0;

        foreach (var id in idsOrdonnes.Distinct())
        {
            if (parId.Remove(id, out var element))
            {
                element.Position = position++;
            }
        }

        foreach (var oublie in parId.Values.OrderBy(e => e.Position).ThenBy(e => e.Id))
        {
            oublie.Position = position++;
        }

        await db.SaveChangesAsync(ct);
        return await RelireAsync(serieId, ct);
    }

    public async Task<ResultatSerie> ReordonnerSousSeriesAsync(
        int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default)
    {
        if (!await db.Series.AnyAsync(s => s.Id == serieId, ct))
        {
            return ResultatSerie.Introuvable;
        }

        var enfants = await db.Series
            .Where(s => s.SerieParenteId == serieId)
            .ToListAsync(ct);
        var parId = enfants.ToDictionary(s => s.Id);
        var position = 0;

        foreach (var id in idsOrdonnes.Distinct())
        {
            if (parId.Remove(id, out var enfant))
            {
                enfant.Position = position++;
            }
        }

        foreach (var oublie in parId.Values.OrderBy(s => s.Position).ThenBy(s => s.Id))
        {
            oublie.Position = position++;
        }

        await db.SaveChangesAsync(ct);
        return await RelireAsync(serieId, ct);
    }

    public async Task<ResultatSouhait> MettreEnEnviesAsync(
        int elementId, string? utilisateur, CancellationToken ct = default)
    {
        var element = await db.ElementsSerie
            .AsNoTracking()
            .FirstOrDefaultAsync(e => e.Id == elementId, ct);

        if (element is null)
        {
            return ResultatSouhait.Invalide("Ce tome n'existe plus.");
        }

        if (element.LivreId is not null)
        {
            // Souhaiter ce qu'on a déjà n'aurait aucun sens, et la liste d'envies le signalerait
            // aussitôt comme « déjà au catalogue ».
            return ResultatSouhait.Invalide($"« {element.Titre} » est déjà dans votre bibliothèque.");
        }

        // ⚠️ Ici et seulement ici, une série (commune) écrit dans une liste d'envies
        // (personnelle). L'envie est donc celle de l'APPELANT, jamais celle du foyer : deux
        // membres peuvent vouloir le même tome manquant, chacun dans sa liste.
        return await souhaits.AjouterAsync(
            new EnregistrementSouhait { Titre = element.Titre }, utilisateur, ct);
    }

    /// <summary>Prêts en cours des livres concernés. Communs au foyer, comme partout ailleurs.</summary>
    private async Task<Dictionary<int, string>> 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, p => p.Emprunteur, ct);
    }

    private async Task<int> ProchainePositionAsync(int? parenteId, CancellationToken ct)
    {
        var derniere = await db.Series
            .Where(s => s.SerieParenteId == parenteId)
            .Select(s => (int?)s.Position)
            .MaxAsync(ct);

        return (derniere ?? -1) + 1;
    }

    /// <summary><paramref name="candidate"/> est-elle <paramref name="serieId"/> ou l'une de ses filles ?</summary>
    private async Task<bool> EstDescendanteAsync(int candidate, int serieId, CancellationToken ct)
    {
        var courante = (int?)candidate;

        // Remontée bornée par le nombre de séries : même si une boucle existait déjà en base,
        // cette fonction s'arrête au lieu de tourner indéfiniment.
        var garde = await db.Series.CountAsync(ct) + 1;

        while (courante is { } id && garde-- > 0)
        {
            if (id == serieId)
            {
                return true;
            }

            courante = await db.Series
                .Where(s => s.Id == id)
                .Select(s => s.SerieParenteId)
                .FirstOrDefaultAsync(ct);
        }

        return false;
    }

    private async Task<ResultatSerie> RelireAsync(int serieId, CancellationToken ct)
    {
        var serie = await db.Series
            .AsNoTracking()
            .Include(s => s.Elements).ThenInclude(e => e.Livre).ThenInclude(l => l!.Auteurs)
            .ThenInclude(la => la.Auteur)
            .FirstOrDefaultAsync(s => s.Id == serieId, ct);

        if (serie is null)
        {
            return ResultatSerie.Introuvable;
        }

        var prets = await PretsOuvertsAsync(
            serie.Elements.Select(e => e.LivreId).OfType<int>().ToList(), ct);

        return ResultatSerie.Ok(Projeter(serie, prets));
    }

    private static SerieDto Projeter(Serie serie, IReadOnlyDictionary<int, string> prets) => new()
    {
        Id = serie.Id,
        Titre = serie.Titre,
        SerieParenteId = serie.SerieParenteId,
        Position = serie.Position,
        AjoutePar = serie.AjoutePar,
        Elements = serie.Elements
            .OrderBy(e => e.Position)
            .ThenBy(e => e.Id)
            .Select(e => new ElementSerieDto
            {
                Id = e.Id,
                Position = e.Position,

                // Le titre du livre prime quand il est là : c'est lui qui a pu être corrigé
                // depuis la saisie du tome. Le titre stocké reste le filet en cas de suppression.
                Titre = e.Livre?.Titre ?? e.Titre,
                LivreId = e.LivreId,
                Auteurs = e.Livre is null || e.Livre.Auteurs.Count == 0
                    ? null
                    : string.Join(", ", e.Livre.Auteurs
                        .OrderBy(la => la.Position)
                        .Select(la => la.Auteur?.Nom)
                        .Where(nom => !string.IsNullOrWhiteSpace(nom))),
                PreteA = e.LivreId is { } id && prets.TryGetValue(id, out var qui) ? qui : null,
            })
            .ToList(),
    };
}
