LSFE/LSFE.MobApp/Services/AddressService.cs
2026-07-30 10:31:04 +08:00

658 lines
25 KiB
C#
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<AddressService> _logger;
private readonly Repository<Entities.AddressCache> _addressCache;
// Known location overrides for areas with incorrect OSM data
private static readonly List<LocationOverride> _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<AddressService> logger, Repository<Entities.AddressCache> 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<Permissions.LocationWhenInUse>();
if (status != PermissionStatus.Granted)
status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
if (status != PermissionStatus.Granted)
throw new Exception("Location permission is required.");
}
// ================== RETRY GEOCODING ==================
/// <summary>
/// Retries geocoding for a coordinate-only address when internet becomes available.
/// Use this to convert "Lat: X, Lng: Y" back to human-readable addresses.
/// </summary>
/// <param name="lat">Latitude</param>
/// <param name="lng">Longitude</param>
/// <returns>Tuple of (address, isHumanReadable)</returns>
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);
}
/// <summary>
/// Clears cached address for a specific location
/// </summary>
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<Entities.AddressCache?> 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);
}
/// <summary>
/// Checks if an address is human-readable or just coordinates
/// </summary>
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;
}
/// <summary>
/// Checks if coordinates fall within a known override area
/// </summary>
private LocationOverride? GetLocationOverride(double lat, double lng)
{
return _locationOverrides.FirstOrDefault(o =>
lat >= o.MinLat && lat <= o.MaxLat &&
lng >= o.MinLng && lng <= o.MaxLng);
}
/// <summary>
/// Applies override data to correct wrong API results
/// </summary>
private string ApplyLocationOverride(string? apiAddress, LocationOverride ovr)
{
var parts = new List<string>();
// 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);
}
/// <summary>
/// Enhances address with nearby POI to get building numbers and names
/// </summary>
private async Task<string> 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<string> { 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;
}
/// <summary>
/// Gets exact building/POI at coordinates using Overpass API
/// </summary>
private async Task<POIInfo?> 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;
}
}
/// <summary>
/// Calculate distance between two coordinates in meters
/// </summary>
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<string?> 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<string?> 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<string?> 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<string>();
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<string>();
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<string> RemoveDuplicatesAndSimilar(List<string> parts)
{
var result = new List<string>();
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<string> 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; }
}
}