using System.IO.Compression; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.Utils; using SatelliteProvider.DataAccess.Repositories; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace SatelliteProvider.Services; public class RouteProcessingService : BackgroundService { private readonly IRouteRepository _routeRepository; private readonly IRegionRepository _regionRepository; private readonly IServiceProvider _serviceProvider; private readonly StorageConfig _storageConfig; private readonly ILogger _logger; private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5); public RouteProcessingService( IRouteRepository routeRepository, IRegionRepository regionRepository, IServiceProvider serviceProvider, IOptions storageConfig, ILogger logger) { _routeRepository = routeRepository; _regionRepository = regionRepository; _serviceProvider = serviceProvider; _storageConfig = storageConfig.Value; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("Route Processing Service started"); while (!stoppingToken.IsCancellationRequested) { try { await ProcessPendingRoutesAsync(stoppingToken); } catch (Exception ex) { _logger.LogError(ex, "Error in route processing service"); } await Task.Delay(_checkInterval, stoppingToken); } _logger.LogInformation("Route Processing Service stopped"); } private async Task ProcessPendingRoutesAsync(CancellationToken cancellationToken) { var pendingRoutes = await GetRoutesWithPendingMapsAsync(); foreach (var route in pendingRoutes) { if (cancellationToken.IsCancellationRequested) break; try { await ProcessRouteSequentiallyAsync(route.Id, cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "Error processing route {RouteId}", route.Id); } } } private async Task> GetRoutesWithPendingMapsAsync() { var routes = await _routeRepository.GetRoutesWithPendingMapsAsync(); return routes.Select(r => (r.Id, r.RequestMaps)).ToList(); } private async Task ProcessRouteSequentiallyAsync(Guid routeId, CancellationToken cancellationToken) { var route = await _routeRepository.GetByIdAsync(routeId); if (route == null) { _logger.LogWarning("Route {RouteId}: Route not found, skipping processing", routeId); return; } if (!route.RequestMaps || route.MapsReady) { return; } var routePointsList = (await _routeRepository.GetRoutePointsAsync(routeId)).ToList(); var regionIdsList = (await _routeRepository.GetRegionIdsByRouteAsync(routeId)).ToList(); var geofenceRegionIdsList = (await _routeRepository.GetGeofenceRegionIdsByRouteAsync(routeId)).ToList(); var allRegionIds = regionIdsList.Union(geofenceRegionIdsList).ToList(); if (regionIdsList.Count == 0 && routePointsList.Count > 0) { using var scope = _serviceProvider.CreateScope(); var regionService = scope.ServiceProvider.GetRequiredService(); foreach (var point in routePointsList) { var regionId = Guid.NewGuid(); await regionService.RequestRegionAsync( regionId, point.Latitude, point.Longitude, route.RegionSizeMeters, route.ZoomLevel, stitchTiles: false); await _routeRepository.LinkRouteToRegionAsync(routeId, regionId); } return; } var regions = new List(); foreach (var regionId in allRegionIds) { var region = await _regionRepository.GetByIdAsync(regionId); if (region != null) { regions.Add(region); } } var completedRegions = regions.Where(r => r.Status == "completed").ToList(); var failedRegions = regions.Where(r => r.Status == "failed").ToList(); var processingRegions = regions.Where(r => r.Status == "queued" || r.Status == "processing").ToList(); var completedRoutePointRegions = completedRegions.Where(r => !geofenceRegionIdsList.Contains(r.Id)).ToList(); var completedGeofenceRegions = completedRegions.Where(r => geofenceRegionIdsList.Contains(r.Id)).ToList(); var hasRoutePointRegions = regionIdsList.Count > 0; var hasEnoughRoutePointRegions = !hasRoutePointRegions || completedRoutePointRegions.Count >= routePointsList.Count; var hasAllGeofenceRegions = geofenceRegionIdsList.Count == 0 || completedGeofenceRegions.Count >= geofenceRegionIdsList.Count; var hasEnoughCompleted = hasEnoughRoutePointRegions && hasAllGeofenceRegions; var activeRegions = completedRegions.Count + processingRegions.Count; var shouldRetryFailed = failedRegions.Count > 0 && !hasEnoughCompleted && activeRegions < allRegionIds.Count; if (hasEnoughCompleted) { var orderedRouteRegions = MatchRegionsToRoutePoints(routePointsList, completedRoutePointRegions, routeId); var routeRegionIds = orderedRouteRegions.Select(r => r.Id).ToList(); var allRegionIdsForStitching = routeRegionIds.Concat(completedGeofenceRegions.Select(r => r.Id)).Distinct(); var geofenceRegionIdsForBorders = completedGeofenceRegions.Select(r => r.Id).ToList(); await GenerateRouteMapsAsync(routeId, route, allRegionIdsForStitching, geofenceRegionIdsForBorders, routePointsList, cancellationToken); return; } if (shouldRetryFailed) { using var scope = _serviceProvider.CreateScope(); var regionService = scope.ServiceProvider.GetRequiredService(); foreach (var failedRegion in failedRegions) { var newRegionId = Guid.NewGuid(); await regionService.RequestRegionAsync( newRegionId, failedRegion.Latitude, failedRegion.Longitude, failedRegion.SizeMeters, failedRegion.ZoomLevel, stitchTiles: false); await _routeRepository.LinkRouteToRegionAsync(routeId, newRegionId); } return; } var anyProcessing = processingRegions.Count > 0; if (anyProcessing) { return; } _logger.LogWarning("Route {RouteId}: No processing regions, but not enough completed. This is an unexpected state - hasEnoughCompleted={HasEnough}, shouldRetryFailed={ShouldRetry}, anyProcessing={AnyProcessing}", routeId, hasEnoughCompleted, shouldRetryFailed, anyProcessing); } private async Task GenerateRouteMapsAsync( Guid routeId, DataAccess.Models.RouteEntity route, IEnumerable regionIds, List geofenceRegionIds, List routePoints, CancellationToken cancellationToken) { try { var readyDir = _storageConfig.ReadyDirectory; Directory.CreateDirectory(readyDir); var allTiles = new Dictionary(); int totalTilesFromRegions = 0; int duplicateTiles = 0; foreach (var regionId in regionIds) { var region = await _regionRepository.GetByIdAsync(regionId); if (region == null || string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath)) { _logger.LogWarning("Route {RouteId}: Region {RegionId} CSV not found", routeId, regionId); continue; } var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken); var lineNumber = 0; foreach (var line in csvLines.Skip(1)) { lineNumber++; var parts = line.Split(','); if (parts.Length < 3) continue; if (!double.TryParse(parts[0], out var lat)) { continue; } if (!double.TryParse(parts[1], out var lon)) { continue; } var filePath = parts[2]; totalTilesFromRegions++; var key = $"{lat:F6}_{lon:F6}"; if (!allTiles.ContainsKey(key)) { allTiles[key] = new TileInfo { Latitude = lat, Longitude = lon, FilePath = filePath }; } else { duplicateTiles++; } } } var csvPath = Path.Combine(readyDir, $"route_{routeId}_ready.csv"); await GenerateRouteCsvAsync(csvPath, allTiles.Values, cancellationToken); string? stitchedImagePath = null; if (route.RequestMaps) { var geofencePolygonBounds = new List<(int MinX, int MinY, int MaxX, int MaxY)>(); var geofencesByPolygon = await _routeRepository.GetGeofenceRegionsByPolygonAsync(routeId); foreach (var (polygonIndex, polygonRegionIds) in geofencesByPolygon.OrderBy(kvp => kvp.Key)) { int? minX = null, minY = null, maxX = null, maxY = null; foreach (var geofenceId in polygonRegionIds) { var region = await _regionRepository.GetByIdAsync(geofenceId); if (region != null && !string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath)) { var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken); foreach (var line in csvLines.Skip(1)) { var parts = line.Split(','); if (parts.Length >= 3) { if (double.TryParse(parts[0], out var lat) && double.TryParse(parts[1], out var lon)) { var tile = GeoUtils.WorldToTilePos(new Common.DTO.GeoPoint { Lat = lat, Lon = lon }, route.ZoomLevel); minX = minX == null ? tile.x : Math.Min(minX.Value, tile.x); minY = minY == null ? tile.y : Math.Min(minY.Value, tile.y); maxX = maxX == null ? tile.x : Math.Max(maxX.Value, tile.x); maxY = maxY == null ? tile.y : Math.Max(maxY.Value, tile.y); } } } } } if (minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue) { geofencePolygonBounds.Add((minX.Value, minY.Value, maxX.Value, maxY.Value)); } } stitchedImagePath = Path.Combine(readyDir, $"route_{routeId}_stitched.jpg"); await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, geofencePolygonBounds, routePoints, cancellationToken); } string? tilesZipPath = null; if (route.CreateTilesZip) { tilesZipPath = Path.Combine(readyDir, $"route_{routeId}_tiles.zip"); await CreateTilesZipAsync(tilesZipPath, allTiles.Values, cancellationToken); } var summaryPath = Path.Combine(readyDir, $"route_{routeId}_summary.txt"); await GenerateRouteSummaryAsync(summaryPath, route, allTiles.Count, totalTilesFromRegions, duplicateTiles, tilesZipPath, cancellationToken); route.MapsReady = true; route.CsvFilePath = csvPath; route.SummaryFilePath = summaryPath; route.StitchedImagePath = stitchedImagePath; route.TilesZipPath = tilesZipPath; route.UpdatedAt = DateTime.UtcNow; await _routeRepository.UpdateRouteAsync(route); await CleanupRegionFilesAsync(regionIds, cancellationToken); _logger.LogInformation("Route {RouteId} maps processing completed successfully", routeId); } catch (Exception ex) { _logger.LogError(ex, "Error generating maps for route {RouteId}", routeId); throw; } } private async Task CleanupRegionFilesAsync(IEnumerable regionIds, CancellationToken cancellationToken) { foreach (var regionId in regionIds) { var region = await _regionRepository.GetByIdAsync(regionId); if (region == null) continue; if (!string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath)) { try { File.Delete(region.CsvFilePath); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete region CSV file: {FilePath}", region.CsvFilePath); } } if (!string.IsNullOrEmpty(region.SummaryFilePath) && File.Exists(region.SummaryFilePath)) { try { File.Delete(region.SummaryFilePath); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete region summary file: {FilePath}", region.SummaryFilePath); } } var readyDir = _storageConfig.ReadyDirectory; var stitchedImagePath = Path.Combine(readyDir, $"region_{regionId}_stitched.jpg"); if (File.Exists(stitchedImagePath)) { try { File.Delete(stitchedImagePath); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete region stitched image: {FilePath}", stitchedImagePath); } } } } private async Task GenerateRouteCsvAsync( string filePath, IEnumerable tiles, CancellationToken cancellationToken) { var orderedTiles = tiles.OrderByDescending(t => t.Latitude).ThenBy(t => t.Longitude).ToList(); using var writer = new StreamWriter(filePath); await writer.WriteLineAsync("latitude,longitude,file_path"); foreach (var tile in orderedTiles) { await writer.WriteLineAsync($"{tile.Latitude:F6},{tile.Longitude:F6},{tile.FilePath}"); } _logger.LogInformation("Route CSV generated: {FilePath} with {Count} tiles", filePath, orderedTiles.Count); } private async Task GenerateRouteSummaryAsync( string filePath, DataAccess.Models.RouteEntity route, int uniqueTiles, int totalTilesFromRegions, int duplicateTiles, string? tilesZipPath, CancellationToken cancellationToken) { var summary = new System.Text.StringBuilder(); summary.AppendLine("Route Maps Summary"); summary.AppendLine("=================="); summary.AppendLine($"Route ID: {route.Id}"); summary.AppendLine($"Route Name: {route.Name}"); if (!string.IsNullOrEmpty(route.Description)) { summary.AppendLine($"Description: {route.Description}"); } summary.AppendLine($"Total Points: {route.TotalPoints}"); summary.AppendLine($"Total Distance: {route.TotalDistanceMeters:F2} meters"); summary.AppendLine($"Region Size: {route.RegionSizeMeters:F0} meters"); summary.AppendLine($"Zoom Level: {route.ZoomLevel}"); summary.AppendLine(); summary.AppendLine("Tile Statistics:"); summary.AppendLine($"- Unique Tiles: {uniqueTiles}"); summary.AppendLine($"- Total Tiles from Regions: {totalTilesFromRegions}"); summary.AppendLine($"- Duplicate Tiles (overlap): {duplicateTiles}"); summary.AppendLine(); summary.AppendLine("Files Created:"); summary.AppendLine($"- CSV: route_{route.Id}_ready.csv"); summary.AppendLine($"- Summary: route_{route.Id}_summary.txt"); if (route.RequestMaps) { summary.AppendLine($"- Stitched Map: route_{route.Id}_stitched.jpg"); } if (tilesZipPath != null) { summary.AppendLine($"- Tiles ZIP: route_{route.Id}_tiles.zip"); } summary.AppendLine(); summary.AppendLine($"Completed: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); await File.WriteAllTextAsync(filePath, summary.ToString(), cancellationToken); _logger.LogInformation("Route summary generated: {FilePath}", filePath); } private async Task StitchRouteTilesAsync( List tiles, string outputPath, int zoomLevel, List<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds, List routePoints, CancellationToken cancellationToken) { if (tiles.Count == 0) { _logger.LogWarning("No tiles to stitch for route map"); return; } const int tileSizePixels = 256; var tileCoords = tiles.Select(t => { var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath); return new { t.Latitude, t.Longitude, t.FilePath, TileX = tileX, TileY = tileY }; }).Where(t => t.TileX >= 0 && t.TileY >= 0).ToList(); var minX = tileCoords.Min(t => t.TileX); var maxX = tileCoords.Max(t => t.TileX); var minY = tileCoords.Min(t => t.TileY); var maxY = tileCoords.Max(t => t.TileY); var gridWidth = maxX - minX + 1; var gridHeight = maxY - minY + 1; var imageWidth = gridWidth * tileSizePixels; var imageHeight = gridHeight * tileSizePixels; using var stitchedImage = new Image(imageWidth, imageHeight); stitchedImage.Mutate(ctx => ctx.BackgroundColor(Color.Black)); var uniqueTileCoords = tileCoords .GroupBy(t => $"{t.TileX}_{t.TileY}") .Select(g => g.First()) .OrderBy(t => t.TileY) .ThenBy(t => t.TileX) .ToList(); int placedTiles = 0; int missingTiles = 0; foreach (var tile in uniqueTileCoords) { var destX = (tile.TileX - minX) * tileSizePixels; var destY = (tile.TileY - minY) * tileSizePixels; if (File.Exists(tile.FilePath)) { try { using var tileImage = await Image.LoadAsync(tile.FilePath, cancellationToken); stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f)); placedTiles++; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to load tile at {FilePath}, leaving black", tile.FilePath); missingTiles++; } } else { _logger.LogWarning("Tile file not found: {FilePath}, leaving black", tile.FilePath); missingTiles++; } } if (geofencePolygonBounds.Count > 0) { for (int i = 0; i < geofencePolygonBounds.Count; i++) { var (geoMinX, geoMinY, geoMaxX, geoMaxY) = geofencePolygonBounds[i]; var x1 = (geoMinX - minX) * tileSizePixels; var y1 = (geoMinY - minY + 1) * tileSizePixels; var x2 = (geoMaxX - minX + 2) * tileSizePixels - 1; var y2 = (geoMaxY - minY + 1) * tileSizePixels - 1; x1 = Math.Max(0, Math.Min(x1, imageWidth - 1)); y1 = Math.Max(0, Math.Min(y1, imageHeight - 1)); x2 = Math.Max(0, Math.Min(x2, imageWidth - 1)); y2 = Math.Max(0, Math.Min(y2, imageHeight - 1)); if (x1 >= 0 && y1 >= 0 && x2 < imageWidth && y2 < imageHeight && x2 > x1 && y2 > y1) { DrawRectangleBorder(stitchedImage, x1, y1, x2, y2, new Rgb24(255, 255, 0)); } } } foreach (var point in routePoints) { var geoPoint = new Common.DTO.GeoPoint { Lat = point.Latitude, Lon = point.Longitude }; var (tileX, tileY) = GeoUtils.WorldToTilePos(geoPoint, zoomLevel); var pixelX = (tileX - minX) * tileSizePixels + tileSizePixels / 2; var pixelY = (tileY - minY) * tileSizePixels + tileSizePixels / 2; if (pixelX >= 0 && pixelX < imageWidth && pixelY >= 0 && pixelY < imageHeight) { DrawCross(stitchedImage, pixelX, pixelY, new Rgb24(255, 0, 0), 50); } } await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken); } private List MatchRegionsToRoutePoints( List routePoints, List regions, Guid routeId) { var orderedRegions = new List(); var availableRegions = new List(regions); foreach (var point in routePoints) { var matchedRegion = availableRegions .OrderBy(r => CalculateDistance(point.Latitude, point.Longitude, r.Latitude, r.Longitude)) .FirstOrDefault(); if (matchedRegion != null) { orderedRegions.Add(matchedRegion); availableRegions.Remove(matchedRegion); } } return orderedRegions; } private static double CalculateDistance(double lat1, double lon1, double lat2, double lon2) { const double earthRadiusMeters = 6371000; var dLat = (lat2 - lat1) * Math.PI / 180.0; var dLon = (lon2 - lon1) * Math.PI / 180.0; var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + Math.Cos(lat1 * Math.PI / 180.0) * Math.Cos(lat2 * Math.PI / 180.0) * Math.Sin(dLon / 2) * Math.Sin(dLon / 2); var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); return earthRadiusMeters * c; } private static (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath) { try { var filename = Path.GetFileNameWithoutExtension(filePath); var parts = filename.Split('_'); if (parts.Length >= 4 && parts[0] == "tile") { if (int.TryParse(parts[2], out var tileX) && int.TryParse(parts[3], out var tileY)) { return (tileX, tileY); } } } catch { } return (-1, -1); } private static void DrawRectangleBorder(Image image, int x1, int y1, int x2, int y2, Rgb24 color) { const int thickness = 5; for (int t = 0; t < thickness; t++) { for (int x = x1; x <= x2; x++) { int topY = y1 + t; int bottomY = y2 - t; if (x >= 0 && x < image.Width && topY >= 0 && topY < image.Height) image[x, topY] = color; if (x >= 0 && x < image.Width && bottomY >= 0 && bottomY < image.Height) image[x, bottomY] = color; } for (int y = y1; y <= y2; y++) { int leftX = x1 + t; int rightX = x2 - t; if (leftX >= 0 && leftX < image.Width && y >= 0 && y < image.Height) image[leftX, y] = color; if (rightX >= 0 && rightX < image.Width && y >= 0 && y < image.Height) image[rightX, y] = color; } } } private static void DrawCross(Image image, int centerX, int centerY, Rgb24 color, int armLength) { const int thickness = 10; int halfThickness = thickness / 2; for (int dx = -armLength; dx <= armLength; dx++) { for (int t = -halfThickness; t <= halfThickness; t++) { int x = centerX + dx; int y = centerY + t; if (x >= 0 && x < image.Width && y >= 0 && y < image.Height) image[x, y] = color; } } for (int dy = -armLength; dy <= armLength; dy++) { for (int t = -halfThickness; t <= halfThickness; t++) { int x = centerX + t; int y = centerY + dy; if (x >= 0 && x < image.Width && y >= 0 && y < image.Height) image[x, y] = color; } } } private Task CreateTilesZipAsync( string zipFilePath, IEnumerable tiles, CancellationToken cancellationToken) { return Task.Run(() => { if (File.Exists(zipFilePath)) { File.Delete(zipFilePath); } using var zipArchive = ZipFile.Open(zipFilePath, ZipArchiveMode.Create); int addedFiles = 0; int missingFiles = 0; var tilesBasePath = _storageConfig.TilesDirectory; var normalizedBasePath = Path.GetFullPath(tilesBasePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); foreach (var tile in tiles) { if (cancellationToken.IsCancellationRequested) break; if (File.Exists(tile.FilePath)) { try { var fullPath = Path.GetFullPath(tile.FilePath); string entryName; if (fullPath.StartsWith(normalizedBasePath, StringComparison.OrdinalIgnoreCase)) { var relativePath = fullPath.Substring(normalizedBasePath.Length + 1); relativePath = relativePath.Replace(Path.DirectorySeparatorChar, '/'); entryName = "tiles/" + relativePath; } else { entryName = "tiles/" + Path.GetFileName(tile.FilePath); } zipArchive.CreateEntryFromFile(tile.FilePath, entryName, CompressionLevel.Optimal); addedFiles++; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to add tile to zip: {FilePath}", tile.FilePath); } } else { _logger.LogWarning("Tile file not found for zip: {FilePath}", tile.FilePath); missingFiles++; } } _logger.LogInformation("Tiles zip created: {ZipPath} with {AddedFiles} tiles ({MissingFiles} missing)", zipFilePath, addedFiles, missingFiles); }, cancellationToken); } } public class TileInfo { public double Latitude { get; set; } public double Longitude { get; set; } public string FilePath { get; set; } = string.Empty; }