using MaBibli.Api.Data;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Revues;
/// Issue d'une écriture sur une revue : la revue, ou un message pour l'utilisateur.
public readonly record struct ResultatRevue(RevueDto? Revue, string? Erreur)
{
public static ResultatRevue Ok(RevueDto revue) => new(revue, null);
public static ResultatRevue Invalide(string message) => new(null, message);
public static readonly ResultatRevue Introuvable = new(null, null);
public bool EstIntrouvable => Revue is null && Erreur is null;
}
public interface IServiceRevues
{
Task> ListerAsync(CancellationToken ct = default);
///
/// Crée la revue, ou rend celle qui existe déjà quand l'ISSN ou le titre la désigne.
///
Task CreerOuRetrouverAsync(
EnregistrementRevue saisie, string? utilisateur, CancellationToken ct = default);
Task ModifierAsync(
int id, EnregistrementRevue saisie, CancellationToken ct = default);
Task SupprimerAsync(int id, CancellationToken ct = default);
Task AjouterNumeroAsync(
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default);
Task RetirerNumeroAsync(int numeroId, CancellationToken ct = default);
}
///
/// Revues et magazines : une fiche par revue, les numéros à l'intérieur.
///
///
/// ⚠️ Communes au foyer, comme le catalogue : aucune méthode ne filtre sur un
/// utilisateur, n'étant qu'une trace.
///
/// Un numéro est recensé, rien de plus — ni prêt, ni statut de lecture (décidé le
/// 2026-08-19). C'est ce qui garde cette table à l'écart de deux mécanismes attachés à
/// Livre par clé étrangère.
///
///
public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
{
public async Task> ListerAsync(CancellationToken ct = default)
{
var revues = await db.Revues
.AsNoTracking()
.Include(r => r.Numeros)
.OrderBy(r => r.TitreNormalise)
.ToListAsync(ct);
return revues.Select(Projeter).ToList();
}
public async Task CreerOuRetrouverAsync(
EnregistrementRevue saisie, string? utilisateur, CancellationToken ct = default)
{
var titre = saisie.Titre?.Trim();
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatRevue.Invalide("Le titre de la revue est obligatoire.");
}
var issn = Vide(saisie.Issn);
var normalise = NormalisationTexte.Normaliser(titre);
// ⚠️ « Créer OU retrouver » et non « créer » : le flux du code-barres 977 rescanne
// forcément la même revue à chaque numéro. Refuser le doublon obligerait l'utilisateur
// à comprendre qu'il doit aller chercher la fiche lui-même — alors que le geste est
// toujours le même : « voici un numéro de plus ».
var existante = await db.Revues
.Include(r => r.Numeros)
.FirstOrDefaultAsync(
r => (issn != null && r.Issn == issn) || r.TitreNormalise == normalise, ct);
if (existante is not null)
{
// Un ISSN appris après coup complète la fiche créée à la main : c'est ce qui
// permettra de la retrouver au prochain scan.
if (existante.Issn is null && issn is not null)
{
existante.Issn = issn;
await db.SaveChangesAsync(ct);
}
return ResultatRevue.Ok(Projeter(existante));
}
var revue = new Revue
{
Titre = titre,
Issn = issn,
Editeur = Vide(saisie.Editeur),
DateAjout = DateTime.UtcNow,
AjoutePar = utilisateur,
};
revue.RecalculerFormes();
db.Revues.Add(revue);
await db.SaveChangesAsync(ct);
return ResultatRevue.Ok(Projeter(revue));
}
public async Task ModifierAsync(
int id, EnregistrementRevue saisie, CancellationToken ct = default)
{
var revue = await db.Revues.Include(r => r.Numeros).FirstOrDefaultAsync(r => r.Id == id, ct);
if (revue is null)
{
return ResultatRevue.Introuvable;
}
var titre = saisie.Titre?.Trim();
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatRevue.Invalide("Le titre de la revue est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(titre);
if (await db.Revues.AnyAsync(r => r.Id != id && r.TitreNormalise == normalise, ct))
{
return ResultatRevue.Invalide($"La revue « {titre} » existe déjà.");
}
var issn = Vide(saisie.Issn);
if (issn is not null && await db.Revues.AnyAsync(r => r.Id != id && r.Issn == issn, ct))
{
return ResultatRevue.Invalide($"L'ISSN {issn} est déjà celui d'une autre revue.");
}
revue.Titre = titre;
revue.Issn = issn;
revue.Editeur = Vide(saisie.Editeur);
revue.RecalculerFormes();
await db.SaveChangesAsync(ct);
return ResultatRevue.Ok(Projeter(revue));
}
public async Task SupprimerAsync(int id, CancellationToken ct = default)
{
var revue = await db.Revues.FirstOrDefaultAsync(r => r.Id == id, ct);
if (revue is null)
{
return false;
}
// Les numéros partent en cascade : ils n'existent que par leur revue, contrairement aux
// livres d'une série, qui vivent leur vie au catalogue.
db.Revues.Remove(revue);
await db.SaveChangesAsync(ct);
return true;
}
public async Task AjouterNumeroAsync(
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default)
{
var revue = await db.Revues.Include(r => r.Numeros).FirstOrDefaultAsync(r => r.Id == revueId, ct);
if (revue is null)
{
return ResultatRevue.Introuvable;
}
var numero = saisie.Numero?.Trim();
if (string.IsNullOrWhiteSpace(numero))
{
return ResultatRevue.Invalide("Le numéro est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(numero);
if (revue.Numeros.Any(n => n.NumeroNormalise == normalise))
{
return ResultatRevue.Invalide($"Le numéro {numero} est déjà dans votre collection.");
}
if (saisie.DateParution is { } parution && parution.Date > DateTime.UtcNow.Date.AddDays(1))
{
// Comme pour les prêts : une date future est une faute de frappe, pas une intention.
// Un jour de marge, les revues datées du mois suivant étant courantes en kiosque.
return ResultatRevue.Invalide("La date de parution ne peut pas être dans le futur.");
}
var ligne = new NumeroRevue
{
RevueId = revueId,
Numero = numero,
DateParution = saisie.DateParution,
Note = Vide(saisie.Note),
DateAjout = DateTime.UtcNow,
};
ligne.RecalculerFormes();
db.NumerosRevue.Add(ligne);
await db.SaveChangesAsync(ct);
var relue = await db.Revues.AsNoTracking().Include(r => r.Numeros)
.FirstAsync(r => r.Id == revueId, ct);
return ResultatRevue.Ok(Projeter(relue));
}
public async Task RetirerNumeroAsync(int numeroId, CancellationToken ct = default)
{
var numero = await db.NumerosRevue.FirstOrDefaultAsync(n => n.Id == numeroId, ct);
if (numero is null)
{
return false;
}
db.NumerosRevue.Remove(numero);
await db.SaveChangesAsync(ct);
return true;
}
private static string? Vide(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
private static RevueDto Projeter(Revue revue) => new()
{
Id = revue.Id,
Titre = revue.Titre,
Issn = revue.Issn,
Editeur = revue.Editeur,
AjoutePar = revue.AjoutePar,
// Du plus récent au plus ancien : sur un magazine, c'est le dernier reçu qu'on cherche,
// à l'inverse d'une série dont on suit l'ordre de lecture. Les numéros sans date de
// parution ferment la liste plutôt que de passer pour les plus vieux.
Numeros = revue.Numeros
.OrderByDescending(n => n.DateParution.HasValue)
.ThenByDescending(n => n.DateParution)
.ThenByDescending(n => n.NumeroNormalise, StringComparer.Ordinal)
.Select(n => new NumeroRevueDto
{
Id = n.Id,
Numero = n.Numero,
DateParution = n.DateParution,
Note = n.Note,
})
.ToList(),
};
}