using MaBibli.Shared.Dtos;
using MaBibli.Shared.Textes;
namespace MaBibli.Client.Services;
///
/// Une entrée du catalogue : un livre seul, ou les tomes d'une même série sous son nom.
///
///
/// ⚠️ Une entrée de série peut en contenir d'autres : un cycle est une série de séries
/// (Serie.SerieParenteId), et le catalogue le montre tel quel depuis le 2026-08-22 —
/// sans quoi « replier chaque niveau » n'aurait pas de niveaux à replier.
///
public sealed record EntreeCatalogue
{
/// La série qui coiffe le bloc, ou null pour un livre seul.
public SerieDto? Serie { get; init; }
/// Les livres rattachés directement à cette série ; un seul hors série.
public required IReadOnlyList Livres { get; init; }
/// Les séries contenues, quand ce bloc est un cycle. Vide sinon.
public IReadOnlyList SousEntrees { get; init; } = [];
public bool EstGroupe => Serie is not null;
///
/// Combien de livres du catalogue tiennent dans ce bloc, descendance comprise.
///
///
/// C'est ce que le bloc annonce, y compris replié : replier ne doit pas faire
/// perdre le compte de ce qu'on vient de cacher.
///
public int NombreLivres => Livres.Count + SousEntrees.Sum(e => e.NombreLivres);
}
///
/// Regroupe les livres du catalogue par série, en respectant les cycles.
///
///
/// Le bloc se place là où son premier tome serait tombé dans l'ordre du catalogue
/// (choisi avec l'utilisateur le 2026-08-22) : l'ordre général reste celui qu'on connaît, et
/// l'on retrouve « La Légende de Drizzt » entre « Dracula » et « Dune ». À l'intérieur, les
/// tomes suivent l'ordre de lecture, seul ordre qui ait un sens pour une saga — c'est
/// même la raison d'être de ElementSerie.Position, et les sous-séries d'un cycle
/// suivent le leur (Serie.Position), comme sur l'écran des séries.
///
/// ⚠️ Rien n'est jamais masqué ni déplacé hors de la liste. Un livre filtré reste absent,
/// un livre visible reste visible : le regroupement ne fait que rassembler, et l'écran offre de
/// le défaire. C'est la même règle que le grisage de la bibliographie — on marque, on ne cache
/// pas.
///
///
/// ⚠️ Un livre peut appartenir à plusieurs séries (le modèle l'autorise, sans unicité sur
/// LivreId seul). Il n'apparaît pourtant qu'une fois : le dupliquer ferait mentir le
/// compteur du catalogue et donnerait deux cartes du même exemplaire. La série retenue est la
/// première par ordre alphabétique — un critère explicable, à défaut d'être le bon dans
/// tous les cas ; sa fiche livre, elle, les montre toutes.
///
///
/// ⚠️ Seuls les niveaux qui portent quelque chose existent. Un cycle de cinq séries dont
/// une seule a un tome au catalogue ne produit pas quatre nœuds vides : on ne crée que la
/// chaîne qui mène aux livres présents. Un niveau vide serait une indentation qui ne se
/// replierait sur rien.
///
///
public static class GroupementCatalogue
{
public static IReadOnlyList Grouper(
IReadOnlyList livres, IReadOnlyList? series)
{
if (series is null || series.Count == 0)
{
return [.. livres.Select(l => new EntreeCatalogue { Livres = [l] })];
}
var place = PlaceDesLivres(series);
var parId = series.ToDictionary(s => s.Id);
// Les blocs s'accumulent dans le brouillon de leur RACINE, créé à la position du premier
// livre rencontré sous elle : c'est ce qui range le bloc là où l'ordre du catalogue
// l'attend. Les nœuds intermédiaires, eux, se créent en chemin.
var brouillons = new List();
var noeuds = new Dictionary();
foreach (var livre in livres)
{
if (!place.TryGetValue(livre.Id, out var appartenance))
{
brouillons.Add(new Noeud(null) { Livres = { (0, livre) } });
continue;
}
var noeud = Descendre(Ascendance(appartenance.Serie, parId), noeuds, brouillons);
noeud.Livres.Add((appartenance.Position, livre));
}
return [.. brouillons.Select(Materialiser)];
}
/// Un bloc en cours de construction : ses tomes directs et ses sous-blocs.
private sealed class Noeud(SerieDto? serie)
{
public SerieDto? Serie { get; } = serie;
public List<(int Position, LivreDto Livre)> Livres { get; } = [];
public List Enfants { get; } = [];
}
///
/// La chaîne de la racine jusqu'à , cycle compris.
///
///
/// ⚠️ La remontée est bornée par le nombre de séries, comme celle du serveur : une
/// boucle résiduelle en base ferait sinon tourner cette fonction sans fin. Mieux vaut un
/// bloc rangé un cran trop bas qu'un catalogue qui ne s'affiche plus.
///
private static List Ascendance(SerieDto serie, Dictionary parId)
{
var chaine = new List { serie };
var vues = new HashSet { serie.Id };
var courante = serie;
while (courante.SerieParenteId is { } parenteId
&& parId.TryGetValue(parenteId, out var parente)
&& vues.Add(parente.Id)
&& chaine.Count <= parId.Count)
{
chaine.Add(parente);
courante = parente;
}
chaine.Reverse();
return chaine;
}
/// Retrouve — ou crée — le nœud de chaque série de la chaîne, et rend le dernier.
private static Noeud Descendre(
List chaine, Dictionary noeuds, List brouillons)
{
Noeud? parent = null;
foreach (var serie in chaine)
{
if (!noeuds.TryGetValue(serie.Id, out var noeud))
{
noeud = new Noeud(serie);
noeuds[serie.Id] = noeud;
if (parent is null)
{
brouillons.Add(noeud);
}
else
{
parent.Enfants.Add(noeud);
}
}
parent = noeud;
}
return parent!;
}
private static EntreeCatalogue Materialiser(Noeud noeud) => new()
{
Serie = noeud.Serie,
Livres =
[
.. noeud.Livres
.OrderBy(t => t.Position)
.ThenBy(t => t.Livre.Id)
.Select(t => t.Livre),
],
// Les sous-séries d'abord, puis les tomes rattachés directement au cycle : c'est la
// disposition de l'écran d'une série, et deux écrans ne doivent pas en donner deux.
SousEntrees =
[
.. noeud.Enfants
.OrderBy(e => e.Serie!.Position)
.ThenBy(e => e.Serie!.Id)
.Select(Materialiser),
],
};
///
/// À quelle série — et à quelle place — appartient chaque livre rattaché.
///
///
/// ⚠️ Les séries sont parcourues dans l'ordre alphabétique de leur titre normalisé, et le
/// TryAdd garde donc la première : sans cet ordre, la série retenue pour un
/// livre rattaché deux fois dépendrait de l'ordre où l'API rend les séries, c'est-à-dire de
/// rien de compréhensible.
///
private static Dictionary PlaceDesLivres(
IReadOnlyList series)
{
var place = new Dictionary();
foreach (var serie in series
.OrderBy(s => NormalisationTexte.Normaliser(s.Titre), StringComparer.Ordinal)
.ThenBy(s => s.Id))
{
foreach (var element in serie.Elements)
{
if (element.LivreId is { } livreId)
{
place.TryAdd(livreId, (serie, element.Position));
}
}
}
return place;
}
}