using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Enums; using SatelliteProvider.Common.Exceptions; using SatelliteProvider.Common.Imaging; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.Common.Utils; using SatelliteProvider.DataAccess.Models; using SatelliteProvider.DataAccess.Repositories; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; namespace SatelliteProvider.Services.RegionProcessing; public class RegionService : IRegionService { private readonly IRegionRepository _regionRepository; private readonly IRegionRequestQueue _queue; private readonly ITileService _tileService; private readonly StorageConfig _storageConfig; private readonly ProcessingConfig _processingConfig; private readonly ILogger _logger; public RegionService( IRegionRepository regionRepository, IRegionRequestQueue queue, ITileService tileService, IOptions storageConfig, IOptions processingConfig, ILogger logger) { _regionRepository = regionRepository; _queue = queue; _tileService = tileService; _storageConfig = storageConfig.Value; _processingConfig = processingConfig.Value; _logger = logger; } public async Task RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel, bool stitchTiles = false) { // AZ-362: idempotent POST contract. A retried POST with the same caller-supplied // Id returns the existing region instead of bubbling a unique-key violation. var existing = await _regionRepository.GetByIdAsync(id); if (existing != null) { _logger.LogInformation( "Idempotent region POST: id {RegionId} already exists with status {Status}; returning existing resource without re-enqueueing", id, existing.Status.ToString().ToLowerInvariant()); return MapToStatus(existing); } var now = DateTime.UtcNow; var region = new RegionEntity { Id = id, Latitude = latitude, Longitude = longitude, SizeMeters = sizeMeters, ZoomLevel = zoomLevel, StitchTiles = stitchTiles, Status = RegionStatus.Queued, TilesDownloaded = 0, TilesReused = 0, CreatedAt = now, UpdatedAt = now }; await _regionRepository.InsertAsync(region); var request = new RegionRequest { Id = id, Latitude = latitude, Longitude = longitude, SizeMeters = sizeMeters, ZoomLevel = zoomLevel, StitchTiles = stitchTiles }; await _queue.EnqueueAsync(request); return MapToStatus(region); } public async Task GetRegionStatusAsync(Guid id) { var region = await _regionRepository.GetByIdAsync(id); return region != null ? MapToStatus(region) : null; } public async Task ProcessRegionAsync(Guid id, CancellationToken cancellationToken = default) { var startTime = DateTime.UtcNow; var region = await _regionRepository.GetByIdAsync(id); if (region == null) { _logger.LogWarning("Region {RegionId} not found in database", id); return; } region.Status = RegionStatus.Processing; region.UpdatedAt = DateTime.UtcNow; await _regionRepository.UpdateAsync(region); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(_processingConfig.RegionProcessingTimeoutSeconds)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); string? errorMessage = null; List? tiles = null; int tilesDownloaded = 0; int tilesReused = 0; try { var processingStartTime = DateTime.UtcNow; var tilesBeforeDownload = await _tileService.GetTilesByRegionAsync( region.Latitude, region.Longitude, region.SizeMeters, region.ZoomLevel); var existingTileIds = new HashSet(tilesBeforeDownload.Select(t => t.Id)); tiles = await _tileService.DownloadAndStoreTilesAsync( region.Latitude, region.Longitude, region.SizeMeters, region.ZoomLevel, linkedCts.Token); tilesDownloaded = tiles.Count(t => !existingTileIds.Contains(t.Id)); tilesReused = tiles.Count(t => existingTileIds.Contains(t.Id)); var readyDir = _storageConfig.ReadyDirectory; Directory.CreateDirectory(readyDir); var csvPath = Path.Combine(readyDir, $"region_{id}_ready.csv"); var summaryPath = Path.Combine(readyDir, $"region_{id}_summary.txt"); string? stitchedImagePath = null; await GenerateCsvFileAsync(csvPath, tiles, linkedCts.Token); if (region.StitchTiles) { stitchedImagePath = Path.Combine(readyDir, $"region_{id}_stitched.jpg"); await StitchTilesAsync(tiles, region.Latitude, region.Longitude, region.ZoomLevel, stitchedImagePath, linkedCts.Token); } await GenerateSummaryFileAsync(summaryPath, id, region, tiles, tilesDownloaded, tilesReused, stitchedImagePath, processingStartTime, linkedCts.Token, errorMessage); region.Status = RegionStatus.Completed; region.CsvFilePath = csvPath; region.SummaryFilePath = summaryPath; region.TilesDownloaded = tilesDownloaded; region.TilesReused = tilesReused; region.UpdatedAt = DateTime.UtcNow; await _regionRepository.UpdateAsync(region); } catch (Exception ex) { var classification = RegionFailureClassifier.Classify(ex, timeoutCts, cancellationToken); errorMessage = classification.ErrorMessage; _logger.LogError( ex, "Region {RegionId} processing failed (category={Category}): {ErrorMessage}", id, classification.Category, classification.ErrorMessage); await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage); } } private async Task HandleProcessingFailureAsync( Guid id, RegionEntity region, DateTime startTime, List? tiles, int tilesDownloaded, int tilesReused, string errorMessage) { region.Status = RegionStatus.Failed; region.UpdatedAt = DateTime.UtcNow; try { var readyDir = _storageConfig.ReadyDirectory; Directory.CreateDirectory(readyDir); var summaryPath = Path.Combine(readyDir, $"region_{id}_summary.txt"); region.SummaryFilePath = summaryPath; await GenerateSummaryFileAsync( summaryPath, id, region, tiles ?? new List(), tilesDownloaded, tilesReused, null, startTime, CancellationToken.None, errorMessage); } catch (Exception ex) { _logger.LogError(ex, "Failed to generate error summary for region {RegionId}", id); } await _regionRepository.UpdateAsync(region); } private async Task StitchTilesAsync( List tiles, double centerLatitude, double centerLongitude, int zoomLevel, string outputPath, CancellationToken cancellationToken) { if (tiles.Count == 0) { throw new InvalidOperationException("No tiles to stitch"); } var tileSizePixels = tiles.First().TileSizePixels; var placements = tiles.Select(t => { var (x, y) = GeoUtils.WorldToTilePos(new GeoPoint(t.Latitude, t.Longitude), zoomLevel); return new TilePlacement(x, y, t.FilePath); }); var stitcher = new TileGridStitcher(); var result = await stitcher.StitchAsync( placements, tileSizePixels, deduplicateByTileCoords: false, swallowTileLoadErrors: false, cancellationToken); using var stitchedImage = result.Image; foreach (var missing in result.MissingTiles) { _logger.LogWarning("Tile file not found: {FilePath}", missing.Tile.FilePath); } var (centerTileX, centerTileY) = GeoUtils.WorldToTilePos(new GeoPoint(centerLatitude, centerLongitude), zoomLevel); var n = Math.Pow(2.0, zoomLevel); var centerTilePixelX = ((centerLongitude + 180.0) / 360.0 * n - centerTileX) * tileSizePixels; var centerTilePixelY = ((1.0 - Math.Log(Math.Tan(centerLatitude * Math.PI / 180.0) + 1.0 / Math.Cos(centerLatitude * Math.PI / 180.0)) / Math.PI) / 2.0 * n - centerTileY) * tileSizePixels; var crossX = (int)Math.Round((centerTileX - result.MinX) * tileSizePixels + centerTilePixelX); var crossY = (int)Math.Round((centerTileY - result.MinY) * tileSizePixels + centerTilePixelY); var red = new Rgb24(255, 0, 0); var imageWidth = result.ImageWidth; var imageHeight = result.ImageHeight; for (int i = -5; i < 5; i++) { var hx = crossX + i; var vy = crossY + i; if (hx >= 0 && hx < imageWidth && crossY >= 0 && crossY < imageHeight) { stitchedImage[hx, crossY] = red; } if (crossX >= 0 && crossX < imageWidth && vy >= 0 && vy < imageHeight) { stitchedImage[crossX, vy] = red; } } await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken); return outputPath; } private static async Task GenerateCsvFileAsync(string filePath, List tiles, CancellationToken cancellationToken) { var rows = tiles.Select(t => new TileCsvRow(t.Latitude, t.Longitude, t.FilePath)); await new TileCsvWriter().WriteAsync(filePath, rows, cancellationToken); } private async Task GenerateSummaryFileAsync( string filePath, Guid regionId, RegionEntity region, List tiles, int tilesDownloaded, int tilesReused, string? stitchedImagePath, DateTime startTime, CancellationToken cancellationToken, string? errorMessage = null) { var endTime = DateTime.UtcNow; var processingTime = (endTime - startTime).TotalSeconds; var summary = new System.Text.StringBuilder(); summary.AppendLine("Region Processing Summary"); summary.AppendLine("========================"); summary.AppendLine($"Region ID: {regionId}"); summary.AppendLine($"Center: {region.Latitude:F6}, {region.Longitude:F6}"); summary.AppendLine($"Size: {region.SizeMeters:F0} meters"); summary.AppendLine($"Zoom Level: {region.ZoomLevel}"); summary.AppendLine($"Status: {region.Status.ToString().ToLowerInvariant()}"); summary.AppendLine(); if (!string.IsNullOrEmpty(errorMessage)) { summary.AppendLine("ERROR:"); summary.AppendLine(errorMessage); summary.AppendLine(); } summary.AppendLine("Processing Statistics:"); summary.AppendLine($"- Tiles Downloaded: {tilesDownloaded}"); summary.AppendLine($"- Tiles Reused from Cache: {tilesReused}"); summary.AppendLine($"- Total Tiles: {tiles.Count}"); summary.AppendLine($"- Processing Time: {processingTime:F2} seconds"); summary.AppendLine($"- Started: {startTime:yyyy-MM-dd HH:mm:ss} UTC"); if (region.Status == RegionStatus.Completed) { summary.AppendLine($"- Completed: {endTime:yyyy-MM-dd HH:mm:ss} UTC"); } else { summary.AppendLine($"- Failed: {endTime:yyyy-MM-dd HH:mm:ss} UTC"); } summary.AppendLine(); summary.AppendLine("Files Created:"); if (tiles.Count > 0) { summary.AppendLine($"- CSV: {Path.GetFileName(filePath).Replace("_summary.txt", "_ready.csv")}"); } if (!string.IsNullOrEmpty(stitchedImagePath)) { summary.AppendLine($"- Stitched Image: {Path.GetFileName(stitchedImagePath)}"); summary.AppendLine($"- Stitched Image Path: {stitchedImagePath}"); } summary.AppendLine($"- Summary: {Path.GetFileName(filePath)}"); await File.WriteAllTextAsync(filePath, summary.ToString(), cancellationToken); } private static RegionStatusResponse MapToStatus(RegionEntity region) { return new RegionStatusResponse { Id = region.Id, Status = region.Status, CsvFilePath = region.CsvFilePath, SummaryFilePath = region.SummaryFilePath, TilesDownloaded = region.TilesDownloaded, TilesReused = region.TilesReused, CreatedAt = region.CreatedAt, UpdatedAt = region.UpdatedAt }; } }