using System.Net; using System.Net.Http.Headers; using System.Text.Json; using System.Text.RegularExpressions; namespace LMEMappeCatastaliApp.Services; public sealed partial class FiducialPointService { private const string ServiceBaseUrl = "https://www1.agenziaentrate.gov.it/servizi/Monografie/"; private const string MappedPointsUrl = "https://cartografia.globogis.it/arcgis/rest/services/fiduciali/Punti_fiduciali/MapServer/2/query"; private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; public FiducialPointService() { _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/1.0 (Android)"); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html")); } public async Task> GetProvincesAsync( CancellationToken cancellationToken = default) { var html = await GetHtmlAsync("ricerca.php", cancellationToken); return ParseOptions(html, "provincia"); } public async Task> GetMunicipalitiesAsync( string provinceCode, CancellationToken cancellationToken = default) { var code = NormalizeCode(provinceCode); var html = await GetHtmlAsync($"ricerca.php?level=1&prov={Uri.EscapeDataString(code)}", cancellationToken); return ParseOptions(html, "co"); } public async Task> GetSheetsAsync( string provinceCode, string municipalityCode, CancellationToken cancellationToken = default) { var province = NormalizeCode(provinceCode); var municipality = NormalizeCode(municipalityCode); var path = $"ricerca.php?level=2&prov={Uri.EscapeDataString(province)}"; var html = await PostFormAsync(path, new Dictionary { ["co"] = municipality }, cancellationToken); return ParseOptions(html, "foglio"); } public async Task> GetFiducialsAsync( string provinceName, string municipalityName, string municipalityCode, string sheet, CancellationToken cancellationToken = default) { var code = NormalizeCode(municipalityCode); var normalizedSheet = sheet.Trim(); var query = string.Join("&", [ $"provincia={Uri.EscapeDataString(provinceName.Trim())}", $"comune={Uri.EscapeDataString(municipalityName.Trim())}", $"co={Uri.EscapeDataString(code)}" ]); var html = await PostFormAsync( $"risultato.php?{query}", new Dictionary { ["foglio"] = normalizedSheet }, cancellationToken); return ParseFiducialLinks(html); } public async Task> GetFiducialsAsync( string municipalityCode, string sheet, CancellationToken cancellationToken = default) { var code = NormalizeCode(municipalityCode); var normalizedSheet = NormalizeSheet(sheet); var html = await GetHtmlAsync( $"risultato.php?co={Uri.EscapeDataString(code)}&foglio={Uri.EscapeDataString(normalizedSheet)}", cancellationToken); return ParseFiducialLinks(html); } private static IReadOnlyList ParseFiducialLinks(string html) => DownloadLinkRegex().Matches(html) .Select(match => { var relativeUrl = WebUtility.HtmlDecode(match.Groups["url"].Value); var fileName = WebUtility.HtmlDecode(match.Groups["name"].Value); return new FiducialPointEntry( FormatIdentifier(fileName), new Uri(new Uri(ServiceBaseUrl), relativeUrl).AbsoluteUri); }) .DistinctBy(item => item.Identifier, StringComparer.OrdinalIgnoreCase) .OrderBy(item => item.Identifier, StringComparer.OrdinalIgnoreCase) .ToArray(); public async Task> GetMappedFiducialsAsync( string municipalityCode, string sheet, CancellationToken cancellationToken = default) { var municipality = NormalizeCode(municipalityCode); if (!int.TryParse(NormalizeSheet(sheet), out var sheetNumber)) { throw new ArgumentException("Numero di foglio non valido.", nameof(sheet)); } var query = new Dictionary { ["f"] = "json", ["where"] = $"COMUNENAZI='{municipality}' AND FOGLIO={sheetNumber}", ["outFields"] = "ETICHETTA,PARTICELLA,NORD,EST,ATTENDIBIL,QUOTA,DATAAGGIOR,IDORIGINE,URL_AGENZI,FOGLIO,ALLEGATO,FIDUCIALE", ["returnGeometry"] = "true", ["outSR"] = "3857", ["resultRecordCount"] = "500" }; var url = $"{MappedPointsUrl}?{string.Join('&', query.Select(item => $"{item.Key}={Uri.EscapeDataString(item.Value)}"))}"; using var response = await _httpClient.GetAsync(url, cancellationToken); response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); if (document.RootElement.TryGetProperty("error", out var error)) { throw new InvalidOperationException(error.TryGetProperty("message", out var message) ? message.GetString() : "Servizio cartografico PF non disponibile."); } if (!document.RootElement.TryGetProperty("features", out var features)) { return []; } return features.EnumerateArray() .Select(feature => ParseMappedPoint(feature, municipality)) .Where(point => point is not null) .Cast() .DistinctBy(point => point.Identifier, StringComparer.OrdinalIgnoreCase) .OrderBy(point => point.Identifier, StringComparer.OrdinalIgnoreCase) .ToArray(); } private static MappedFiducialPoint? ParseMappedPoint(JsonElement feature, string municipalityCode) { if (!feature.TryGetProperty("geometry", out var geometry) || !TryGetDouble(geometry, "x", out var x) || !TryGetDouble(geometry, "y", out var y)) { return null; } var attributes = feature.GetProperty("attributes"); var label = GetString(attributes, "ETICHETTA"); var identifier = FormatIdentifier(label); if (identifier == label) { var sheet = GetString(attributes, "FOGLIO").PadLeft(4, '0'); var number = GetString(attributes, "FIDUCIALE").PadLeft(2, '0'); identifier = $"PF{number}/{sheet}/{municipalityCode}"; } const double earthRadius = 6378137d; var longitude = x / earthRadius * 180d / Math.PI; var latitude = (2d * Math.Atan(Math.Exp(y / earthRadius)) - Math.PI / 2d) * 180d / Math.PI; return new MappedFiducialPoint( identifier, latitude, longitude, GetNullableDouble(attributes, "NORD"), GetNullableDouble(attributes, "EST"), GetString(attributes, "ATTENDIBIL"), GetNullableDouble(attributes, "QUOTA"), GetString(attributes, "PARTICELLA"), GetString(attributes, "DATAAGGIOR"), GetString(attributes, "IDORIGINE")); } private static bool TryGetDouble(JsonElement element, string name, out double value) { value = 0; return element.TryGetProperty(name, out var property) && (property.ValueKind == JsonValueKind.Number ? property.TryGetDouble(out value) : double.TryParse(property.ToString(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out value)); } private static double? GetNullableDouble(JsonElement element, string name) => TryGetDouble(element, name, out var value) ? value : null; private static string GetString(JsonElement element, string name) => element.TryGetProperty(name, out var property) && property.ValueKind != JsonValueKind.Null ? property.ToString().Trim() : string.Empty; private static string NormalizeSheet(string sheet) { var digits = new string((sheet ?? string.Empty).Where(char.IsDigit).ToArray()); if (string.IsNullOrWhiteSpace(digits)) { throw new ArgumentException("Numero di foglio non valido.", nameof(sheet)); } return int.TryParse(digits, out var number) ? number.ToString(System.Globalization.CultureInfo.InvariantCulture) : digits; } private async Task GetHtmlAsync(string relativeUrl, CancellationToken cancellationToken) { using var response = await _httpClient.GetAsync(ServiceBaseUrl + relativeUrl, cancellationToken); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(cancellationToken); } private async Task PostFormAsync( string relativeUrl, IReadOnlyDictionary values, CancellationToken cancellationToken) { using var content = new FormUrlEncodedContent(values); using var response = await _httpClient.PostAsync(ServiceBaseUrl + relativeUrl, content, cancellationToken); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(cancellationToken); } private static IReadOnlyList ParseOptions(string html, string selectId) { var select = SelectRegex().Matches(html) .LastOrDefault(match => string.Equals(match.Groups["id"].Value, selectId, StringComparison.OrdinalIgnoreCase)); if (select is null) { return []; } return OptionRegex().Matches(select.Groups["body"].Value) .Select(match => new FiducialAreaOption( WebUtility.HtmlDecode(match.Groups["value"].Value).Trim(), WebUtility.HtmlDecode(StripTagsRegex().Replace(match.Groups["label"].Value, string.Empty)).Trim())) .Where(item => !string.IsNullOrWhiteSpace(item.Code) && !string.IsNullOrWhiteSpace(item.Name)) .DistinctBy(item => item.Code, StringComparer.OrdinalIgnoreCase) .ToArray(); } private static string NormalizeCode(string value) { var normalized = value.Trim().ToUpperInvariant(); if (normalized.Length is < 2 or > 4 || normalized.Any(character => !char.IsLetterOrDigit(character))) { throw new ArgumentException("Codice territoriale non valido.", nameof(value)); } return normalized; } private static string FormatIdentifier(string fileName) { var parts = fileName.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); return parts.Length == 3 ? $"PF{parts[2]}/{parts[1]}/{parts[0]}" : fileName; } [GeneratedRegex("]*(?:id|name)=[\\\"'](?[^\\\"']+)[\\\"'][^>]*>(?[\\s\\S]*?)", RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 2000)] private static partial Regex SelectRegex(); [GeneratedRegex("]*value=[\\\"'](?[^\\\"']*)[\\\"'][^>]*>(?