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; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace SatelliteProvider.Services; public class RegionService : IRegionService { private readonly IRegionRepository _regionRepository; private readonly IRegionRequestQueue _queue; private readonly ITileService _tileService; private readonly StorageConfig _storageConfig; private readonly ILogger _logger; public RegionService( IRegionRepository regionRepository, IRegionRequestQueue queue, ITileService tileService, IOptions storageConfig, ILogger logger) { _regionRepository = regionRepository; _queue = queue; _tileService = tileService; _storageConfig = storageConfig.Value; _logger = logger; } public async Task RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel) { var now = DateTime.UtcNow; var region = new RegionEntity { Id = id, Latitude = latitude, Longitude = longitude, SizeMeters = sizeMeters, ZoomLevel = zoomLevel, Status = "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 }; await _queue.EnqueueAsync(request); _logger.LogInformation("Region {RegionId} queued for processing", id); 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) { _logger.LogInformation("Processing region {RegionId}", id); var startTime = DateTime.UtcNow; var region = await _regionRepository.GetByIdAsync(id); if (region == null) { _logger.LogWarning("Region {RegionId} not found", id); return; } region.Status = "processing"; region.UpdatedAt = DateTime.UtcNow; await _regionRepository.UpdateAsync(region); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); string? errorMessage = null; List? tiles = null; int tilesDownloaded = 0; int tilesReused = 0; try { _logger.LogInformation("Downloading tiles for region {RegionId} at ({Lat}, {Lon}) size {Size}m zoom {Zoom}", id, region.Latitude, region.Longitude, region.SizeMeters, region.ZoomLevel); var processingStartTime = DateTime.UtcNow; _logger.LogInformation("Checking for existing tiles in region {RegionId}", id); var tilesBeforeDownload = await _tileService.GetTilesByRegionAsync( region.Latitude, region.Longitude, region.SizeMeters, region.ZoomLevel); var existingTileIds = new HashSet(tilesBeforeDownload.Select(t => t.Id)); _logger.LogInformation("Found {Count} existing tiles for region {RegionId}", existingTileIds.Count, id); _logger.LogInformation("Starting tile download for region {RegionId}", 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)); _logger.LogInformation("Region {RegionId}: Downloaded {Downloaded} tiles, Reused {Reused} tiles", id, tilesDownloaded, tilesReused); 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"); var stitchedImagePath = Path.Combine(readyDir, $"region_{id}_stitched.jpg"); await GenerateCsvFileAsync(csvPath, tiles, linkedCts.Token); _logger.LogInformation("Stitching tiles for region {RegionId}", id); 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 = "completed"; region.CsvFilePath = csvPath; region.SummaryFilePath = summaryPath; region.TilesDownloaded = tilesDownloaded; region.TilesReused = tilesReused; region.UpdatedAt = DateTime.UtcNow; await _regionRepository.UpdateAsync(region); var duration = (DateTime.UtcNow - startTime).TotalSeconds; _logger.LogInformation("Region {RegionId} processing completed in {Duration:F2}s", id, duration); } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { errorMessage = "Processing timed out after 5 minutes. Unable to download tiles within the time limit."; _logger.LogError("Region {RegionId} processing timed out after 5 minutes", id); await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage); } catch (RateLimitException ex) { errorMessage = $"Rate limit exceeded: {ex.Message}. Google Maps API rate limit was reached and retries were exhausted."; _logger.LogError(ex, "Rate limit exceeded for region {RegionId}", id); await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage); } catch (HttpRequestException ex) { errorMessage = $"Network error: {ex.Message}. Failed to download tiles from Google Maps."; _logger.LogError(ex, "Network error processing region {RegionId}", id); await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage); } catch (Exception ex) { errorMessage = $"Unexpected error: {ex.Message}"; _logger.LogError(ex, "Failed to process region {RegionId}: {Message}", id, ex.Message); 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 = "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 tileCoords = tiles.Select(t => { var (x, y) = GeoUtils.WorldToTilePos(new GeoPoint(t.Latitude, t.Longitude), zoomLevel); return (x, y, t.FilePath); }).ToList(); var minX = tileCoords.Min(t => t.x); var maxX = tileCoords.Max(t => t.x); var minY = tileCoords.Min(t => t.y); var maxY = tileCoords.Max(t => t.y); var gridWidth = maxX - minX + 1; var gridHeight = maxY - minY + 1; var imageWidth = gridWidth * tileSizePixels; var imageHeight = gridHeight * tileSizePixels; _logger.LogInformation("Stitching {Count} tiles into {Width}x{Height} image (grid: {GridWidth}x{GridHeight})", tiles.Count, imageWidth, imageHeight, gridWidth, gridHeight); using var stitchedImage = new Image(imageWidth, imageHeight); foreach (var (x, y, filePath) in tileCoords) { if (!File.Exists(filePath)) { _logger.LogWarning("Tile file not found: {FilePath}", filePath); continue; } using var tileImage = await Image.LoadAsync(filePath, cancellationToken); var destX = (x - minX) * tileSizePixels; var destY = (y - minY) * tileSizePixels; stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f)); } 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 - minX) * tileSizePixels + centerTilePixelX); var crossY = (int)Math.Round((centerTileY - minY) * tileSizePixels + centerTilePixelY); _logger.LogInformation("Drawing red cross at pixel position ({X}, {Y}) for coordinates ({Lat}, {Lon})", crossX, crossY, centerLatitude, centerLongitude); var red = new Rgb24(255, 0, 0); stitchedImage.Mutate(ctx => { 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); _logger.LogInformation("Stitched image saved to {OutputPath}", outputPath); return outputPath; } private async Task GenerateCsvFileAsync(string filePath, List 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}"); } } 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}"); 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 == "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 RegionStatus MapToStatus(RegionEntity region) { return new RegionStatus { Id = region.Id, Status = region.Status, CsvFilePath = region.CsvFilePath, SummaryFilePath = region.SummaryFilePath, TilesDownloaded = region.TilesDownloaded, TilesReused = region.TilesReused, CreatedAt = region.CreatedAt, UpdatedAt = region.UpdatedAt }; } }