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.Services.Exceptions; namespace SchotzIadKmTracker.Api.Services; /// /// Implémentation du Carnet_Adresses (Exigence 15). Gère le CRUD des Adresses_Nommées avec : /// /// normalisation de la Dénomination (trim + minuscules + suppression des accents) pour /// garantir l'unicité (15.6/15.7) ; /// réutilisation du Cache_Adresses si l'adresse normalisée y est présente (15.10), /// sinon géocodage/re-géocodage via l'API BAN avec un délai de 5 s (15.11) ; /// persistance sans coordonnées + GeocodageReussi = false si BAN est /// indisponible ou dépasse le délai (15.12) ; /// mise en cache des coordonnées obtenues (15.9). /// /// public class AdresseNommeeService : IAdresseNommeeService { private const int DenominationMaxLength = 100; private const int AdresseMaxLength = 255; private readonly AppDbContext _db; private readonly IBANClient _banClient; private readonly IAdresseCacheService _cache; public AdresseNommeeService(AppDbContext db, IBANClient banClient, IAdresseCacheService cache) { _db = db; _banClient = banClient; _cache = cache; } /// public async Task> GetAllAsync(CancellationToken cancellationToken = default) { var adresses = await _db.AdressesNommees .AsNoTracking() .OrderBy(a => a.Denomination) .ToListAsync(cancellationToken); return adresses.Select(ToDto).ToList(); } /// public async Task CreateAsync( SaveAdresseNommeeDto dto, CancellationToken cancellationToken = default) { ValiderDenomination(dto.Denomination); ValiderAdresse(dto.Adresse); var denominationNormalisee = Normaliser(dto.Denomination); // Contrôle d'unicité avant persistance (15.7 / Property 28) ; l'index unique // UX_AdresseNommee_Denomination reste le dernier rempart au niveau SQL. var collision = await _db.AdressesNommees .AnyAsync(a => a.DenominationNormalisee == denominationNormalisee, cancellationToken); if (collision) { throw new DenominationDupliqueeException( $"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée."); } var (latitude, longitude, geocodageReussi) = await ResoudreCoordonneesAsync(dto.Adresse, dto.Latitude, dto.Longitude, cancellationToken); var maintenant = DateTime.UtcNow; var entite = new AdresseNommee { Denomination = dto.Denomination.Trim(), DenominationNormalisee = denominationNormalisee, Adresse = dto.Adresse.Trim(), Latitude = latitude, Longitude = longitude, GeocodageReussi = geocodageReussi, CreatedAt = maintenant, UpdatedAt = maintenant }; await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); try { _db.AdressesNommees.Add(entite); await _db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); } catch (DbUpdateException ex) { await transaction.RollbackAsync(cancellationToken); // Dernier rempart : violation de l'index unique (course concurrente). throw new DenominationDupliqueeException( $"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.", ex); } catch { await transaction.RollbackAsync(cancellationToken); throw; } // Mise en cache des coordonnées obtenues si absentes du Cache_Adresses (15.9). if (geocodageReussi && latitude.HasValue && longitude.HasValue) { await _cache.UpsertAdresseAsync(entite.Adresse, latitude.Value, longitude.Value, cancellationToken: cancellationToken); } return ToDto(entite); } /// public async Task UpdateAsync( int id, SaveAdresseNommeeDto dto, CancellationToken cancellationToken = default) { ValiderDenomination(dto.Denomination); ValiderAdresse(dto.Adresse); var entite = await _db.AdressesNommees.FirstOrDefaultAsync(a => a.Id == id, cancellationToken) ?? throw new AdresseNommeeNotFoundException( $"L'adresse nommée d'identifiant {id} est introuvable."); var denominationNormalisee = Normaliser(dto.Denomination); // Unicité : une autre Adresse_Nommee ne doit pas porter la même Dénomination normalisée (15.7). var collision = await _db.AdressesNommees .AnyAsync(a => a.Id != id && a.DenominationNormalisee == denominationNormalisee, cancellationToken); if (collision) { throw new DenominationDupliqueeException( $"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée."); } var adresseTrim = dto.Adresse.Trim(); var adresseAChange = !string.Equals( Normaliser(entite.Adresse), Normaliser(adresseTrim), StringComparison.Ordinal); double? latitude = entite.Latitude; double? longitude = entite.Longitude; var geocodageReussi = entite.GeocodageReussi; // Re-géocodage uniquement si le texte d'adresse a changé (15.11). if (adresseAChange) { (latitude, longitude, geocodageReussi) = await ResoudreCoordonneesAsync(adresseTrim, dto.Latitude, dto.Longitude, cancellationToken); } entite.Denomination = dto.Denomination.Trim(); entite.DenominationNormalisee = denominationNormalisee; entite.Adresse = adresseTrim; entite.Latitude = latitude; entite.Longitude = longitude; entite.GeocodageReussi = geocodageReussi; entite.UpdatedAt = DateTime.UtcNow; await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); try { await _db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); } catch (DbUpdateException ex) { await transaction.RollbackAsync(cancellationToken); throw new DenominationDupliqueeException( $"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.", ex); } catch { await transaction.RollbackAsync(cancellationToken); throw; } if (geocodageReussi && latitude.HasValue && longitude.HasValue) { await _cache.UpsertAdresseAsync(entite.Adresse, latitude.Value, longitude.Value, cancellationToken: cancellationToken); } return ToDto(entite); } /// public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) { var entite = await _db.AdressesNommees.FirstOrDefaultAsync(a => a.Id == id, cancellationToken) ?? throw new AdresseNommeeNotFoundException( $"L'adresse nommée d'identifiant {id} est introuvable."); // Suppression du seul enregistrement du Carnet_Adresses : les Trajets ne référencent que // le texte d'adresse et les coordonnées copiés, donc rien d'autre n'est modifié (15.14). await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); try { _db.AdressesNommees.Remove(entite); await _db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); } catch { await transaction.RollbackAsync(cancellationToken); throw; } } /// /// Résout les coordonnées d'un texte d'adresse : réutilisation du Cache_Adresses si l'adresse /// normalisée y figure (15.10), sinon coordonnées fournies par le frontend (sélection BAN), /// sinon géocodage via l'API BAN (15.11). En cas d'indisponibilité/timeout de la BAN, aucune /// coordonnée n'est retournée et GeocodageReussi vaut false (15.12). /// private async Task<(double? Latitude, double? Longitude, bool GeocodageReussi)> ResoudreCoordonneesAsync( string adresse, double? latitudeFournie, double? longitudeFournie, CancellationToken cancellationToken) { // 1) Réutilisation du Cache_Adresses sans appel BAN (15.10). var enCache = await _cache.ObtenirParAdresseAsync(adresse, cancellationToken); if (enCache is not null) { return (enCache.Latitude, enCache.Longitude, true); } // 2) Coordonnées déjà résolues côté frontend via une sélection BAN : on les conserve // directement, ce qui évite un appel BAN redondant (15.5). if (latitudeFournie.HasValue && longitudeFournie.HasValue) { return (latitudeFournie, longitudeFournie, true); } // 3) Géocodage via l'API BAN (timeout 5 s configuré sur le HttpClient) (15.11). try { var resultats = await _banClient.RechercherAsync(adresse, cancellationToken); var premier = resultats.FirstOrDefault(); if (premier is not null) { return (premier.Latitude, premier.Longitude, true); } // Aucune correspondance BAN : persistance sans coordonnées. return (null, null, false); } catch (BANIndisponibleException) { // BAN indisponible/timeout : persistance sans coordonnées (15.12). return (null, null, false); } } private static void ValiderDenomination(string? denomination) { var trimmee = denomination?.Trim() ?? string.Empty; if (trimmee.Length == 0 || trimmee.Length > DenominationMaxLength) { throw new DenominationInvalideException( "La dénomination est absente ou invalide (1 à 100 caractères requis)."); } } private static void ValiderAdresse(string? adresse) { var trimmee = adresse?.Trim() ?? string.Empty; if (trimmee.Length == 0 || trimmee.Length > AdresseMaxLength) { throw new AdresseNommeeInvalideException( "L'adresse est absente ou invalide (1 à 255 caractères requis)."); } } private static AdresseNommeeDto ToDto(AdresseNommee a) => new(a.Id, a.Denomination, a.Adresse, a.Latitude, a.Longitude); /// /// Normalise un texte : suppression des espaces de bord, passage en minuscules et suppression /// des accents (diacritiques). Cohérent avec afin d'assurer /// un matching identique entre Carnet_Adresses et Cache_Adresses. /// 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); } }