using System.Globalization; using System.Security.Cryptography; 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"; // AZ-503: stable path segment used when an upload arrives without a FlightId. // Picked as a literal token (not a UUID) so flight-anonymous evidence is // visually distinct from real flight directories during ops triage. private const string AnonymousFlightSegment = "none"; 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, metadata.FlightId); 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). var imageArray = imageBytes.ToArray(); await File.WriteAllBytesAsync(filePath, imageArray, cancellationToken); var capturedAtUtc = metadata.CapturedAt.Kind == DateTimeKind.Utc ? metadata.CapturedAt : metadata.CapturedAt.ToUniversalTime(); var now = _timeProvider.GetUtcNow().UtcDateTime; // AZ-503: deterministic id from (z, x, y, source, flight_id-or-zero) and // location_hash from (z, x, y). Cross-repo identical via Uuidv5.TileNamespace. var source = TileSourceConverter.ToWireValue(TileSource.Uav); var flightIdForName = metadata.FlightId ?? Guid.Empty; var idName = string.Create(CultureInfo.InvariantCulture, $"{metadata.TileZoom}/{tileX}/{tileY}/{source}/{flightIdForName}"); var locationHashName = string.Create(CultureInfo.InvariantCulture, $"{metadata.TileZoom}/{tileX}/{tileY}"); var id = Uuidv5.Create(Uuidv5.TileNamespace, idName); var locationHash = Uuidv5.Create(Uuidv5.TileNamespace, locationHashName); var contentSha256 = SHA256.HashData(imageArray); var entity = new TileEntity { Id = id, 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 = source, CapturedAt = capturedAtUtc, CreatedAt = now, UpdatedAt = now, FlightId = metadata.FlightId, LocationHash = locationHash, ContentSha256 = contentSha256, LegacyId = null, }; return await _tileRepository.InsertAsync(entity); } public static string BuildUavTileFilePath(StorageConfig storageConfig, int tileZoom, int tileX, int tileY, Guid? flightId = null) { ArgumentNullException.ThrowIfNull(storageConfig); var flightSegment = flightId.HasValue ? flightId.Value.ToString("D", CultureInfo.InvariantCulture) : AnonymousFlightSegment; return Path.Combine( storageConfig.TilesDirectory, UavTileSubdirectory, flightSegment, tileZoom.ToString(CultureInfo.InvariantCulture), tileX.ToString(CultureInfo.InvariantCulture), tileY.ToString(CultureInfo.InvariantCulture) + UavTileFileExtension); } private static UavTileUploadHandlerResult EnvelopeError(string detail) => new(EnvelopeRejected: true, EnvelopeError: detail, Response: null); }