using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.Common.Utils; using SatelliteProvider.DataAccess.Models; using SatelliteProvider.DataAccess.Repositories; namespace SatelliteProvider.Services.RouteManagement; // AZ-364 / C11: thin orchestrator after the god-class decomposition. // Polls the route repository, classifies region statuses, queues region work // via IRegionService (AZ-360 / C08 — direct dependency, no IServiceProvider), // and dispatches output generation to the per-concern collaborators // (RouteCsvWriter, RouteSummaryWriter, RouteImageRenderer, TilesZipBuilder, // RegionFileCleaner, RouteRegionMatcher). public class RouteProcessingService : BackgroundService { private readonly IRouteRepository _routeRepository; private readonly IRegionRepository _regionRepository; private readonly IRegionService _regionService; private readonly RouteCsvWriter _routeCsvWriter; private readonly RouteSummaryWriter _routeSummaryWriter; private readonly RouteImageRenderer _routeImageRenderer; private readonly TilesZipBuilder _tilesZipBuilder; private readonly RegionFileCleaner _regionFileCleaner; private readonly RouteRegionMatcher _routeRegionMatcher; private readonly ILogger _logger; private readonly TimeSpan _checkInterval; public RouteProcessingService( IRouteRepository routeRepository, IRegionRepository regionRepository, IRegionService regionService, RouteCsvWriter routeCsvWriter, RouteSummaryWriter routeSummaryWriter, RouteImageRenderer routeImageRenderer, TilesZipBuilder tilesZipBuilder, RegionFileCleaner regionFileCleaner, IOptions processingConfig, ILogger logger) { _routeRepository = routeRepository; _regionRepository = regionRepository; _regionService = regionService; _routeCsvWriter = routeCsvWriter; _routeSummaryWriter = routeSummaryWriter; _routeImageRenderer = routeImageRenderer; _tilesZipBuilder = tilesZipBuilder; _regionFileCleaner = regionFileCleaner; _routeRegionMatcher = new RouteRegionMatcher(); _checkInterval = TimeSpan.FromSeconds(processingConfig.Value.RouteProcessingPollIntervalSeconds); _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 _routeRepository.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 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) { 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 = _routeRegionMatcher.Match(routePointsList, completedRoutePointRegions); var regionsForOutput = orderedRouteRegions.Concat(completedGeofenceRegions) .GroupBy(r => r.Id) .Select(g => g.First()) .ToList(); await GenerateRouteMapsAsync(routeId, route, regionsForOutput, completedGeofenceRegions, routePointsList, cancellationToken); return; } if (shouldRetryFailed) { 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, RouteEntity route, IReadOnlyList regionsForOutput, IReadOnlyList geofenceRegions, IReadOnlyList routePoints, CancellationToken cancellationToken) { try { var allTiles = new Dictionary(); int totalTilesFromRegions = 0; int duplicateTiles = 0; foreach (var region in regionsForOutput) { if (string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath)) { _logger.LogWarning("Route {RouteId}: Region {RegionId} CSV not found", routeId, region.Id); 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(lat, lon, filePath); } else { duplicateTiles++; } } } var tileList = allTiles.Values.ToList(); var csvPath = await _routeCsvWriter.WriteAsync(routeId, tileList, cancellationToken); string? stitchedImagePath = null; if (route.RequestMaps) { var geofencePolygonBounds = await ComputeGeofencePolygonBoundsAsync(routeId, route.ZoomLevel, cancellationToken); stitchedImagePath = await _routeImageRenderer.RenderAsync( routeId, tileList, route.ZoomLevel, geofencePolygonBounds, routePoints, cancellationToken); } string? tilesZipPath = null; if (route.CreateTilesZip) { tilesZipPath = await _tilesZipBuilder.BuildAsync(routeId, tileList, cancellationToken); } var summaryPath = await _routeSummaryWriter.WriteAsync( route, tileList.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 _regionFileCleaner.CleanupAsync(regionsForOutput, 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> ComputeGeofencePolygonBoundsAsync( Guid routeId, int zoomLevel, CancellationToken cancellationToken) { var bounds = new List<(int MinX, int MinY, int MaxX, int MaxY)>(); var geofencesByPolygon = await _routeRepository.GetGeofenceRegionsByPolygonAsync(routeId); foreach (var (_, 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)) { 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) || !double.TryParse(parts[1], out var lon)) { continue; } var tile = GeoUtils.WorldToTilePos(new GeoPoint(lat, lon), 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) { bounds.Add((minX.Value, minY.Value, maxX.Value, maxY.Value)); } } return bounds; } }