using MaBibli.Api.Data;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Catalogue;
public interface IServiceAuteurs
{
///
/// Convertit des noms saisis en fiches auteur, en réutilisant l'existant quand c'est sûr.
/// Les nouvelles fiches sont ajoutées au contexte mais pas enregistrées.
///
Task> ResoudreAsync(IEnumerable noms, CancellationToken ct = default);
Task> ListerAsync(CancellationToken ct = default);
Task ObtenirAsync(int id, CancellationToken ct = default);
/// Renomme une fiche, ou signale la fiche cible à fusionner.
Task RenommerAsync(
int id, string nom, bool confirmerFusion = false, CancellationToken ct = default);
/// Rapprochements ambigus à soumettre à l'utilisateur, refus déjà écartés.
Task> SuggestionsAsync(CancellationToken ct = default);
/// Fusionne source dans cible. source disparaît.
Task FusionnerAsync(int sourceId, int cibleId, CancellationToken ct = default);
/// Mémorise que ces deux auteurs sont bien deux personnes différentes.
Task RefuserAsync(int sourceId, int cibleId, CancellationToken ct = default);
/// Supprime les auteurs qui ne sont plus rattachés à aucun livre.
Task SupprimerOrphelinsAsync(CancellationToken ct = default);
}
///
/// Gestion de la table des auteurs : résolution des noms, regroupement et fusions.
///
///
/// Tout se joue sur une table volontairement petite. Une bibliothèque de foyer compte
/// quelques centaines d'auteurs : les comparaisons mot à mot (initiales, ordre inversé) sont
/// faites en mémoire, parce qu'elles ne s'expriment pas en SQL. Le premier essai — la clé de
/// regroupement — reste, lui, un simple index en base et couvre l'écrasante majorité des cas.
///
public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
{
public async Task> ResoudreAsync(
IEnumerable noms, CancellationToken ct = default)
{
var resolus = new List();
foreach (var brut in noms)
{
var nom = brut?.Trim();
if (string.IsNullOrEmpty(nom))
{
continue;
}
var cle = RapprochementAuteurs.Cle(nom);
if (cle.Length == 0)
{
continue; // Une saisie sans la moindre lettre ne désigne pas un auteur.
}
// Déjà résolu dans ce même enregistrement : « Zola » saisi deux fois ne doit pas
// produire deux liens vers le même auteur (la clé primaire du lien s'y opposerait).
if (resolus.Any(a => a.CleRegroupement == cle))
{
continue;
}
var auteur = await TrouverAsync(nom, cle, ct);
if (auteur is null)
{
auteur = new Auteur { Nom = nom };
auteur.RecalculerFormes();
db.Auteurs.Add(auteur);
}
resolus.Add(auteur);
}
return resolus;
}
///
/// Cherche l'auteur existant qui correspond sûrement à ce nom, ou null.
///
private async Task TrouverAsync(string nom, string cle, CancellationToken ct)
{
// 1. Clé identique : même mots normalisés, ordre indifférent. Résolu par l'index unique.
// Le Local est consulté aussi, sinon deux auteurs identiques créés dans la même
// requête violeraient l'unicité au SaveChanges.
var parCle = db.Auteurs.Local.FirstOrDefault(a => a.CleRegroupement == cle)
?? await db.Auteurs.FirstOrDefaultAsync(a => a.CleRegroupement == cle, ct);
if (parCle is not null)
{
return parCle;
}
// 2. Variantes que la clé ne capture pas : les initiales abrégées. Il faut aligner les
// mots un à un, ce qui n'a pas d'équivalent SQL — d'où le balayage en mémoire.
var candidats = await db.Auteurs.ToListAsync(ct);
candidats.AddRange(db.Auteurs.Local.Where(a => a.Id == 0));
var surs = candidats
.Where(a => RapprochementAuteurs.SontLeMemeAuteur(nom, a.Nom))
.OrderBy(a => a.Id)
.ToList();
// Plusieurs correspondances sûres signifient que la table contient déjà des doublons
// (ils se seraient rapprochés entre eux). On prend la plus ancienne plutôt que d'en
// créer une troisième : l'utilisateur les réunira depuis l'écran des auteurs.
return surs.FirstOrDefault();
}
public async Task> ListerAsync(CancellationToken ct = default) =>
await db.Auteurs
.AsNoTracking()
.OrderBy(a => a.NomNormalise)
.Select(a => new AuteurDto
{
Id = a.Id,
Nom = a.Nom,
NombreDeLivres = a.Livres.Count,
})
.ToListAsync(ct);
public async Task ObtenirAsync(int id, CancellationToken ct = default) =>
await db.Auteurs
.AsNoTracking()
.Where(a => a.Id == id)
.Select(a => new AuteurDto
{
Id = a.Id,
Nom = a.Nom,
NombreDeLivres = a.Livres.Count,
})
.FirstOrDefaultAsync(ct);
public async Task RenommerAsync(
int id, string nom, bool confirmerFusion = false, CancellationToken ct = default)
{
var auteur = await db.Auteurs.FirstOrDefaultAsync(a => a.Id == id, ct);
if (auteur is null)
{
return new ResultatRenommageAuteur();
}
nom = nom.Trim();
var cle = RapprochementAuteurs.Cle(nom);
if (cle.Length == 0)
{
return new ResultatRenommageAuteur
{
Auteur = new AuteurDto { Id = id, Nom = auteur.Nom },
};
}
var collision = await db.Auteurs
.AsNoTracking()
.FirstOrDefaultAsync(a => a.Id != id && a.CleRegroupement == cle, ct);
if (collision is not null)
{
var dto = new AuteurDto
{
Id = collision.Id,
Nom = collision.Nom,
NombreDeLivres = await db.LivreAuteurs.CountAsync(l => l.AuteurId == collision.Id, ct),
};
if (!confirmerFusion)
{
return new ResultatRenommageAuteur { Collision = dto };
}
await FusionnerAsync(id, collision.Id, ct);
return new ResultatRenommageAuteur { Auteur = dto };
}
auteur.Nom = nom;
auteur.RecalculerFormes();
await db.SaveChangesAsync(ct);
return new ResultatRenommageAuteur
{
Auteur = new AuteurDto
{
Id = auteur.Id,
Nom = auteur.Nom,
NombreDeLivres = await db.LivreAuteurs.CountAsync(l => l.AuteurId == auteur.Id, ct),
},
};
}
public async Task> SuggestionsAsync(
CancellationToken ct = default)
{
var auteurs = await db.Auteurs
.AsNoTracking()
.Select(a => new { a.Id, a.Nom, Livres = a.Livres.Count })
.OrderBy(a => a.Id)
.ToListAsync(ct);
var refuses = await db.RapprochementsRefuses
.AsNoTracking()
.Select(r => new { r.AuteurAId, r.AuteurBId })
.ToListAsync(ct);
var ecartes = refuses.Select(r => (r.AuteurAId, r.AuteurBId)).ToHashSet();
var suggestions = new List();
// Comparaison de toutes les paires : quadratique, mais sur quelques centaines d'auteurs
// c'est instantané, et le résultat n'est calculé qu'à l'ouverture de l'écran des auteurs.
for (var i = 0; i < auteurs.Count; i++)
{
for (var j = i + 1; j < auteurs.Count; j++)
{
var a = auteurs[i];
var b = auteurs[j];
if (RapprochementAuteurs.Comparer(a.Nom, b.Nom) != NiveauRapprochement.Suggere)
{
continue;
}
if (ecartes.Contains(Ordonner(a.Id, b.Id)))
{
continue; // L'utilisateur a déjà dit non : ne pas le lui redemander.
}
// La fusion fait disparaître la source : c'est le nom le moins complet qui
// s'efface, pour ne pas perdre « Peter F. Hamilton » au profit de « Hamilton ».
var (source, cible) =
NormalisationTexte.Mots(a.Nom).Length <= NormalisationTexte.Mots(b.Nom).Length
? (a, b)
: (b, a);
suggestions.Add(new SuggestionRapprochement
{
Source = new AuteurDto { Id = source.Id, Nom = source.Nom, NombreDeLivres = source.Livres },
Cible = new AuteurDto { Id = cible.Id, Nom = cible.Nom, NombreDeLivres = cible.Livres },
});
}
}
return suggestions;
}
public async Task FusionnerAsync(int sourceId, int cibleId, CancellationToken ct = default)
{
if (sourceId == cibleId)
{
return false;
}
var source = await db.Auteurs.Include(a => a.Livres).FirstOrDefaultAsync(a => a.Id == sourceId, ct);
var cible = await db.Auteurs.Include(a => a.Livres).FirstOrDefaultAsync(a => a.Id == cibleId, ct);
if (source is null || cible is null)
{
return false;
}
var dejaLies = cible.Livres.Select(l => l.LivreId).ToHashSet();
foreach (var lien in source.Livres.ToList())
{
// Un livre co-signé par les deux fiches ne doit pas donner deux liens identiques :
// la clé primaire du lien est (LivreId, AuteurId).
if (!dejaLies.Add(lien.LivreId))
{
var existant = cible.Livres.FirstOrDefault(l => l.LivreId == lien.LivreId);
if (existant is not null)
{
existant.Position = Math.Min(existant.Position, lien.Position);
if (existant.Role == RoleAuteur.NonPrecise)
{
existant.Role = lien.Role;
}
}
db.LivreAuteurs.Remove(lien);
continue;
}
db.LivreAuteurs.Remove(lien);
db.LivreAuteurs.Add(new LivreAuteur
{
LivreId = lien.LivreId,
AuteurId = cible.Id,
Position = lien.Position,
Role = lien.Role,
});
}
// Les refus qui portaient sur la fiche absorbée n'ont plus d'objet.
var refusObsoletes = await db.RapprochementsRefuses
.Where(r => r.AuteurAId == sourceId || r.AuteurBId == sourceId)
.ToListAsync(ct);
db.RapprochementsRefuses.RemoveRange(refusObsoletes);
db.Auteurs.Remove(source);
await db.SaveChangesAsync(ct);
return true;
}
public async Task RefuserAsync(int sourceId, int cibleId, CancellationToken ct = default)
{
if (sourceId == cibleId)
{
return false;
}
var (a, b) = Ordonner(sourceId, cibleId);
var existants = await db.Auteurs.CountAsync(x => x.Id == a || x.Id == b, ct);
if (existants != 2)
{
return false;
}
var deja = await db.RapprochementsRefuses.AnyAsync(r => r.AuteurAId == a && r.AuteurBId == b, ct);
if (!deja)
{
db.RapprochementsRefuses.Add(new RapprochementRefuse { AuteurAId = a, AuteurBId = b });
await db.SaveChangesAsync(ct);
}
return true;
}
public async Task SupprimerOrphelinsAsync(CancellationToken ct = default)
{
// Après une édition ou une suppression de livre, une fiche auteur peut n'avoir plus aucun
// livre. La garder polluerait l'écran des auteurs et les suggestions de rapprochement.
var orphelins = await db.Auteurs.Where(a => a.Livres.Count == 0).ToListAsync(ct);
if (orphelins.Count == 0)
{
return;
}
var ids = orphelins.Select(a => a.Id).ToList();
var refus = await db.RapprochementsRefuses
.Where(r => ids.Contains(r.AuteurAId) || ids.Contains(r.AuteurBId))
.ToListAsync(ct);
db.RapprochementsRefuses.RemoveRange(refus);
db.Auteurs.RemoveRange(orphelins);
await db.SaveChangesAsync(ct);
}
/// Couple rangé, pour qu'un refus vaille dans les deux sens.
private static (int, int) Ordonner(int x, int y) => x < y ? (x, y) : (y, x);
}