using System.Globalization;
using System.Text;
using Microsoft.EntityFrameworkCore;
using TrajetKmTracker.Api.Data;
using TrajetKmTracker.Api.Models.DTOs;
using TrajetKmTracker.Api.Models.Entities;
using TrajetKmTracker.Api.Repositories;
using TrajetKmTracker.Api.Services.Exceptions;
namespace TrajetKmTracker.Api.Services;
///
public class TrajetService : ITrajetService
{
private readonly AppDbContext _context;
private readonly ITrajetRepository _repository;
private readonly IVehiculeService _vehiculeService;
private readonly IDistanceCalculator _distanceCalculator;
private readonly IAdresseCacheService _adresseCacheService;
public TrajetService(
AppDbContext context,
ITrajetRepository repository,
IVehiculeService vehiculeService,
IDistanceCalculator distanceCalculator,
IAdresseCacheService adresseCacheService)
{
_context = context;
_repository = repository;
_vehiculeService = vehiculeService;
_distanceCalculator = distanceCalculator;
_adresseCacheService = adresseCacheService;
}
///
public async Task CreateAsync(
CreateTrajetDto dto, CancellationToken cancellationToken = default)
{
// La forme de la date (JJ/MM/AAAA) et la validité des champs sont garanties par le
// validateur FluentValidation exécuté côté MVC avant d'atteindre le service.
var date = ParseDate(dto.Date);
// --- Association du véhicule (Exigences 1.2, 10.14) ---
// VehiculeId explicite : on l'utilise et on copie sa PuissanceFiscale.
// Sinon : véhicule courant à la date du trajet (via le repository).
var trajet = new Trajet
{
Date = date,
AdresseDepart = dto.AdresseDepart,
AdresseArrivee = dto.AdresseArrivee,
Commentaire = dto.Commentaire,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await AssocierVehiculeAsync(trajet, dto.VehiculeId, date, cancellationToken);
// --- Calcul de la distance (BAN puis ORS, fallback Haversine) (Exigences 2.4, 2.5) ---
var distanceResult = await _distanceCalculator.CalculerAsync(
dto.AdresseDepart,
dto.AdresseArrivee,
dto.DepartLatitude,
dto.DepartLongitude,
dto.ArriveeLatitude,
dto.ArriveeLongitude,
cancellationToken);
// Distance nulle possible (aucune coordonnée résoluble) : on persiste tout de même
// le Trajet avec Distance = null + avertissement (Exigence 2.4).
trajet.Distance = distanceResult.DistanceKm;
// Géométrie de l'itinéraire routier (paire départ→arrivée) pour le tracé carte.
trajet.GeometrieJson = distanceResult.GeometrieJson;
// --- Persistance transactionnelle ---
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
// Position en fin de journée : max(Position) + 1.0 (ou 1.0 si journée vide).
trajet.Position = await _repository.CalculerPositionFinDeJourneeAsync(date, cancellationToken);
await _repository.AddAsync(trajet, cancellationToken);
await _repository.SauvegarderAsync(cancellationToken);
// Mise en cache des adresses résolues (coordonnées connues) — après persistance
// du trajet, dans la même transaction (Exigences 6.7, 6.8, 9.1).
await MettreEnCacheAdressesResoluesAsync(
dto.AdresseDepart, dto.AdresseArrivee, distanceResult, cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
return new TrajetCreationResult(
Trajet: ToDto(trajet),
Avertissement: distanceResult.Avertissement,
Message: distanceResult.Avertissement ? distanceResult.Message : null);
}
///
public async Task UpdateAsync(
int id, UpdateTrajetDto dto, CancellationToken cancellationToken = default)
{
// 404 si le trajet n'existe pas : l'opération est bloquée, aucun résumé n'est mis à jour
// (Exigence 5.6).
var trajet = await _repository.GetByIdAsync(id, cancellationToken)
?? throw new TrajetNotFoundException(id);
var nouvelleDate = ParseDate(dto.Date);
// Détermine s'il faut recalculer la distance :
// - au moins une adresse a changé (comparaison sur le texte normalisé) (Exigence 5.2), OU
// - le trajet avait une Distance nulle et de nouvelles coordonnées sont fournies : on tente
// un recalcul (Exigence 5.10).
// Sinon (adresses inchangées) : on met à jour les autres champs SANS recalcul (Exigence 5.3/5.9).
var adresseModifiee =
!AdressesEquivalentes(trajet.AdresseDepart, dto.AdresseDepart) ||
!AdressesEquivalentes(trajet.AdresseArrivee, dto.AdresseArrivee);
var tenterRecalculDistanceNulle =
trajet.Distance is null &&
(dto.DepartLatitude.HasValue || dto.ArriveeLatitude.HasValue ||
!string.IsNullOrWhiteSpace(dto.AdresseDepart) || !string.IsNullOrWhiteSpace(dto.AdresseArrivee));
var doitRecalculer = adresseModifiee || tenterRecalculDistanceNulle;
// Sauvegarde des valeurs existantes pour préservation en cas d'échec (Exigence 5.8).
var distanceExistante = trajet.Distance;
DistanceCalculationResult? distanceResult = null;
bool avertissement = false;
string? messageAvertissement = null;
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
if (doitRecalculer)
{
try
{
distanceResult = await _distanceCalculator.CalculerAsync(
dto.AdresseDepart,
dto.AdresseArrivee,
dto.DepartLatitude,
dto.DepartLongitude,
dto.ArriveeLatitude,
dto.ArriveeLongitude,
cancellationToken);
}
catch (Exception ex)
{
// Le recalcul a échoué : annuler la modification et préserver les données
// existantes du Trajet (Exigence 5.8).
await transaction.RollbackAsync(cancellationToken);
throw new DistanceRecalculException(
"Le recalcul de la distance a échoué ; la modification a été annulée et les données du trajet sont conservées.",
ex);
}
if (adresseModifiee)
{
// Adresse(s) modifiée(s) : la nouvelle distance remplace l'ancienne (peut être null
// si aucune coordonnée n'est résoluble → avertissement, Exigence 2.4).
trajet.Distance = distanceResult.DistanceKm;
// La géométrie routière suit la nouvelle paire d'adresses (null si vol d'oiseau).
trajet.GeometrieJson = distanceResult.GeometrieJson;
}
else if (distanceResult.DistanceKm.HasValue)
{
// Distance était nulle et de nouvelles coordonnées ont permis un calcul (Exigence 5.10).
trajet.Distance = distanceResult.DistanceKm;
trajet.GeometrieJson = distanceResult.GeometrieJson;
}
avertissement = distanceResult.Avertissement;
messageAvertissement = distanceResult.Avertissement ? distanceResult.Message : null;
}
// Mise à jour des champs (commentaire, adresses, date, véhicule) — sans recalcul si
// les adresses n'ont pas changé (Exigence 5.3/5.9).
trajet.AdresseDepart = dto.AdresseDepart;
trajet.AdresseArrivee = dto.AdresseArrivee;
trajet.Commentaire = dto.Commentaire;
trajet.UpdatedAt = DateTime.UtcNow;
if (trajet.Date != nouvelleDate)
{
trajet.Date = nouvelleDate;
}
await AssocierVehiculeAsync(trajet, dto.VehiculeId, nouvelleDate, cancellationToken);
_repository.Update(trajet);
await _repository.SauvegarderAsync(cancellationToken);
// Mise en cache des adresses résolues (coordonnées connues) (Exigences 6.7, 6.8).
if (distanceResult is not null)
{
await MettreEnCacheAdressesResoluesAsync(
dto.AdresseDepart, dto.AdresseArrivee, distanceResult, cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch (DistanceRecalculException)
{
// Déjà rollbacké ci-dessus ; préserver les données existantes en mémoire.
trajet.Distance = distanceExistante;
throw;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
return new TrajetUpdateResult(
Trajet: ToDto(trajet),
Avertissement: avertissement,
Message: messageAvertissement);
}
///
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
{
var trajet = await _repository.GetByIdAsync(id, cancellationToken)
?? throw new TrajetNotFoundException(id);
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
_repository.Delete(trajet);
await _repository.SauvegarderAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
///
public async Task ReordonnerAsync(
ReordonnerTrajetsDto dto, CancellationToken cancellationToken = default)
{
// Le repository met à jour uniquement la Position des trajets fournis, en transaction,
// sans altérer les autres champs (Exigence 11.9). Les identifiants inconnus sont ignorés.
var positions = (dto.Trajets ?? Array.Empty())
.Select(t => (t.Id, t.Position))
.ToList();
if (positions.Count == 0)
{
return;
}
await _repository.ReordonnerAsync(positions, cancellationToken);
}
///
public async Task RecalculerToutesLesDistancesAsync(CancellationToken cancellationToken = default)
{
// On ne (re)sollicite ORS que là où c'est utile : uniquement les trajets SANS géométrie
// routière (GeometrieJson NULL). Un trajet ayant déjà une géométrie signifie que le calcul
// routier a réussi précédemment ; on ne le recalcule pas, ce qui limite les appels ORS.
var trajets = await _context.Trajets
.Where(t => t.GeometrieJson == null)
.ToListAsync(cancellationToken);
var recalcules = 0;
foreach (var trajet in trajets)
{
DistanceCalculationResult result;
try
{
// Recalcul systématique via BAN + ORS (fallback Haversine géré en interne).
// On ne fournit pas de coordonnées : le calculateur les résout depuis les adresses.
result = await _distanceCalculator.CalculerAsync(
trajet.AdresseDepart,
trajet.AdresseArrivee,
departLatitude: null,
departLongitude: null,
arriveeLatitude: null,
arriveeLongitude: null,
cancellationToken);
}
catch
{
// Un échec sur un trajet ne doit pas interrompre le lot : on le laisse tel quel.
continue;
}
// On ne remplace la distance que si un calcul a abouti (sinon on conserve l'existant).
if (result.DistanceKm.HasValue)
{
trajet.Distance = result.DistanceKm;
// Géométrie routière recalculée (null si ORS a échoué → tracé ligne droite).
trajet.GeometrieJson = result.GeometrieJson;
trajet.UpdatedAt = DateTime.UtcNow;
_repository.Update(trajet);
await _repository.SauvegarderAsync(cancellationToken);
// Met à jour le cache d'adresses (coordonnées + géométrie d'itinéraire).
await MettreEnCacheAdressesResoluesAsync(
trajet.AdresseDepart, trajet.AdresseArrivee, result, cancellationToken);
recalcules++;
}
}
return recalcules;
}
///
/// Compare deux textes d'adresse après normalisation (trim + minuscules + suppression des accents),
/// de façon cohérente avec le Cache_Adresses. Deux adresses équivalentes ne déclenchent pas de recalcul.
///
private static bool AdressesEquivalentes(string? a, string? b) =>
string.Equals(Normaliser(a), Normaliser(b), StringComparison.Ordinal);
///
/// Normalise un texte d'adresse : suppression des espaces de bord, passage en minuscules et
/// suppression des accents (diacritiques). Cohérent avec .
///
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);
}
///
/// Associe le véhicule au trajet : si est fourni, on l'utilise
/// et on copie sa puissance fiscale ; sinon on applique le véhicule courant à la date du trajet
/// (dénormalisation de la PuissanceFiscale, Exigence 10.14).
///
private async Task AssocierVehiculeAsync(
Trajet trajet, int? vehiculeId, DateOnly date, CancellationToken cancellationToken)
{
if (vehiculeId.HasValue)
{
var historique = await _vehiculeService.GetHistoriqueAsync(cancellationToken);
var vehicule = historique.FirstOrDefault(v => v.Id == vehiculeId.Value)
?? throw new VehiculeNotFoundException(
$"Le véhicule d'identifiant {vehiculeId.Value} est introuvable.");
trajet.VehiculeId = vehicule.Id;
trajet.PuissanceFiscale = vehicule.PuissanceFiscale;
return;
}
// Véhicule courant à la date du trajet : le repository résout et copie la puissance fiscale.
await _repository.AppliquerVehiculeADateAsync(trajet, cancellationToken);
// Aucun véhicule applicable à cette date : VehiculeId reste 0 (non initialisé).
if (trajet.VehiculeId == 0)
{
throw new VehiculeNotFoundException(
$"Aucun véhicule n'est applicable à la date {date:dd/MM/yyyy} ; renseignez un véhicule avant de saisir ce trajet.");
}
}
///
/// Met en cache les adresses de départ et d'arrivée dont les coordonnées ont été résolues
/// (via BAN/ORS). Les adresses non résolues ne sont pas mises en cache.
/// La géométrie GeoJSON de l'itinéraire, lorsqu'elle existe, est associée à l'adresse d'arrivée.
///
private async Task MettreEnCacheAdressesResoluesAsync(
string adresseDepart,
string adresseArrivee,
DistanceCalculationResult result,
CancellationToken cancellationToken)
{
if (result.DepartLatitude.HasValue && result.DepartLongitude.HasValue)
{
await _adresseCacheService.UpsertAdresseAsync(
adresseDepart,
result.DepartLatitude.Value,
result.DepartLongitude.Value,
geometrieJson: null,
cancellationToken);
}
if (result.ArriveeLatitude.HasValue && result.ArriveeLongitude.HasValue)
{
await _adresseCacheService.UpsertAdresseAsync(
adresseArrivee,
result.ArriveeLatitude.Value,
result.ArriveeLongitude.Value,
result.GeometrieJson,
cancellationToken);
}
}
///
/// Parse une date au format JJ/MM/AAAA. La validité est déjà garantie par le validateur MVC ;
/// ce parse défensif lève si la contrainte n'a pas été appliquée.
///
private static DateOnly ParseDate(string date)
{
if (!DateOnly.TryParseExact(
date,
"dd/MM/yyyy",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed))
{
throw new FormatException(
$"La date « {date} » n'est pas au format attendu JJ/MM/AAAA.");
}
return parsed;
}
private static TrajetDto ToDto(Trajet t) =>
new(
t.Id,
t.Date,
t.AdresseDepart,
t.AdresseArrivee,
t.Distance,
t.Commentaire,
t.Position,
t.VehiculeId,
t.PuissanceFiscale);
}