using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Data;
///
/// Remet en cohérence les colonnes normalisées, et réunit les auteurs que cette normalisation
/// révèle identiques.
///
///
/// Pourquoi ce passage existe. La migration a recopié les anciennes données avec les
/// moyens du bord : lower() de SQLite ne retire pas les accents, donc « Émile Zola » et
/// « emile zola » y sont restés deux auteurs distincts et les titres normalisés sont approchés.
/// Seul C# sait appliquer les vraies règles.
///
/// Le passage est idempotent et ne touche que ce qui diffère : il ne coûte rien au
/// démarrage suivant. Il sert aussi de filet si les règles de normalisation changent un jour —
/// le prochain démarrage rattrape la base sans migration à écrire.
///
///
public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger logger)
{
public async Task ExecuterAsync(CancellationToken ct = default)
{
var titres = await RenormaliserTitresAsync(ct);
var (auteursCorriges, auteursFusionnes) = await RenormaliserAuteursAsync(ct);
var autres = await RenormaliserLesAutresTablesAsync(ct);
if (titres + auteursCorriges + auteursFusionnes + autres > 0)
{
logger.LogInformation(
"Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, "
+ "{Fusions} fusionné(s), {Autres} autre(s) ligne(s).",
titres, auteursCorriges, auteursFusionnes, autres);
}
}
///
/// Remet à jour les colonnes normalisées des tables apparues après ce service.
///
///
/// Envies, séries, revues et numéros calculent leurs formes à l'écriture : rien ne
/// les rattraperait si la règle de normalisation changeait — et elle a changé le 2026-08-19,
/// avec le développement des ligatures œ et æ. Sans ce passage, une envie
/// enregistrée avant serait restée introuvable par sa propre clé.
///
/// ⚠️ Trois de ces colonnes portent une unicité. Une règle plus large peut donc
/// rendre identiques deux lignes qui ne l'étaient pas (« L'Œuvre » et « L'oeuvre »), et
/// l'écriture échouerait au démarrage — l'application ne se lancerait plus. La
/// collision est donc détectée avant d'écrire : la ligne la plus ancienne prend la nouvelle
/// clé, les suivantes gardent l'ancienne et sont journalisées. Mal normalisée est
/// infiniment préférable à supprimée, ou à un serveur qui ne démarre pas.
///
///
private async Task RenormaliserLesAutresTablesAsync(CancellationToken ct)
{
var corriges = 0;
var souhaits = await db.LivresSouhaites.OrderBy(s => s.Id).ToListAsync(ct);
corriges += Renormaliser(
souhaits,
s => Cle(s.Utilisateur, s.TitreNormalise, s.AuteurNormalise),
s => Cle(s.Utilisateur, CleOeuvre.Cle(s.Titre), RapprochementAuteurs.Cle(s.Auteur)),
s => s.RecalculerFormes(),
s => $"l'envie « {s.Titre} »");
var series = await db.Series.OrderBy(s => s.Id).ToListAsync(ct);
corriges += Renormaliser(
series,
s => s.TitreNormalise,
s => NormalisationTexte.Normaliser(s.Titre),
s => s.RecalculerFormes(),
s => $"la série « {s.Titre} »");
var revues = await db.Revues.OrderBy(r => r.Id).ToListAsync(ct);
corriges += Renormaliser(
revues,
r => r.TitreNormalise,
r => NormalisationTexte.Normaliser(r.Titre),
r => r.RecalculerFormes(),
r => $"la revue « {r.Titre} »");
var numeros = await db.NumerosRevue.OrderBy(n => n.Id).ToListAsync(ct);
corriges += Renormaliser(
numeros,
n => Cle(n.RevueId.ToString(), n.NumeroNormalise),
n => Cle(n.RevueId.ToString(), NormalisationTexte.Normaliser(n.Numero)),
n => n.RecalculerFormes(),
n => $"le numéro {n.Numero}");
if (corriges > 0)
{
await db.SaveChangesAsync(ct);
}
return corriges;
}
///
/// Recalcule les formes d'un ensemble de lignes en refusant toute collision d'unicité.
///
///
/// Le jeu des clés occupées part de l'état actuel de la base, et chaque ligne libère
/// la sienne avant de réserver la nouvelle. Sans cette libération, une ligne inchangée
/// bloquerait sa propre mise à jour ; sans le jeu initial, une ligne traitée tôt prendrait
/// la clé d'une ligne non encore traitée, et l'écriture échouerait.
///
private int Renormaliser(
IReadOnlyList lignes,
Func cleActuelle,
Func cleVoulue,
Action appliquer,
Func nommer)
{
var occupees = new HashSet(lignes.Select(cleActuelle), StringComparer.Ordinal);
var corriges = 0;
foreach (var ligne in lignes)
{
var actuelle = cleActuelle(ligne);
var voulue = cleVoulue(ligne);
if (actuelle == voulue)
{
continue;
}
occupees.Remove(actuelle);
if (!occupees.Add(voulue))
{
// Deux lignes que la nouvelle règle rend identiques. On garde les deux, en
// laissant la seconde sur son ancienne clé : c'est visible, réparable à la main,
// et surtout ça ne coûte pas le démarrage du serveur.
occupees.Add(actuelle);
logger.LogWarning(
"Renormalisation : {Ligne} garde son ancienne forme, la nouvelle est déjà prise.",
nommer(ligne));
continue;
}
appliquer(ligne);
corriges++;
}
return corriges;
}
/// Clé composite comparable, les composants ne pouvant pas contenir de NUL.
private static string Cle(params string?[] composants) => string.Join('\0', composants);
private async Task RenormaliserTitresAsync(CancellationToken ct)
{
var livres = await db.Livres.ToListAsync(ct);
var corriges = 0;
foreach (var livre in livres)
{
var attendu = NormalisationTexte.Normaliser(livre.Titre);
if (livre.TitreNormalise == attendu)
{
continue;
}
livre.TitreNormalise = attendu;
corriges++;
}
if (corriges > 0)
{
await db.SaveChangesAsync(ct);
}
return corriges;
}
private async Task<(int Corriges, int Fusionnes)> RenormaliserAuteursAsync(CancellationToken ct)
{
var auteurs = await db.Auteurs.Include(a => a.Livres).OrderBy(a => a.Id).ToListAsync(ct);
var corriges = 0;
var fusionnes = 0;
// Premier tour : la clé de regroupement, qui couvre casse, accents et ordre des mots.
// Le premier auteur rencontré pour une clé donnée est celui qui reste ; les suivants
// sont absorbés. L'ordre par identifiant rend le résultat reproductible.
var parCle = new Dictionary(StringComparer.Ordinal);
var conserves = new List();
foreach (var auteur in auteurs)
{
var cle = RapprochementAuteurs.Cle(auteur.Nom);
var nomNormalise = NormalisationTexte.Normaliser(auteur.Nom);
if (parCle.TryGetValue(cle, out var conserve))
{
// Deux fiches que la migration n'a pas pu rapprocher (accents) et que les vraies
// règles réunissent. C'est une variante sûre : la fusion est automatique.
Absorber(conserve, auteur);
fusionnes++;
continue;
}
parCle[cle] = auteur;
conserves.Add(auteur);
if (auteur.CleRegroupement == cle && auteur.NomNormalise == nomNormalise)
{
continue;
}
auteur.CleRegroupement = cle;
auteur.NomNormalise = nomNormalise;
corriges++;
}
// Second tour : les initiales abrégées, que la clé ne sait pas capturer
// (« P.F. Hamilton » et « Peter F. Hamilton »). Sans ce tour, une base héritée
// resterait éclatée là où une saisie neuve aurait été réunie d'emblée — deux
// comportements pour une même règle.
foreach (var auteur in conserves.ToList())
{
if (db.Entry(auteur).State == EntityState.Deleted)
{
continue;
}
var jumeau = conserves.FirstOrDefault(autre =>
!ReferenceEquals(autre, auteur)
&& db.Entry(autre).State != EntityState.Deleted
&& RapprochementAuteurs.SontLeMemeAuteur(auteur.Nom, autre.Nom));
if (jumeau is null)
{
continue;
}
// Le nom le plus complet l'emporte : « Peter F. Hamilton » plutôt que « P.F. ».
var (conserve, absorbe) =
NomsDeveloppes(auteur.Nom) >= NomsDeveloppes(jumeau.Nom)
? (auteur, jumeau)
: (jumeau, auteur);
Absorber(conserve, absorbe);
conserves.Remove(absorbe);
fusionnes++;
}
if (corriges > 0 || fusionnes > 0)
{
await db.SaveChangesAsync(ct);
}
return (corriges, fusionnes);
}
/// Reporte les livres de sur .
private void Absorber(Auteur conserve, Auteur absorbe)
{
// La fiche conservée est celle au plus petit identifiant, or cet ordre est un hasard de
// la migration. On rattrape sur le nom d'affichage : sans cet arbitrage, la bibliothèque
// perdrait ses accents au profit des saisies bâclées.
if (QualiteAffichage(absorbe.Nom) > QualiteAffichage(conserve.Nom))
{
conserve.Nom = absorbe.Nom;
conserve.NomNormalise = NormalisationTexte.Normaliser(absorbe.Nom);
conserve.CleRegroupement = RapprochementAuteurs.Cle(absorbe.Nom);
}
var dejaLies = conserve.Livres.Select(l => l.LivreId).ToHashSet();
foreach (var lien in absorbe.Livres.ToList())
{
db.LivreAuteurs.Remove(lien);
// Un livre déjà signé par la fiche conservée ne doit pas recevoir un second lien :
// la clé primaire est (LivreId, AuteurId).
if (dejaLies.Add(lien.LivreId))
{
db.LivreAuteurs.Add(new LivreAuteur
{
LivreId = lien.LivreId,
AuteurId = conserve.Id,
Position = lien.Position,
});
}
}
db.Auteurs.Remove(absorbe);
}
///
/// Note grossière d'un nom en tant que nom d'affichage, pour départager des variantes
/// équivalentes.
///
///
/// Trois signaux, dans cet ordre d'importance : la forme inversée « ZOLA, Émile » est une
/// convention de catalogue, pas quelque chose qu'on veut lire dans une liste ; les capitales
/// intégrales sont une saisie négligée ; les accents, eux, sont de l'information qu'on ne
/// veut pas perdre. Le résultat vise « Émile Zola ».
///
private static int QualiteAffichage(string nom)
{
var note = nom.Count(c => char.IsLetter(c) && c > 127) * 2;
if (nom.Contains(','))
{
note -= 3;
}
note -= nom
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Count(mot => mot.Length > 1 && mot.Where(char.IsLetter).All(char.IsUpper));
return note;
}
/// Nombre de mots qui ne sont pas de simples initiales.
private static int NomsDeveloppes(string nom) =>
NormalisationTexte.Mots(nom).Count(mot => mot.Length > 1);
}