using System.Globalization; using System.Text; using Microsoft.EntityFrameworkCore; using SchotzIadKmTracker.Api.Data; using SchotzIadKmTracker.Api.Models.DTOs; using SchotzIadKmTracker.Api.Models.Entities; using SchotzIadKmTracker.Api.Repositories; namespace SchotzIadKmTracker.Api.Services; /// /// Implémentation du Cache_Adresses. Les adresses sont stockées et comparées sous forme /// normalisée (trim + minuscules + suppression des accents) afin d'assurer un matching robuste. /// Les écritures (upsert + éviction LRU) sont exécutées en transaction. /// public class AdresseCacheService : IAdresseCacheService { /// Longueur minimale d'une requête de recherche locale (Requirement 9.3). private const int LongueurMinimaleRecherche = 3; /// Taille maximale du cache avant éviction LRU (Requirement 9.5). private const int TailleMaximaleCache = 1_000; private readonly IAdresseCacheRepository _repository; private readonly AppDbContext _db; public AdresseCacheService(IAdresseCacheRepository repository, AppDbContext db) { _repository = repository; _db = db; } public async Task> RechercherLocalAsync( string query, int limite = 5, CancellationToken cancellationToken = default) { var fragment = Normaliser(query); if (fragment.Length < LongueurMinimaleRecherche) { return Array.Empty(); } if (limite <= 0) { limite = 5; } var entrees = await _repository.RechercherAsync(fragment, limite, cancellationToken); return entrees .Select(e => new AdresseCacheDto(e.Adresse, e.Latitude, e.Longitude)) .ToList(); } public async Task ObtenirParAdresseAsync( string adresse, CancellationToken cancellationToken = default) { var normalisee = Normaliser(adresse); if (normalisee.Length == 0) { return null; } var entree = await _repository.ObtenirParAdresseNormaliseeAsync(normalisee, cancellationToken); if (entree is null) { return null; } // Toute utilisation d'une adresse en cache rafraîchit sa date de dernière utilisation. // Si une transaction est déjà ouverte par l'appelant (ex. création d'un Trajet), // on y participe sans en ouvrir une nouvelle : SQLite interdit les transactions imbriquées. entree.DerniereUtilisation = DateTime.UtcNow; if (_db.Database.CurrentTransaction is null) { await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); await _repository.SauvegarderAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); } else { await _repository.SauvegarderAsync(cancellationToken); } return new AdresseCacheEntreeDto( entree.Adresse, entree.Latitude, entree.Longitude, entree.GeometrieJson); } public async Task UpsertAdresseAsync( string adresse, double latitude, double longitude, string? geometrieJson = null, CancellationToken cancellationToken = default) { var normalisee = Normaliser(adresse); if (normalisee.Length == 0) { return; } // Si une transaction est déjà ouverte par l'appelant (ex. TrajetService.CreateAsync), // on y participe sans en ouvrir une nouvelle (SQLite interdit les transactions imbriquées). var transactionAmbiante = _db.Database.CurrentTransaction is not null; var transaction = transactionAmbiante ? null : await _db.Database.BeginTransactionAsync(cancellationToken); var existante = await _repository.ObtenirParAdresseNormaliseeAsync(normalisee, cancellationToken); if (existante is not null) { // Adresse déjà présente : coordonnées inchangées, seule la date d'utilisation est // rafraîchie (Requirements 9.1, 9.6). existante.DerniereUtilisation = DateTime.UtcNow; } else { // Nouvelle adresse : insertion avec les coordonnées fournies. var nouvelle = new AdresseCache { Adresse = normalisee, Latitude = latitude, Longitude = longitude, GeometrieJson = geometrieJson, DerniereUtilisation = DateTime.UtcNow }; await _repository.AjouterAsync(nouvelle, cancellationToken); } await _repository.SauvegarderAsync(cancellationToken); // Éviction LRU : conserver au plus les 1 000 entrées les plus récemment utilisées // et supprimer les plus anciennes (Requirement 9.5). var total = await _repository.CompterAsync(cancellationToken); if (total > TailleMaximaleCache) { var aSupprimer = await _repository.ObtenirPlusAnciennesAsync( total - TailleMaximaleCache, cancellationToken); _repository.Supprimer(aSupprimer); await _repository.SauvegarderAsync(cancellationToken); } // On ne valide que la transaction que l'on a nous-mêmes ouverte ; sinon c'est // l'appelant qui validera la transaction ambiante. if (transaction is not null) { await transaction.CommitAsync(cancellationToken); await transaction.DisposeAsync(); } } /// /// Normalise un texte d'adresse : suppression des espaces de bord, passage en minuscules /// et suppression des accents (diacritiques). Cohérent avec la normalisation des Adresses_Nommées. /// private static string Normaliser(string? valeur) { if (string.IsNullOrWhiteSpace(valeur)) { return string.Empty; } var trimmee = valeur.Trim().ToLowerInvariant(); var decomposee = trimmee.Normalize(NormalizationForm.FormD); var builder = new StringBuilder(decomposee.Length); foreach (var caractere in decomposee) { var categorie = CharUnicodeInfo.GetUnicodeCategory(caractere); if (categorie != UnicodeCategory.NonSpacingMark) { builder.Append(caractere); } } return builder.ToString().Normalize(NormalizationForm.FormC); } }