using Microsoft.Extensions.Logging; using SchotzIadKmTracker.Api.Services.Exceptions; namespace SchotzIadKmTracker.Api.Services; /// /// Implémentation de . /// /// Résout d'abord les coordonnées manquantes via BAN (priorité BAN sur ORS, /// Property 16), puis calcule la distance routière via ORS. En cas de timeout ou /// d'échec ORS, applique le fallback orthodromique (Haversine) sans erreur. Si les /// coordonnées ne peuvent pas être résolues (BAN sans résultat / indisponible), retourne /// une distance nulle assortie d'un avertissement, sans lever d'exception bloquante. /// /// _Requirements: 2.1, 2.2, 2.3, 2.4, 2.6_ /// public class DistanceCalculatorService : IDistanceCalculator { /// Distance minimale stockable (km). private const decimal DistanceMinKm = 0.01m; /// Distance maximale stockable (km). private const decimal DistanceMaxKm = 99_999.99m; private readonly IBANClient _banClient; private readonly IORSClient _orsClient; private readonly ILogger _logger; public DistanceCalculatorService( IBANClient banClient, IORSClient orsClient, ILogger logger) { _banClient = banClient; _orsClient = orsClient; _logger = logger; } /// public async Task CalculerAsync( string adresseDepart, string adresseArrivee, double? departLatitude, double? departLongitude, double? arriveeLatitude, double? arriveeLongitude, CancellationToken cancellationToken = default) { // Coordonnées de départ connues seulement si latitude ET longitude sont présentes. double? depLat = departLatitude; double? depLon = departLongitude; double? arrLat = arriveeLatitude; double? arrLon = arriveeLongitude; // Résolution BAN AVANT tout appel ORS pour les coordonnées manquantes (Property 16). if (!EstCoordonneeConnue(depLat, depLon)) { var (lat, lon) = await GeocoderAsync(adresseDepart, cancellationToken); depLat = lat; depLon = lon; } if (!EstCoordonneeConnue(arrLat, arrLon)) { var (lat, lon) = await GeocoderAsync(adresseArrivee, cancellationToken); arrLat = lat; arrLon = lon; } // Si une coordonnée reste absente après géocodage BAN → distance nulle + avertissement. if (!EstCoordonneeConnue(depLat, depLon) || !EstCoordonneeConnue(arrLat, arrLon)) { return new DistanceCalculationResult( DepartLatitude: depLat, DepartLongitude: depLon, ArriveeLatitude: arrLat, ArriveeLongitude: arrLon, DistanceKm: null, GeometrieJson: null, Avertissement: true, Message: "Le trajet a été enregistré mais la distance n'a pas pu être " + "calculée faute de coordonnées géographiques."); } // À ce stade, les quatre coordonnées sont non-null : jamais d'appel ORS avec des coordonnées nulles. var lat1 = depLat!.Value; var lon1 = depLon!.Value; var lat2 = arrLat!.Value; var lon2 = arrLon!.Value; decimal distanceKm; string? geometrieJson = null; var avertissement = false; string? message = null; try { var route = await _orsClient.CalculateRouteAsync(lat1, lon1, lat2, lon2, cancellationToken); distanceKm = route.DistanceKm; geometrieJson = route.GeometrieJson; } catch (ORSTimeoutException ex) { _logger.LogWarning(ex, "Timeout ORS : application du fallback Haversine pour le calcul de distance."); distanceKm = HaversineKm(lat1, lon1, lat2, lon2); } catch (ORSException ex) { _logger.LogWarning(ex, "Échec ORS : application du fallback Haversine pour le calcul de distance."); distanceKm = HaversineKm(lat1, lon1, lat2, lon2); } // Arrondi 2 décimales + bornage [0,01 ; 99 999,99] km. distanceKm = BornerDistance(Math.Round(distanceKm, 2, MidpointRounding.AwayFromZero)); return new DistanceCalculationResult( DepartLatitude: lat1, DepartLongitude: lon1, ArriveeLatitude: lat2, ArriveeLongitude: lon2, DistanceKm: distanceKm, GeometrieJson: geometrieJson, Avertissement: avertissement, Message: message); } /// /// Géocode une adresse via BAN et retourne les coordonnées du meilleur (premier) résultat. /// Retourne (null, null) si aucune adresse n'est trouvée ou si BAN est indisponible, /// sans propager d'exception (échec BAN non bloquant). /// private async Task<(double? Latitude, double? Longitude)> GeocoderAsync( string adresse, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(adresse)) { return (null, null); } try { var resultats = await _banClient.RechercherAsync(adresse, cancellationToken); if (resultats.Count == 0) { return (null, null); } // Meilleur / premier résultat (la BAN retourne les résultats par pertinence décroissante). var meilleur = resultats[0]; return (meilleur.Latitude, meilleur.Longitude); } catch (BANIndisponibleException ex) { _logger.LogWarning(ex, "BAN indisponible lors du géocodage de l'adresse manquante ; distance non calculable."); return (null, null); } } /// Vrai si latitude ET longitude sont toutes deux renseignées. private static bool EstCoordonneeConnue(double? latitude, double? longitude) => latitude.HasValue && longitude.HasValue; /// Borne une distance dans l'intervalle stockable [0,01 ; 99 999,99] km. private static decimal BornerDistance(decimal distanceKm) { if (distanceKm < DistanceMinKm) { return DistanceMinKm; } return distanceKm > DistanceMaxKm ? DistanceMaxKm : distanceKm; } /// /// Calcule la distance orthodromique (formule de Haversine) entre deux points, en /// kilomètres, arrondie à 2 décimales. R = 6371 km (rayon moyen de la Terre). /// private static decimal HaversineKm(double lat1, double lon1, double lat2, double lon2) { const double R = 6371.0; var dLat = (lat2 - lat1) * Math.PI / 180; var dLon = (lon2 - lon1) * Math.PI / 180; var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + Math.Cos(lat1 * Math.PI / 180) * Math.Cos(lat2 * Math.PI / 180) * Math.Sin(dLon / 2) * Math.Sin(dLon / 2); var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); return Math.Round((decimal)(R * c), 2); } }