using System.Globalization; using System.Security.Cryptography; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Enums; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.Common.Utils; using SatelliteProvider.DataAccess.Models; using SatelliteProvider.DataAccess.Repositories; namespace SatelliteProvider.Services.TileDownloader; public class TileService : ITileService { private static readonly TimeSpan TileCacheAbsolute = TimeSpan.FromHours(1); private static readonly TimeSpan TileCacheSliding = TimeSpan.FromMinutes(30); private static readonly TimeSpan TileResponseMaxAge = TimeSpan.FromDays(1); private const string TileImageContentType = "image/jpeg"; private readonly ISatelliteDownloader _downloader; private readonly ITileRepository _tileRepository; private readonly IMemoryCache _cache; private readonly MapConfig _mapConfig; private readonly ILogger _logger; public TileService( ISatelliteDownloader downloader, ITileRepository tileRepository, IMemoryCache cache, IOptions mapConfig, ILogger logger) { _downloader = downloader; _tileRepository = tileRepository; _cache = cache; _mapConfig = mapConfig.Value; _logger = logger; } public async Task> DownloadAndStoreTilesAsync( double latitude, double longitude, double sizeMeters, int zoomLevel, CancellationToken cancellationToken = default) { var existingTiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel); var existingTilesList = existingTiles.ToList(); var centerPoint = new GeoPoint(latitude, longitude); var existingTileInfos = existingTilesList .Select(t => new ExistingTileInfo(t.Latitude, t.Longitude, t.TileZoom)) .ToList(); var downloadedTiles = await _downloader.GetTilesWithMetadataAsync( centerPoint, sizeMeters / 2, zoomLevel, existingTileInfos, cancellationToken); var result = new List(); foreach (var existingTile in existingTilesList) { result.Add(MapToMetadata(existingTile)); } foreach (var downloadedTile in downloadedTiles) { var tileEntity = BuildTileEntity(downloadedTile); await _tileRepository.InsertAsync(tileEntity); result.Add(MapToMetadata(tileEntity)); } return result; } public async Task GetTileAsync(Guid id) { var tile = await _tileRepository.GetByIdAsync(id); return tile != null ? MapToMetadata(tile) : null; } public async Task> GetTilesByRegionAsync( double latitude, double longitude, double sizeMeters, int zoomLevel) { var tiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel); return tiles.Select(MapToMetadata); } public async Task GetOrDownloadTileAsync(int z, int x, int y, CancellationToken cancellationToken = default) { var cacheKey = $"tile_{z}_{x}_{y}"; var etag = $"\"{z}_{x}_{y}\""; if (_cache.TryGetValue(cacheKey, out byte[]? cachedBytes) && cachedBytes != null) { return new TileBytes(cachedBytes, TileImageContentType, etag, TileResponseMaxAge); } string filePath; var existing = await _tileRepository.GetByTileCoordinatesAsync(z, x, y); if (existing != null && File.Exists(existing.FilePath)) { filePath = existing.FilePath; } else { var tileCenter = GeoUtils.TileToWorldPos(x, y, z); var downloaded = await _downloader.DownloadSingleTileAsync(tileCenter.Lat, tileCenter.Lon, z, cancellationToken); var entity = BuildTileEntity(downloaded); await _tileRepository.InsertAsync(entity); filePath = entity.FilePath; } var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken); _cache.Set(cacheKey, bytes, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TileCacheAbsolute, SlidingExpiration = TileCacheSliding }); return new TileBytes(bytes, TileImageContentType, etag, TileResponseMaxAge); } public async Task DownloadAndStoreSingleTileAsync( double latitude, double longitude, int zoomLevel, CancellationToken cancellationToken = default) { var downloaded = await _downloader.DownloadSingleTileAsync(latitude, longitude, zoomLevel, cancellationToken); var entity = BuildTileEntity(downloaded); await _tileRepository.InsertAsync(entity); return MapToMetadata(entity); } public async Task GetInventoryAsync(TileInventoryRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); cancellationToken.ThrowIfCancellationRequested(); var tiles = request.Tiles; var hashes = request.LocationHashes; // Defensive guards. The HTTP handler rejects these cases with HTTP // 400 before reaching the service; this preserves the same invariant // for any future non-HTTP caller (and keeps unit-tests grounded). var hasTiles = tiles is { Count: > 0 }; var hasHashes = hashes is { Count: > 0 }; if (hasTiles == hasHashes) { throw new ArgumentException( "TileInventoryRequest must populate exactly one of `Tiles` or `LocationHashes` (and not both).", nameof(request)); } // Build the (request entry → location_hash) mapping. When the caller // supplied coords, compute UUIDv5 server-side; when they supplied // pre-computed hashes, use them verbatim. We keep both representations // in lockstep so the response can echo the request entry's coord // triple back to the caller (Tiles input branch) or zero them out // (LocationHashes input branch). var entries = new List<(int Zoom, int X, int Y, Guid Hash)>(hasTiles ? tiles!.Count : hashes!.Count); if (hasTiles) { foreach (var coord in tiles!) { var hash = Uuidv5.LocationHashForTile(coord.TileZoom, coord.TileX, coord.TileY); entries.Add((coord.TileZoom, coord.TileX, coord.TileY, hash)); } } else { foreach (var hash in hashes!) { entries.Add((0, 0, 0, hash)); } } var distinctHashes = entries.Select(e => e.Hash).Distinct().ToArray(); var rows = await _tileRepository.GetTilesByLocationHashesAsync(distinctHashes); var results = new List(entries.Count); foreach (var (zoom, x, y, hash) in entries) { if (rows.TryGetValue(hash, out var tile)) { results.Add(new TileInventoryEntry { TileZoom = hasTiles ? zoom : tile.TileZoom, TileX = hasTiles ? x : tile.TileX, TileY = hasTiles ? y : tile.TileY, LocationHash = hash, Present = true, Id = tile.Id, CapturedAt = tile.CapturedAt, Source = tile.Source, FlightId = tile.FlightId, ResolutionMPerPx = tile.TileSizePixels > 0 ? tile.TileSizeMeters / tile.TileSizePixels : null }); } else { results.Add(new TileInventoryEntry { TileZoom = zoom, TileX = x, TileY = y, LocationHash = hash, Present = false }); } } return new TileInventoryResponse { Results = results }; } private TileEntity BuildTileEntity(DownloadedTileInfoV2 downloaded) { var now = DateTime.UtcNow; var source = TileSourceConverter.ToWireValue(TileSource.GoogleMaps); // AZ-503: deterministic UUIDv5 over (z, x, y, source, flight_id-or-zero). // google_maps tiles have no flight_id so the name fragment uses the // canonical all-zeros UUID; the same Python-side serialization in // gps-denied-onboard produces byte-identical IDs. var idName = string.Create(CultureInfo.InvariantCulture, $"{downloaded.ZoomLevel}/{downloaded.X}/{downloaded.Y}/{source}/{Guid.Empty}"); var id = Uuidv5.Create(Uuidv5.TileNamespace, idName); var locationHash = Uuidv5.LocationHashForTile(downloaded.ZoomLevel, downloaded.X, downloaded.Y); // content_sha256 is computed from the actual JPEG body on disk. Google Maps // downloads land on disk before this method runs (FilePath is set by the // downloader), so a single read here is safe and avoids re-streaming. If // the file is missing for any reason, leave ContentSha256 null and rely on // the application invariant of "NOT NULL for AZ-503+ inserts" surfacing // the problem in tests rather than silently inserting a sentinel digest. byte[]? contentSha256 = null; if (File.Exists(downloaded.FilePath)) { using var stream = File.OpenRead(downloaded.FilePath); contentSha256 = SHA256.HashData(stream); } return new TileEntity { Id = id, TileZoom = downloaded.ZoomLevel, TileX = downloaded.X, TileY = downloaded.Y, Latitude = downloaded.CenterLatitude, Longitude = downloaded.CenterLongitude, TileSizeMeters = downloaded.TileSizeMeters, TileSizePixels = _mapConfig.TileSizePixels, ImageType = "jpg", MapsVersion = null, Version = null, FilePath = downloaded.FilePath, Source = source, CapturedAt = now, CreatedAt = now, UpdatedAt = now, FlightId = null, LocationHash = locationHash, ContentSha256 = contentSha256, LegacyId = null }; } private static TileMetadata MapToMetadata(TileEntity entity) { return new TileMetadata { Id = entity.Id, TileZoom = entity.TileZoom, TileX = entity.TileX, TileY = entity.TileY, Latitude = entity.Latitude, Longitude = entity.Longitude, TileSizeMeters = entity.TileSizeMeters, TileSizePixels = entity.TileSizePixels, ImageType = entity.ImageType, Version = entity.Version, FilePath = entity.FilePath, CreatedAt = entity.CreatedAt, UpdatedAt = entity.UpdatedAt }; } }