using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Maui.Devices.Sensors; namespace LMEMappeCatastaliApp.Services; public sealed class LocationSearchService { private const string NominatimEndpoint = "https://nominatim.openstreetmap.org/search"; private const string ArcGisEndpoint = "https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates"; private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(20) }; private readonly SemaphoreSlim _requestLock = new(1, 1); private readonly Dictionary _cache = new(StringComparer.OrdinalIgnoreCase); private DateTimeOffset _lastRemoteRequest = DateTimeOffset.MinValue; public LocationSearchService() { _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.2"); _httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json"); _httpClient.DefaultRequestHeaders.AcceptLanguage.ParseAdd("it-IT"); } public async Task SearchAsync(string text, CancellationToken cancellationToken = default) { var query = text.Trim(); if (string.IsNullOrWhiteSpace(query)) { return null; } if (TryParseCoordinates(query, out var coordinates)) { return coordinates; } if (_cache.TryGetValue(query, out var cached)) { return cached; } var nativeResult = await TryNativeGeocodingAsync(query); if (nativeResult is not null) { _cache[query] = nativeResult; return nativeResult; } var arcGisResult = await SearchArcGisAsync(query, cancellationToken); if (arcGisResult is not null) { return arcGisResult; } var remoteResult = await SearchNominatimAsync(query, cancellationToken); _cache[query] = remoteResult; return remoteResult; } private async Task SearchArcGisAsync(string query, CancellationToken cancellationToken) { try { var url = $"{ArcGisEndpoint}?f=json&forStorage=false&sourceCountry=ITA&maxLocations=1&outFields=Match_addr,Addr_type,Score&SingleLine={Uri.EscapeDataString(query)}"; using var response = await _httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) { return null; } await using var content = await response.Content.ReadAsStreamAsync(cancellationToken); var payload = await JsonSerializer.DeserializeAsync(content, cancellationToken: cancellationToken); var candidate = payload?.Candidates?.FirstOrDefault(); if (candidate?.Location is null || candidate.Score < 60) { return null; } return new LocationSearchResult(candidate.Location.Y, candidate.Location.X, candidate.Address ?? query); } catch (OperationCanceledException) { throw; } catch (Exception) { return null; } } private static async Task TryNativeGeocodingAsync(string query) { try { var locations = await Geocoding.Default.GetLocationsAsync(query); var location = locations?.FirstOrDefault(); return location is null ? null : new LocationSearchResult(location.Latitude, location.Longitude, query); } catch (Exception) { return null; } } private async Task SearchNominatimAsync(string query, CancellationToken cancellationToken) { await _requestLock.WaitAsync(cancellationToken); try { var wait = TimeSpan.FromSeconds(1) - (DateTimeOffset.UtcNow - _lastRemoteRequest); if (wait > TimeSpan.Zero) { await Task.Delay(wait, cancellationToken); } var url = $"{NominatimEndpoint}?format=jsonv2&limit=1&accept-language=it&q={Uri.EscapeDataString(query)}"; _lastRemoteRequest = DateTimeOffset.UtcNow; using var response = await _httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) { throw new HttpRequestException($"Il servizio di ricerca ha risposto {((int)response.StatusCode)}."); } await using var content = await response.Content.ReadAsStreamAsync(cancellationToken); var results = await JsonSerializer.DeserializeAsync>(content, cancellationToken: cancellationToken); var result = results?.FirstOrDefault(); if (result is null || !double.TryParse(result.Latitude, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude) || !double.TryParse(result.Longitude, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude)) { return null; } return new LocationSearchResult(latitude, longitude, result.DisplayName ?? query); } finally { _requestLock.Release(); } } private static bool TryParseCoordinates(string query, out LocationSearchResult? result) { result = null; var parts = query.Split([' ', ',', ';'], StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 2 || !double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude) || !double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude) || Math.Abs(latitude) > 90 || Math.Abs(longitude) > 180) { return false; } result = new LocationSearchResult( latitude, longitude, $"Coordinate: {latitude:F6}, {longitude:F6}"); return true; } private sealed record NominatimResult( [property: JsonPropertyName("lat")] string? Latitude, [property: JsonPropertyName("lon")] string? Longitude, [property: JsonPropertyName("display_name")] string? DisplayName); private sealed record ArcGisResponse( [property: JsonPropertyName("candidates")] List? Candidates); private sealed record ArcGisCandidate( [property: JsonPropertyName("address")] string? Address, [property: JsonPropertyName("score")] double Score, [property: JsonPropertyName("location")] ArcGisLocation? Location); private sealed record ArcGisLocation( [property: JsonPropertyName("x")] double X, [property: JsonPropertyName("y")] double Y); } public sealed record LocationSearchResult(double Latitude, double Longitude, string Label);