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; } }