using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
namespace MaBibli.Client.Services;
///
/// Accès à l'API depuis le client Blazor, avec repli sur le cache hors-ligne.
///
///
/// Le piège que ce service existe pour éviter (CLAUDE.md) : en Blazor WebAssembly le code
/// tourne dans le navigateur alors que SQLite vit sur le serveur. Le service worker met en cache
/// les assets — HTML, CSS, WASM — mais jamais les réponses de l'API. Sans le travail fait
/// ici, l'application démarre parfaitement hors-ligne… et affiche une bibliothèque vide.
///
/// La règle est simple et sans exception : les lectures retombent sur le cache, les écritures
/// sont refusées. Aucune file d'attente, aucune synchronisation en retour, aucun conflit à
/// résoudre — c'est la décision actée, et c'est ce qui rend le hors-ligne fiable plutôt que
/// « presque bon ».
///
///
public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, EtatReseau reseau)
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
///
/// Recharge tous les instantanés depuis le réseau.
///
///
/// Appelé au démarrage et à chaque retour du réseau : c'est ce qui garantit que la
/// bibliothèque entière est disponible hors-ligne, et pas seulement les écrans déjà ouverts.
///
/// Vrai si les données ont pu être rafraîchies.
public async Task SynchroniserAsync(CancellationToken ct = default)
{
if (!reseau.TenterLeReseau)
{
return false;
}
try
{
await ListerAsync(new CritereLivres(), ct);
await ListerAuteursAsync(ct);
await ListerPretsEnCoursAsync(ct);
await ObtenirUtilisateurAsync(ct);
return reseau.EnLigne;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception)
{
// Une synchronisation ratée n'est pas une panne de l'application : l'écran affichera
// ce qu'il a, et la date de synchronisation dira qu'il n'est plus tout frais.
return false;
}
}
public async Task> ListerAsync(CritereLivres criteres, CancellationToken ct = default)
{
var parametres = new List();
if (!string.IsNullOrWhiteSpace(criteres.Recherche))
{
parametres.Add($"recherche={Uri.EscapeDataString(criteres.Recherche.Trim())}");
}
if (criteres.Format is { } format)
{
parametres.Add($"format={format}");
}
if (criteres.Statut is { } statut)
{
parametres.Add($"statut={statut}");
}
if (criteres.AuteurId is { } auteurId)
{
parametres.Add($"auteurId={auteurId}");
}
var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty);
var (ok, liste) = await EssayerAsync(() => http.GetFromJsonAsync>(url, Json, ct));
if (ok)
{
// Seule la liste NON filtrée fait un instantané valable : c'est elle qui permettra
// de chercher hors-ligne autre chose que ce qui était affiché au moment de la coupure.
if (parametres.Count == 0)
{
await MemoriserAsync(ClesCache.Catalogue, liste ?? []);
}
return liste ?? [];
}
var instantane = await LireCacheAsync>(ClesCache.Catalogue);
return instantane is null ? [] : FiltreLivresLocal.Appliquer(instantane.Donnees, criteres);
}
public async Task ObtenirAsync(int id, CancellationToken ct = default)
{
var (ok, livre) = await EssayerAsync(async () =>
{
var reponse = await http.GetAsync($"api/livres/{id}", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync(reponse, ct);
});
if (ok)
{
return livre;
}
// Hors-ligne, la fiche se relit dans l'instantané du catalogue : pas d'appel par livre à
// mettre en cache, donc rien à maintenir en plus.
var instantane = await LireCacheAsync>(ClesCache.Catalogue);
return instantane?.Donnees.FirstOrDefault(l => l.Id == id);
}
public async Task> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default) =>
await EcrireAsync(() => http.PostAsJsonAsync("api/livres", saisie, Json, ct), ct);
public async Task> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) =>
await EcrireAsync(() => http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct), ct);
///
/// Change le statut de lecture de l'utilisateur courant ; null le remet à
/// « non commencé ». Le serveur sait qui appelle : le client ne transmet jamais d'identité.
///
public async Task> ChangerStatutAsync(
int id, Statut? statut, CancellationToken ct = default) =>
await EcrireAsync(
() => http.PatchAsJsonAsync(
$"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct),
ct);
public async Task> ListerAuteursAsync(CancellationToken ct = default)
{
var (ok, auteurs) = await EssayerAsync(
() => http.GetFromJsonAsync>("api/auteurs", Json, ct));
if (ok)
{
await MemoriserAsync(ClesCache.Auteurs, auteurs ?? []);
return auteurs ?? [];
}
var instantane = await LireCacheAsync>(ClesCache.Auteurs);
return instantane?.Donnees ?? [];
}
public async Task ObtenirAuteurAsync(int id, CancellationToken ct = default)
{
var (ok, auteur) = await EssayerAsync(async () =>
{
var reponse = await http.GetAsync($"api/auteurs/{id}", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync(reponse, ct);
});
if (ok)
{
return auteur;
}
var instantane = await LireCacheAsync>(ClesCache.Auteurs);
return instantane?.Donnees.FirstOrDefault(a => a.Id == id);
}
///
/// Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul.
///
///
/// Volontairement pas mis en cache : c'est une liste de décisions à prendre, or aucune
/// décision ne peut être enregistrée hors-ligne. L'afficher sans pouvoir y répondre
/// n'apporterait rien.
///
public async Task> ListerRapprochementsAsync(
CancellationToken ct = default)
{
var (ok, suggestions) = await EssayerAsync(
() => http.GetFromJsonAsync>("api/auteurs/rapprochements", Json, ct));
return ok ? suggestions ?? [] : [];
}
public Task AccepterRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) =>
DeciderAsync("accepter", sourceId, cibleId, ct);
public Task RefuserRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) =>
DeciderAsync("refuser", sourceId, cibleId, ct);
private async Task DeciderAsync(string action, int sourceId, int cibleId, CancellationToken ct)
{
if (!reseau.EnLigne)
{
return EtatReseau.MotifHorsLigne;
}
try
{
var reponse = await http.PostAsJsonAsync(
$"api/auteurs/rapprochements/{action}",
new DecisionRapprochement { SourceId = sourceId, CibleId = cibleId },
Json,
ct);
reseau.SignalerSuccesReseau();
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
return EtatReseau.MotifHorsLigne;
}
}
/// Les livres actuellement dehors, du prêt le plus ancien au plus récent.
public async Task> ListerPretsEnCoursAsync(CancellationToken ct = default)
{
var (ok, prets) = await EssayerAsync(
() => http.GetFromJsonAsync>("api/prets/en-cours", Json, ct));
if (ok)
{
await MemoriserAsync(ClesCache.PretsEnCours, prets ?? []);
return prets ?? [];
}
var instantane = await LireCacheAsync>(ClesCache.PretsEnCours);
return instantane?.Donnees ?? [];
}
///
/// Historique complet des prêts d'un livre, du plus récent au plus ancien.
///
///
/// Liste vide = jamais prêté ; null = livre inconnu ou réseau absent. L'appelant
/// doit consulter pour distinguer les deux : l'historique
/// n'est pas mis en cache (une ligne par livre, pour une information rarement consultée),
/// et afficher « jamais prêté » hors-ligne serait un mensonge.
///
public async Task?> ListerPretsLivreAsync(int livreId, CancellationToken ct = default)
{
var (ok, prets) = await EssayerAsync(async () =>
{
var reponse = await http.GetAsync($"api/livres/{livreId}/prets", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync>(reponse, ct);
});
return ok ? prets : null;
}
///
/// Prête un livre. est une date locale, convertie ici en
/// UTC : tout ce que l'API stocke est en UTC, comme le reste des dates du projet.
///
public async Task> PreterAsync(
int livreId, string emprunteur, DateTime? datePret = null, CancellationToken ct = default)
{
var saisie = new EnregistrementPret
{
Emprunteur = emprunteur,
DatePret = EnUtc(datePret),
};
return await EcrireAsync(
() => http.PostAsJsonAsync($"api/livres/{livreId}/prets", saisie, Json, ct), ct);
}
/// Clôt un prêt. Le prêt n'est pas supprimé : il devient l'historique du livre.
public async Task> RendreAsync(
int pretId, DateTime? dateRetour = null, CancellationToken ct = default) =>
await EcrireAsync(
() => http.PostAsJsonAsync(
$"api/prets/{pretId}/retour", new RetourPret { DateRetour = EnUtc(dateRetour) }, Json, ct),
ct);
///
/// Convertit une date issue d'un <input type="date"> en instant UTC.
///
///
/// Le navigateur produit une date sans fuseau (minuit, « Unspecified ») : l'envoyer telle
/// quelle décalerait le prêt d'un jour pour la moitié du globe. On la déclare locale avant
/// de la convertir, ce qui est bien ce que l'utilisateur voulait dire en la choisissant.
///
private static DateTime? EnUtc(DateTime? date) =>
date is null ? null : DateTime.SpecifyKind(date.Value, DateTimeKind.Local).ToUniversalTime();
public async Task SupprimerAsync(int id, CancellationToken ct = default)
{
if (!reseau.EnLigne)
{
return EtatReseau.MotifHorsLigne;
}
try
{
var reponse = await http.DeleteAsync($"api/livres/{id}", ct);
reseau.SignalerSuccesReseau();
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
return EtatReseau.MotifHorsLigne;
}
}
///
/// Interroge la cascade BnF → OpenLibrary. Exige le réseau : rien n'est mis en cache,
/// et hors-ligne l'appel n'est même pas tenté (null immédiat).
///
public async Task ChercherIsbnAsync(string isbn, CancellationToken ct = default)
{
var (ok, resultat) = await EssayerAsync(async () =>
{
var reponse = await http.GetAsync($"api/isbn/{Uri.EscapeDataString(isbn)}", ct);
return reponse.IsSuccessStatusCode
? await LireAsync(reponse, ct)
: null;
});
return ok ? resultat : null;
}
public async Task ObtenirUtilisateurAsync(CancellationToken ct = default)
{
var (ok, utilisateur) = await EssayerAsync(
() => http.GetFromJsonAsync("api/moi", Json, ct));
if (ok && utilisateur is not null)
{
await MemoriserAsync(ClesCache.Utilisateur, utilisateur);
return utilisateur;
}
// Hors-ligne, le bandeau garde le nom de la dernière session plutôt que de faire croire
// à une déconnexion.
var instantane = await LireCacheAsync(ClesCache.Utilisateur);
return instantane?.Donnees ?? UtilisateurCourant.Anonyme;
}
///
/// Exécute un appel de lecture. Renvoie false — sans exception — quand le réseau
/// manque, ce qui est la bascule vers le cache. Une annulation demandée par l'appelant,
/// elle, continue de remonter : c'est une frappe au clavier, pas une panne.
///
private async Task<(bool Ok, T? Valeur)> EssayerAsync(Func> appel)
{
// TenterLeReseau et non EnLigne : quand la panne vient du serveur, le navigateur n'a
// jamais quitté le réseau et n'émettra donc aucun événement « online ». C'est la lecture
// suivante qui doit constater le retour, sans quoi l'application resterait sur le cache
// jusqu'au rechargement de la page.
if (!reseau.TenterLeReseau)
{
return (false, default);
}
try
{
var valeur = await appel();
reseau.SignalerSuccesReseau();
return (true, valeur);
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
return (false, default);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
// Délai dépassé sans annulation de l'appelant : le serveur ne répond pas.
reseau.SignalerEchecReseau();
return (false, default);
}
}
///
/// Exécute une écriture, ou la refuse hors-ligne avec un motif affichable.
///
///
/// L'interface doit désactiver ces actions bien avant d'arriver ici (CLAUDE.md : pas de
/// bouton qui échoue silencieusement). Ce refus est la seconde ligne, pour le cas où le
/// réseau tombe entre l'affichage et le clic.
///
private async Task> EcrireAsync(
Func> appel, CancellationToken ct)
{
if (!reseau.EnLigne)
{
return new ResultatApi(default, EtatReseau.MotifHorsLigne);
}
try
{
var reponse = await appel();
reseau.SignalerSuccesReseau();
var resultat = await InterpreterAsync(reponse, ct);
if (resultat.EstOk)
{
// La base a changé : l'instantané doit suivre, sinon une coupure juste après une
// modification montrerait un catalogue d'avant.
_ = RafraichirEnFondAsync();
}
return resultat;
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
return new ResultatApi(default, EtatReseau.MotifHorsLigne);
}
}
/// Remet l'instantané à jour sans faire attendre l'utilisateur.
private async Task RafraichirEnFondAsync()
{
try
{
await ListerAsync(new CritereLivres());
await ListerPretsEnCoursAsync();
await ListerAuteursAsync();
}
catch (Exception)
{
// Le cache restera daté de la synchronisation précédente : l'interface le dit.
}
}
private async Task MemoriserAsync(string cle, T valeur)
{
var date = DateTimeOffset.Now;
await cache.EcrireAsync(cle, valeur, date);
reseau.SignalerSynchro(date);
}
/// Relit un instantané et fait remonter sa date, celle que l'interface affiche.
private async Task?> LireCacheAsync(string cle)
{
var instantane = await cache.LireAsync(cle);
if (instantane is not null)
{
reseau.SignalerSynchro(instantane.Date);
}
return instantane;
}
private static async Task LireAsync(HttpResponseMessage reponse, CancellationToken ct)
{
reponse.EnsureSuccessStatusCode();
return await reponse.Content.ReadFromJsonAsync(Json, ct);
}
private static async Task> InterpreterAsync(HttpResponseMessage reponse, CancellationToken ct)
{
if (reponse.IsSuccessStatusCode)
{
var valeur = await reponse.Content.ReadFromJsonAsync(Json, ct);
return new ResultatApi(valeur, null);
}
return new ResultatApi(default, await MessageErreurAsync(reponse, ct));
}
/// Récupère le message d'erreur de l'API pour l'afficher tel quel à l'utilisateur.
private static async Task MessageErreurAsync(HttpResponseMessage reponse, CancellationToken ct)
{
try
{
var corps = await reponse.Content.ReadFromJsonAsync(Json, ct);
if (!string.IsNullOrWhiteSpace(corps?.Erreur))
{
return corps.Erreur;
}
}
catch (Exception)
{
// Corps illisible ou vide : on retombe sur le code HTTP.
}
return reponse.StatusCode switch
{
HttpStatusCode.NotFound => "Ce livre n'existe plus.",
_ => $"L'enregistrement a échoué ({(int)reponse.StatusCode}).",
};
}
// ─────────────────────────────────────────────────────────────────────────
// Liste d'envies et bibliographie par auteur.
// Bloc ajouté en fin de classe pour rester bien délimité.
// ─────────────────────────────────────────────────────────────────────────
///
/// La liste d'envies de l'utilisateur courant.
///
///
/// Comme pour le statut de lecture, le client ne transmet aucune identité : le serveur sait
/// qui appelle. Il n'existe volontairement pas de moyen de demander la liste d'un autre.
///
public async Task> ListerSouhaitsAsync(CancellationToken ct = default) =>
await http.GetFromJsonAsync>("api/souhaits", Json, ct) ?? [];
public async Task> AjouterSouhaitAsync(
EnregistrementSouhait saisie, CancellationToken ct = default)
{
var reponse = await http.PostAsJsonAsync("api/souhaits", saisie, Json, ct);
return await InterpreterAsync(reponse, ct);
}
public async Task SupprimerSouhaitAsync(int id, CancellationToken ct = default)
{
var reponse = await http.DeleteAsync($"api/souhaits/{id}", ct);
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
}
///
/// Adresse de téléchargement d'un export.
///
///
/// Le fichier n'est pas récupéré par ce service : un simple lien suffit, le serveur pose un
/// Content-Disposition: attachment et le navigateur enregistre. Passer par
/// obligerait à fabriquer une URL d'objet en JavaScript pour un
/// résultat identique.
///
/// ⚠️ Le lien doit porter l'attribut download : sans lui, le routeur Blazor
/// intercepte le clic comme une navigation interne et affiche sa page « introuvable » au
/// lieu de laisser le navigateur télécharger.
///
///
public static string UrlExportSouhaits(FormatExportSouhaits format) =>
format == FormatExportSouhaits.Csv ? "api/souhaits/export.csv" : "api/souhaits/export.txt";
///
/// Bibliographie d'un auteur d'après la BnF. null si l'auteur n'existe pas.
///
public async Task ObtenirBibliographieAsync(
int auteurId, CancellationToken ct = default)
{
var reponse = await http.GetAsync($"api/auteurs/{auteurId}/bibliographie", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync(reponse, ct);
}
private sealed record ErreurApi(string? Erreur);
}
/// Résultat d'un appel d'écriture : la valeur, ou un message d'erreur affichable.
public readonly record struct ResultatApi(T? Valeur, string? Erreur)
{
public bool EstOk => Erreur is null;
}