using LSFE.MobApp.DTO; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.Json; using System.Threading.Tasks; namespace LSFE.MobApp.Services { public class AddressService { private readonly ILogger _logger; private readonly Repository _addressCache; // Known location overrides for areas with incorrect OSM data private static readonly List _locationOverrides = new() { // Lloyd Laboratories - FBIC area (Tikay, not Sumapang Matanda) new LocationOverride { Name = "First Bulacan Industrial City (FBIC)", MinLat = 14.835, MaxLat = 14.842, MinLng = 120.850, MaxLng = 120.860, Street = "Lloyd Avenue", Subdivision = "First Bulacan Industrial City", Barangay = "Tikay", City = "Malolos", Province = "Bulacan", Postcode = "3000" }, // Add more known areas here as needed }; public AddressService(ILogger logger, Repository addressCache) { _logger = logger; _addressCache = addressCache; } public async Task<(LocationResult result, bool isHumanReadable)> GetCurrentLocationAsync() { await EnsurePermissionsAsync(); var location = await Geolocation.Default.GetLastKnownLocationAsync() ?? await Geolocation.Default.GetLocationAsync( new GeolocationRequest( GeolocationAccuracy.High, TimeSpan.FromSeconds(30))); if (location == null) throw new Exception("Unable to get location."); double lat = location.Latitude; double lng = location.Longitude; var (address, isHumanReadable) = await GetAddressAsync(lat, lng); return (new LocationResult { Latitude = lat, Longitude = lng, Address = address }, isHumanReadable); } // ================== PERMISSIONS ================== private async Task EnsurePermissionsAsync() { var status = await Permissions.CheckStatusAsync(); if (status != PermissionStatus.Granted) status = await Permissions.RequestAsync(); if (status != PermissionStatus.Granted) throw new Exception("Location permission is required."); } // ================== RETRY GEOCODING ================== /// /// Retries geocoding for a coordinate-only address when internet becomes available. /// Use this to convert "Lat: X, Lng: Y" back to human-readable addresses. /// /// Latitude /// Longitude /// Tuple of (address, isHumanReadable) public async Task<(string address, bool isHumanReadable)> RetryGeocodingAsync(double lat, double lng) { _logger.LogInformation($"Retrying geocoding for coordinates: {lat}, {lng}"); // Clear cache for this location to force fresh geocoding attempt await ClearCacheForLocationAsync(lat, lng); // Try to geocode again return await GetAddressAsync(lat, lng); } /// /// Clears cached address for a specific location /// private async Task ClearCacheForLocationAsync(double lat, double lng) { const double radius = 0.0002; // ~20 meters double latMin = lat - radius; double latMax = lat + radius; double lngMin = lng - radius; double lngMax = lng + radius; var cachedEntries = await _addressCache.GetAllAsync(); cachedEntries.Where(x => x.Latitude >= latMin && x.Latitude <= latMax && x.Longitude >= lngMin && x.Longitude <= lngMax ); foreach (var entry in cachedEntries) { await _addressCache.DeleteAsync(entry); } } // ================== ADDRESS WITH OVERRIDE ================== private async Task GetNearbyAsync(double lat, double lng) { const double radius = 0.0025; // mobile-safe radius var cutoff = DateTime.UtcNow.AddHours(-24); double latMin = lat - radius; double latMax = lat + radius; double lngMin = lng - radius; double lngMax = lng + radius; var data = await _addressCache.GetAllAsync(); return await _addressCache.FirstOrDefaultAsync(x => x.Latitude >= latMin && x.Latitude <= latMax && x.Longitude >= lngMin && x.Longitude <= lngMax && x.CachedAt >= cutoff ); } public async Task<(string address, bool isHumanReadable)> GetAddressAsync(double lat, double lng) { // 1️⃣ Check cache first var cached = await GetNearbyAsync(lat, lng); if (cached != null) return (cached.Address, IsAddressHumanReadable(cached.Address)); // 2️⃣ Check for location override (for areas with known bad OSM data) var locationOverride = GetLocationOverride(lat, lng); // 3️⃣ Get address from geocoding APIs var geoAddress = await GetGeocodedAddressAsync(lat, lng); // 4️⃣ Apply override corrections if found string finalAddress; bool isHumanReadable; if (locationOverride != null) { finalAddress = ApplyLocationOverride(geoAddress, locationOverride); isHumanReadable = true; // Override always provides human-readable address _logger.LogInformation($"Applied location override for {locationOverride.Name}"); } else if (!string.IsNullOrWhiteSpace(geoAddress)) { // 5️⃣ Verify and enhance with nearby POI search finalAddress = await EnhanceWithNearbyPOI(lat, lng, geoAddress); isHumanReadable = true; // Got address from geocoding API } else { // 6️⃣ No internet or geocoding failed - return coordinates finalAddress = $"Lat: {lat:F6}, Lng: {lng:F6}"; isHumanReadable = false; _logger.LogWarning($"Failed to geocode address, returning coordinates: {finalAddress}"); } // Save to cache (even if it's just coordinates) await SaveCache(lat, lng, finalAddress); return (finalAddress, isHumanReadable); } /// /// Checks if an address is human-readable or just coordinates /// private bool IsAddressHumanReadable(string address) { if (string.IsNullOrWhiteSpace(address)) return false; // If address starts with "Lat:" or contains only coordinates format, it's not human-readable if (address.StartsWith("Lat:", StringComparison.OrdinalIgnoreCase) || address.StartsWith("Latitude:", StringComparison.OrdinalIgnoreCase)) return false; // Check if it's just a coordinate pattern (numbers, dots, commas, spaces) var cleanAddress = address.Replace("Lat:", "").Replace("Lng:", "").Replace("Long:", "") .Replace("Latitude:", "").Replace("Longitude:", "").Trim(); // If after removing coordinate keywords, we only have numbers/dots/commas, it's not human-readable if (cleanAddress.All(c => char.IsDigit(c) || c == '.' || c == ',' || c == ' ' || c == ':' || c == '-')) return false; // If we have actual text (city names, street names, etc.), it's human-readable return true; } /// /// Checks if coordinates fall within a known override area /// private LocationOverride? GetLocationOverride(double lat, double lng) { return _locationOverrides.FirstOrDefault(o => lat >= o.MinLat && lat <= o.MaxLat && lng >= o.MinLng && lng <= o.MaxLng); } /// /// Applies override data to correct wrong API results /// private string ApplyLocationOverride(string? apiAddress, LocationOverride ovr) { var parts = new List(); // Try to extract establishment name from API result if (!string.IsNullOrWhiteSpace(apiAddress)) { var apiParts = apiAddress.Split(',').Select(p => p.Trim()).ToArray(); if (apiParts.Length > 0 && !apiParts[0].Contains("Highway") && !apiParts[0].Contains("Road")) { parts.Add(apiParts[0]); // Keep establishment name from API } } // Use override data for everything else if (!string.IsNullOrWhiteSpace(ovr.Street)) parts.Add(ovr.Street); if (!string.IsNullOrWhiteSpace(ovr.Subdivision)) parts.Add(ovr.Subdivision); if (!string.IsNullOrWhiteSpace(ovr.Barangay)) parts.Add($"Brgy. {ovr.Barangay}"); if (!string.IsNullOrWhiteSpace(ovr.City)) parts.Add($"City of {ovr.City}"); if (!string.IsNullOrWhiteSpace(ovr.Province)) parts.Add(ovr.Province); if (!string.IsNullOrWhiteSpace(ovr.Postcode)) parts.Add(ovr.Postcode); parts.Add("Philippines"); return string.Join(", ", parts); } /// /// Enhances address with nearby POI to get building numbers and names /// private async Task EnhanceWithNearbyPOI(double lat, double lng, string baseAddress) { try { // Use Overpass API to find exact building/POI at this location var poi = await GetNearbyBuildingAsync(lat, lng, radiusMeters: 10); if (poi != null && !string.IsNullOrWhiteSpace(poi.Name)) { // Check if POI name is already in address if (!baseAddress.Contains(poi.Name, StringComparison.OrdinalIgnoreCase)) { // Insert POI at the beginning var parts = baseAddress.Split(',').Select(p => p.Trim()).ToList(); // Build enhanced address with POI var enhanced = new List { poi.Name }; // Add house number if available if (!string.IsNullOrWhiteSpace(poi.HouseNumber)) { enhanced[0] = $"{poi.Name}, {poi.HouseNumber}"; } enhanced.AddRange(parts); return string.Join(", ", RemoveDuplicatesAndSimilar(enhanced)); } } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to enhance address with POI"); } return baseAddress; } /// /// Gets exact building/POI at coordinates using Overpass API /// private async Task GetNearbyBuildingAsync(double lat, double lng, int radiusMeters = 10) { try { using var client = new HttpClient(); client.Timeout = TimeSpan.FromSeconds(10); // Overpass query to find buildings/POI at exact location var query = $@" [out:json][timeout:5]; ( node[""name""](around:{radiusMeters},{lat},{lng}); way[""name""](around:{radiusMeters},{lat},{lng}); node[""building""](around:{radiusMeters},{lat},{lng}); way[""building""](around:{radiusMeters},{lat},{lng}); ); out body; "; var content = new StringContent($"data={Uri.EscapeDataString(query)}"); content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-www-form-urlencoded"); var response = await client.PostAsync("https://overpass-api.de/api/interpreter", content); var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("elements", out var elements)) return null; // Find the closest POI with a name POIInfo? bestPOI = null; double closestDistance = double.MaxValue; foreach (var element in elements.EnumerateArray()) { if (!element.TryGetProperty("tags", out var tags)) continue; var name = GetJsonString(tags, "name"); if (string.IsNullOrWhiteSpace(name)) continue; // Get element coordinates double elemLat = lat, elemLng = lng; if (element.TryGetProperty("lat", out var latProp) && element.TryGetProperty("lon", out var lonProp)) { elemLat = latProp.GetDouble(); elemLng = lonProp.GetDouble(); } // Calculate distance var distance = CalculateDistance(lat, lng, elemLat, elemLng); if (distance < closestDistance) { closestDistance = distance; bestPOI = new POIInfo { Name = name, HouseNumber = GetJsonString(tags, "addr:housenumber"), Street = GetJsonString(tags, "addr:street"), Distance = distance }; } } return bestPOI; } catch (Exception ex) { _logger.LogWarning(ex, "Overpass API query failed"); return null; } } /// /// Calculate distance between two coordinates in meters /// private double CalculateDistance(double lat1, double lng1, double lat2, double lng2) { const double R = 6371000; // Earth radius in meters var dLat = (lat2 - lat1) * Math.PI / 180; var dLng = (lng2 - lng1) * 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(dLng / 2) * Math.Sin(dLng / 2); var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); return R * c; } // ================== GEOCODING (BEST EFFORT) ================== private async Task GetGeocodedAddressAsync(double lat, double lng) { // Try Photon first (unlimited, good for PH) var photon = await GetPhotonAddressAsync(lat, lng); if (!string.IsNullOrWhiteSpace(photon)) return photon; // Fallback to Nominatim var nominatim = await GetNominatimAddressAsync(lat, lng); if (!string.IsNullOrWhiteSpace(nominatim)) return nominatim; return null; } private async Task GetPhotonAddressAsync(double lat, double lng) { try { using var client = new HttpClient(); client.Timeout = TimeSpan.FromSeconds(10); var url = $"https://photon.komoot.io/reverse?lon={lng}&lat={lat}"; var json = await client.GetStringAsync(url); using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("features", out var features) || features.GetArrayLength() == 0) return null; var props = features[0].GetProperty("properties"); return FormatPhotonAddress(props); } catch (Exception ex) { _logger.LogWarning(ex, "Photon geocoding failed"); return null; } } private async Task GetNominatimAddressAsync(double lat, double lng) { try { using var client = new HttpClient(); client.Timeout = TimeSpan.FromSeconds(10); client.DefaultRequestHeaders.Add("User-Agent", "LSFE.MobApp/1.0"); var url = $"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lng}&format=json&addressdetails=1"; var json = await client.GetStringAsync(url); using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("address", out var address)) return null; return FormatNominatimAddress(address); } catch (Exception ex) { _logger.LogWarning(ex, "Nominatim geocoding failed"); return null; } } private string FormatPhotonAddress(JsonElement props) { var parts = new List(); var name = GetJsonString(props, "name"); var street = GetJsonString(props, "street"); var district = GetJsonString(props, "district") ?? GetJsonString(props, "suburb"); var city = GetJsonString(props, "city"); var state = GetJsonString(props, "state"); var postcode = GetJsonString(props, "postcode"); var country = GetJsonString(props, "country"); if (!string.IsNullOrWhiteSpace(name) && name != street) parts.Add(name); if (!string.IsNullOrWhiteSpace(street)) parts.Add(street); if (!string.IsNullOrWhiteSpace(district)) parts.Add($"Brgy. {district}"); if (!string.IsNullOrWhiteSpace(city)) parts.Add($"City of {city}"); if (!string.IsNullOrWhiteSpace(state)) parts.Add(state); if (!string.IsNullOrWhiteSpace(postcode)) parts.Add(postcode); if (!string.IsNullOrWhiteSpace(country)) parts.Add(country); return string.Join(", ", parts.Where(p => !string.IsNullOrWhiteSpace(p))); } private string FormatNominatimAddress(JsonElement address) { var parts = new List(); var industrial = GetJsonString(address, "industrial"); var road = GetJsonString(address, "road"); var suburb = GetJsonString(address, "suburb") ?? GetJsonString(address, "village"); var city = GetJsonString(address, "city") ?? GetJsonString(address, "town"); var state = GetJsonString(address, "state"); var postcode = GetJsonString(address, "postcode"); var country = GetJsonString(address, "country"); if (!string.IsNullOrWhiteSpace(industrial)) parts.Add(industrial); if (!string.IsNullOrWhiteSpace(road)) parts.Add(road); if (!string.IsNullOrWhiteSpace(suburb)) parts.Add($"Brgy. {suburb}"); if (!string.IsNullOrWhiteSpace(city)) parts.Add($"City of {city}"); if (!string.IsNullOrWhiteSpace(state)) parts.Add(state); if (!string.IsNullOrWhiteSpace(postcode)) parts.Add(postcode); if (!string.IsNullOrWhiteSpace(country)) parts.Add(country); return string.Join(", ", parts.Where(p => !string.IsNullOrWhiteSpace(p))); } // ================== HELPERS ================== private string? GetJsonString(JsonElement element, string property) { if (element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String) { return value.GetString(); } return null; } private List RemoveDuplicatesAndSimilar(List parts) { var result = new List(); foreach (var part in parts) { if (string.IsNullOrWhiteSpace(part)) continue; bool isSimilar = false; foreach (var existing in result) { if (IsSimilar(part, existing)) { isSimilar = true; if (part.Length > existing.Length) { result.Remove(existing); result.Add(part); } break; } } if (!isSimilar) result.Add(part); } return result; } private bool IsSimilar(string a, string b) { if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return false; var aLower = a.ToLowerInvariant().Trim(); var bLower = b.ToLowerInvariant().Trim(); if (aLower == bLower) return true; var aClean = aLower.Replace(", inc.", "").Replace(" inc.", "").Replace(" corporation", "").Replace(" corp", "").Trim(); var bClean = bLower.Replace(", inc.", "").Replace(" inc.", "").Replace(" corporation", "").Replace(" corp", "").Trim(); if (aClean.Contains(bClean) || bClean.Contains(aClean)) { var minLength = Math.Min(aClean.Length, bClean.Length); var maxLength = Math.Max(aClean.Length, bClean.Length); return (double)minLength / maxLength >= 0.6; } return false; } private async Task SaveCache(double lat, double lng, string address) { var cacheEntry = new Entities.AddressCache() { Latitude = lat, Longitude = lng, Address = address, CachedAt = DateTime.UtcNow }; var isSuccess = await _addressCache.SaveAsync(cacheEntry); } private async Task GetFromMauiGeocoderAsync(double lat, double lng) { try { var placemark = (await Geocoding.Default.GetPlacemarksAsync(lat, lng))?.FirstOrDefault(); if (placemark == null) return $"Lat: {lat:F6}, Long: {lng:F6}"; return string.Join(", ", new[] { placemark.FeatureName, placemark.Thoroughfare, placemark.SubLocality, placemark.Locality, placemark.AdminArea, placemark.CountryName }.Where(x => !string.IsNullOrWhiteSpace(x))); } catch { return $"Lat: {lat:F6}, Long: {lng:F6}"; } } } // ================== MODELS ================== internal class LocationOverride { public string Name { get; set; } = ""; public double MinLat { get; set; } public double MaxLat { get; set; } public double MinLng { get; set; } public double MaxLng { get; set; } public string? Street { get; set; } public string? Subdivision { get; set; } public string? Barangay { get; set; } public string? City { get; set; } public string? Province { get; set; } public string? Postcode { get; set; } } internal class POIInfo { public string Name { get; set; } = ""; public string? HouseNumber { get; set; } public string? Street { get; set; } public double Distance { get; set; } } }