using System.Globalization; using System.Net.Http.Json; using System.Text.Json.Serialization; using LehrerApp.Core.Models; namespace LehrerApp.Api; public interface IGeocoder { Task GeocodeAsync(SchoolLocationRequest address, CancellationToken cancellationToken); } public sealed record GeocodingResult(double Latitude, double Longitude, string DisplayName); /// Einmalige, explizit ausgelöste Adressauflösung über den öffentlichen OSM-Nominatim- /// Dienst. Kein Autocomplete; die dauerhafte Ergebnisspeicherung übernimmt SchoolWeatherStore. public sealed class NominatimGeocoder(HttpClient http) : IGeocoder { private static readonly SemaphoreSlim RequestGate = new(1, 1); private static DateTime _lastRequestAt = DateTime.MinValue; public async Task GeocodeAsync(SchoolLocationRequest address, CancellationToken cancellationToken) { var structured = Query(new Dictionary { ["street"] = address.Street, ["postalcode"] = address.PostalCode, ["city"] = address.City, }); var result = await SearchRespectingRateLimitAsync(structured, cancellationToken); if (result is not null) return result; // Manche Schulen/Ortsteile sind bei OSM anders adressiert. Ein kontrollierter zweiter // Versuch mit der gesamten Suchzeile ist robuster, bleibt aber weit weg vom 1-rps-Limit. var freeForm = string.Join(", ", new[] { address.SchoolName, address.Street, $"{address.PostalCode} {address.City}", "Deutschland", }.Where(x => !string.IsNullOrWhiteSpace(x))); return await SearchRespectingRateLimitAsync( Query(new Dictionary { ["q"] = freeForm }), cancellationToken); } private async Task SearchRespectingRateLimitAsync(string query, CancellationToken cancellationToken) { await RequestGate.WaitAsync(cancellationToken); try { var wait = TimeSpan.FromSeconds(1) - (DateTime.UtcNow - _lastRequestAt); if (wait > TimeSpan.Zero) await Task.Delay(wait, cancellationToken); _lastRequestAt = DateTime.UtcNow; var candidates = await http.GetFromJsonAsync>( $"search?format=jsonv2&limit=1&countrycodes=de{query}", cancellationToken); var candidate = candidates?.FirstOrDefault(); if (candidate is null || !double.TryParse(candidate.Lat, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude) || !double.TryParse(candidate.Lon, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude)) return null; return new(latitude, longitude, candidate.DisplayName ?? ""); } finally { RequestGate.Release(); } } private static string Query(IReadOnlyDictionary values) => string.Concat( values.Where(x => !string.IsNullOrWhiteSpace(x.Value)) .Select(x => $"&{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value.Trim())}")); private sealed class NominatimCandidate { [JsonPropertyName("lat")] public string? Lat { get; set; } [JsonPropertyName("lon")] public string? Lon { get; set; } [JsonPropertyName("display_name")] public string? DisplayName { get; set; } } }