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 || !route.RequestMaps || route.MapsReady) { return; } var routePointsList = (await _routeRepository.GetRoutePointsAsync(routeId)).ToList(); var regionIdsList = (await _routeRepository.GetRegionIdsByRouteAsync(routeId)).ToList(); if (regionIdsList.Count == 0) { using var scope = _serviceProvider.CreateScope(); var regionService = scope.ServiceProvider.GetRequiredService(); _logger.LogInformation("Route {RouteId}: Starting sequential region processing for {PointCount} points", routeId, routePointsList.Count); var firstPoint = routePointsList.First(); var regionId = Guid.NewGuid(); await regionService.RequestRegionAsync( regionId, firstPoint.Latitude, firstPoint.Longitude, route.RegionSizeMeters, route.ZoomLevel, stitchTiles: false); await _routeRepository.LinkRouteToRegionAsync(routeId, regionId); _logger.LogInformation("Route {RouteId}: Queued first region {RegionId} (1/{TotalPoints})", routeId, regionId, routePointsList.Count); return; } var lastRegion = await _regionRepository.GetByIdAsync(regionIdsList.Last()); if (lastRegion == null) { return; } if (lastRegion.Status == "queued" || lastRegion.Status == "processing") { return; } if (lastRegion.Status == "failed") { _logger.LogError("Route {RouteId}: Region {RegionId} failed. Stopping route processing.", routeId, lastRegion.Id); route.MapsReady = false; route.UpdatedAt = DateTime.UtcNow; await _routeRepository.UpdateRouteAsync(route); return; } if (regionIdsList.Count < routePointsList.Count) { using var scope = _serviceProvider.CreateScope(); var regionService = scope.ServiceProvider.GetRequiredService(); var nextPoint = routePointsList[regionIdsList.Count]; var regionId = Guid.NewGuid(); await regionService.RequestRegionAsync( regionId, nextPoint.Latitude, nextPoint.Longitude, route.RegionSizeMeters, route.ZoomLevel, stitchTiles: false); await _routeRepository.LinkRouteToRegionAsync(routeId, regionId); _logger.LogInformation("Route {RouteId}: Queued next region {RegionId} ({CurrentRegion}/{TotalPoints})", routeId, regionId, regionIdsList.Count + 1, routePointsList.Count); return; } _logger.LogInformation("Route {RouteId}: All {RegionCount} regions completed, generating final maps", routeId, regionIdsList.Count); await GenerateRouteMapsAsync(routeId, route, regionIdsList, cancellationToken); } private async Task GenerateRouteMapsAsync( Guid routeId, DataAccess.Models.RouteEntity route, IEnumerable regionIds, 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("Region {RegionId} CSV not found for route {RouteId}", regionId, routeId); continue; } var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken); foreach (var line in csvLines.Skip(1)) { 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++; } } } _logger.LogInformation("Route {RouteId}: Collected {UniqueCount} unique tiles ({DuplicateCount} duplicates from {TotalCount} total)", routeId, allTiles.Count, duplicateTiles, totalTilesFromRegions); var csvPath = Path.Combine(readyDir, $"route_{routeId}_ready.csv"); await GenerateRouteCsvAsync(csvPath, allTiles.Values, cancellationToken); string? stitchedImagePath = null; if (route.RequestMaps) { stitchedImagePath = Path.Combine(readyDir, $"route_{routeId}_stitched.jpg"); await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, cancellationToken); } var summaryPath = Path.Combine(readyDir, $"route_{routeId}_summary.txt"); await GenerateRouteSummaryAsync(summaryPath, route, allTiles.Count, totalTilesFromRegions, duplicateTiles, cancellationToken); route.MapsReady = true; route.CsvFilePath = csvPath; route.SummaryFilePath = summaryPath; route.StitchedImagePath = stitchedImagePath; route.UpdatedAt = DateTime.UtcNow; await _routeRepository.UpdateRouteAsync(route); _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 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, 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"); } 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, 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; _logger.LogInformation("Stitching route map: {Width}x{Height} pixels (grid: {GridWidth}x{GridHeight} tiles)", imageWidth, imageHeight, gridWidth, gridHeight); _logger.LogInformation("Bounding box: top={MinY}, left={MinX}, bottom={MaxY}, right={MaxX}", minY, minX, maxY, maxX); 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(); _logger.LogInformation("Unique tiles to place: {Count}", uniqueTileCoords.Count); _logger.LogInformation("Sample tiles (first 5):"); foreach (var sample in uniqueTileCoords.Take(5)) { _logger.LogInformation(" Tile ({TileX}, {TileY}) from ({Lat:F6}, {Lon:F6})", sample.TileX, sample.TileY, sample.Latitude, sample.Longitude); } 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); if (tileImage.Width != tileSizePixels || tileImage.Height != tileSizePixels) { _logger.LogWarning("Tile {FilePath} has wrong size {Width}x{Height}, expected {ExpectedWidth}x{ExpectedHeight}", tile.FilePath, tileImage.Width, tileImage.Height, tileSizePixels, tileSizePixels); } stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f)); placedTiles++; if (placedTiles <= 3) { _logger.LogInformation("Placed tile {Count}: ({TileX},{TileY}) at pixel ({DestX},{DestY})", placedTiles, tile.TileX, tile.TileY, destX, destY); } } 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++; } } await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken); var totalPossibleTiles = gridWidth * gridHeight; var uncoveredTiles = totalPossibleTiles - placedTiles - missingTiles; _logger.LogInformation("Route map stitched: {OutputPath}", outputPath); _logger.LogInformation(" Tiles placed: {PlacedTiles}", placedTiles); _logger.LogInformation(" Tiles missing (file issues): {MissingTiles}", missingTiles); _logger.LogInformation(" Uncovered area (black): {UncoveredTiles} tiles", uncoveredTiles); _logger.LogInformation(" Total canvas: {TotalTiles} tiles ({GridWidth}x{GridHeight})", totalPossibleTiles, gridWidth, gridHeight); } 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); } } public class TileInfo { public double Latitude { get; set; } public double Longitude { get; set; } public string FilePath { get; set; } = string.Empty; }