using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Enums; using SatelliteProvider.Common.Utils; using SatelliteProvider.DataAccess.Models; using SatelliteProvider.DataAccess.Repositories; namespace SatelliteProvider.Services.TileDownloader; public interface IUavTileUploadHandler { Task HandleAsync(string metadataJson, IReadOnlyList files, CancellationToken cancellationToken = default); } public sealed record UavUploadFile(string FileName, string? ContentType, ReadOnlyMemory Content); public sealed record UavTileUploadHandlerResult(bool EnvelopeRejected, string? EnvelopeError, UavTileBatchUploadResponse? Response); public sealed class UavTileUploadHandler : IUavTileUploadHandler { private const string UavTileFileExtension = ".jpg"; private const string UavTileSubdirectory = "uav"; private readonly IUavTileQualityGate _qualityGate; private readonly ITileRepository _tileRepository; private readonly StorageConfig _storageConfig; private readonly MapConfig _mapConfig; private readonly UavQualityConfig _qualityConfig; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public UavTileUploadHandler( IUavTileQualityGate qualityGate, ITileRepository tileRepository, IOptions storageConfig, IOptions mapConfig, IOptions qualityConfig, ILogger logger, TimeProvider? timeProvider = null) { _qualityGate = qualityGate ?? throw new ArgumentNullException(nameof(qualityGate)); _tileRepository = tileRepository ?? throw new ArgumentNullException(nameof(tileRepository)); _storageConfig = storageConfig?.Value ?? throw new ArgumentNullException(nameof(storageConfig)); _mapConfig = mapConfig?.Value ?? throw new ArgumentNullException(nameof(mapConfig)); _qualityConfig = qualityConfig?.Value ?? throw new ArgumentNullException(nameof(qualityConfig)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; } private static readonly JsonSerializerOptions MetadataJsonOptions = new() { PropertyNameCaseInsensitive = true, }; public async Task HandleAsync(string metadataJson, IReadOnlyList files, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(files); if (string.IsNullOrWhiteSpace(metadataJson)) { return EnvelopeError("Request `metadata` field is required."); } UavTileBatchMetadataPayload? payload; try { payload = JsonSerializer.Deserialize(metadataJson, MetadataJsonOptions); } catch (JsonException ex) { return EnvelopeError($"Invalid `metadata` JSON: {ex.Message}"); } if (payload is null || payload.Items.Count == 0) { return EnvelopeError("Request `metadata.items` is required and must contain at least one entry."); } if (payload.Items.Count != files.Count) { return EnvelopeError($"Mismatched batch: metadata has {payload.Items.Count} entries but {files.Count} file(s) were uploaded."); } if (payload.Items.Count > _qualityConfig.MaxBatchSize) { return EnvelopeError($"Batch size {payload.Items.Count} exceeds the configured maximum of {_qualityConfig.MaxBatchSize}."); } var response = new UavTileBatchUploadResponse(); for (var index = 0; index < payload.Items.Count; index++) { cancellationToken.ThrowIfCancellationRequested(); var metadata = payload.Items[index]; var file = files[index]; var gateResult = _qualityGate.Validate(file.Content, file.ContentType, metadata); if (!gateResult.Accepted) { response.Items.Add(new UavTileUploadResultItem { Index = index, Status = UavTileUploadStatus.Rejected, RejectReason = gateResult.Reason, RejectDetails = gateResult.Details, }); continue; } try { var persistedId = await PersistAsync(metadata, file.Content, cancellationToken); response.Items.Add(new UavTileUploadResultItem { Index = index, Status = UavTileUploadStatus.Accepted, TileId = persistedId, }); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { _logger.LogError(ex, "UAV tile persistence failed at index {Index}", index); response.Items.Add(new UavTileUploadResultItem { Index = index, Status = UavTileUploadStatus.Rejected, RejectReason = UavTileRejectReasons.StorageFailure, }); } } return new UavTileUploadHandlerResult(EnvelopeRejected: false, EnvelopeError: null, Response: response); } private async Task PersistAsync(UavTileMetadata metadata, ReadOnlyMemory imageBytes, CancellationToken cancellationToken) { var (tileX, tileY) = GeoUtils.WorldToTilePos(new GeoPoint(metadata.Latitude, metadata.Longitude), metadata.TileZoom); var filePath = BuildUavTileFilePath(_storageConfig, metadata.TileZoom, tileX, tileY); var directory = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } // File-first, row-second so a crash leaves an orphan file rather than a row // pointing at nothing (Risk 2 in the AZ-488 task spec). await File.WriteAllBytesAsync(filePath, imageBytes.ToArray(), cancellationToken); var capturedAtUtc = metadata.CapturedAt.Kind == DateTimeKind.Utc ? metadata.CapturedAt : metadata.CapturedAt.ToUniversalTime(); var now = _timeProvider.GetUtcNow().UtcDateTime; var entity = new TileEntity { Id = Guid.NewGuid(), TileZoom = metadata.TileZoom, TileX = tileX, TileY = tileY, Latitude = metadata.Latitude, Longitude = metadata.Longitude, TileSizeMeters = metadata.TileSizeMeters, TileSizePixels = _mapConfig.TileSizePixels, ImageType = "jpg", MapsVersion = null, Version = null, FilePath = filePath, Source = TileSourceConverter.ToWireValue(TileSource.Uav), CapturedAt = capturedAtUtc, CreatedAt = now, UpdatedAt = now, }; return await _tileRepository.InsertAsync(entity); } public static string BuildUavTileFilePath(StorageConfig storageConfig, int tileZoom, int tileX, int tileY) { ArgumentNullException.ThrowIfNull(storageConfig); return Path.Combine( storageConfig.TilesDirectory, UavTileSubdirectory, tileZoom.ToString(System.Globalization.CultureInfo.InvariantCulture), tileX.ToString(System.Globalization.CultureInfo.InvariantCulture), tileY.ToString(System.Globalization.CultureInfo.InvariantCulture) + UavTileFileExtension); } private static UavTileUploadHandlerResult EnvelopeError(string detail) => new(EnvelopeRejected: true, EnvelopeError: detail, Response: null); }