using System.Diagnostics; using Dapper; using Microsoft.Extensions.Logging; using Npgsql; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.Utils; using SatelliteProvider.DataAccess.Models; namespace SatelliteProvider.DataAccess.Repositories; public class TileRepository : ITileRepository { private const int SlowQueryThresholdMs = 500; private const string ColumnList = @"id, tile_zoom as TileZoom, tile_x as TileX, tile_y as TileY, latitude, longitude, tile_size_meters as TileSizeMeters, tile_size_pixels as TileSizePixels, image_type as ImageType, maps_version as MapsVersion, version, file_path as FilePath, source, captured_at as CapturedAt, created_at as CreatedAt, updated_at as UpdatedAt"; private readonly string _connectionString; private readonly ILogger _logger; public TileRepository(string connectionString, ILogger logger) { _connectionString = connectionString; _logger = logger; } public async Task GetByIdAsync(Guid id) { using var connection = new NpgsqlConnection(_connectionString); const string sql = $@" SELECT {ColumnList} FROM tiles WHERE id = @Id"; return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); } public async Task GetByTileCoordinatesAsync(int tileZoom, int tileX, int tileY) { using var connection = new NpgsqlConnection(_connectionString); // AZ-484 selection rule: most-recent across sources, deterministic tie-break on // (captured_at DESC, updated_at DESC, id DESC). const string sql = $@" SELECT {ColumnList} FROM tiles WHERE tile_zoom = @TileZoom AND tile_x = @TileX AND tile_y = @TileY ORDER BY captured_at DESC, updated_at DESC, id DESC LIMIT 1"; return await connection.QuerySingleOrDefaultAsync(sql, new { TileZoom = tileZoom, TileX = tileX, TileY = tileY }); } public async Task> GetTilesByRegionAsync(double latitude, double longitude, double sizeMeters, int zoomLevel) { using var connection = new NpgsqlConnection(_connectionString); var latRad = latitude * Math.PI / 180.0; var metersPerPixel = (GeoUtils.EarthEquatorialCircumferenceMeters * Math.Cos(latRad)) / (Math.Pow(2, zoomLevel) * MapConfig.DefaultTileSizePixels); var tileSizeMeters = metersPerPixel * MapConfig.DefaultTileSizePixels; var expandedSizeMeters = sizeMeters + (tileSizeMeters * 2); var latRange = expandedSizeMeters / GeoUtils.MetersPerDegreeLatitude; var lonRange = expandedSizeMeters / (GeoUtils.MetersPerDegreeLatitude * Math.Cos(latitude * Math.PI / 180.0)); // AZ-484 selection rule: at most one row per (lat, lon, zoom, size) cell, picking // the most-recent across sources via DISTINCT ON, with deterministic tie-break on // (captured_at DESC, updated_at DESC, id DESC). The outer ORDER BY restores the // pre-AZ-484 caller-facing order (latitude DESC, longitude ASC); the pre-AZ-484 // updated_at DESC tiebreak is unreachable here because DISTINCT ON already // guarantees one row per (latitude, longitude, ...) tuple. const string sql = $@" SELECT * FROM ( SELECT DISTINCT ON (latitude, longitude, tile_zoom, tile_size_meters) {ColumnList} FROM tiles WHERE latitude BETWEEN @MinLat AND @MaxLat AND longitude BETWEEN @MinLon AND @MaxLon AND tile_zoom = @TileZoom ORDER BY latitude, longitude, tile_zoom, tile_size_meters, captured_at DESC, updated_at DESC, id DESC ) deduped ORDER BY latitude DESC, longitude ASC"; var stopwatch = Stopwatch.StartNew(); var tiles = await connection.QueryAsync(sql, new { MinLat = latitude - latRange / 2, MaxLat = latitude + latRange / 2, MinLon = longitude - lonRange / 2, MaxLon = longitude + lonRange / 2, TileZoom = zoomLevel }); stopwatch.Stop(); if (stopwatch.ElapsedMilliseconds > SlowQueryThresholdMs) { _logger.LogWarning( "Slow GetTilesByRegionAsync: {ElapsedMs} ms (threshold {ThresholdMs} ms) for lat={Latitude}, lon={Longitude}, sizeMeters={SizeMeters}, zoom={ZoomLevel}", stopwatch.ElapsedMilliseconds, SlowQueryThresholdMs, latitude, longitude, sizeMeters, zoomLevel); } return tiles; } public async Task InsertAsync(TileEntity tile) { using var connection = new NpgsqlConnection(_connectionString); // AZ-484: per-source UPSERT — conflict key now includes `source` so that two // producers (e.g. google_maps + uav) can coexist for the same cell. A re-insert // for the SAME source updates file_path / tile_x / tile_y plus refreshes // captured_at and updated_at to reflect the new acquisition. const string sql = @" INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters, tile_size_pixels, image_type, maps_version, version, file_path, source, captured_at, created_at, updated_at) VALUES (@Id, @TileZoom, @TileX, @TileY, @Latitude, @Longitude, @TileSizeMeters, @TileSizePixels, @ImageType, @MapsVersion, @Version, @FilePath, @Source, @CapturedAt, @CreatedAt, @UpdatedAt) ON CONFLICT (latitude, longitude, tile_zoom, tile_size_meters, source) DO UPDATE SET file_path = EXCLUDED.file_path, tile_x = EXCLUDED.tile_x, tile_y = EXCLUDED.tile_y, captured_at = EXCLUDED.captured_at, updated_at = EXCLUDED.updated_at RETURNING id"; return await connection.ExecuteScalarAsync(sql, tile); } public async Task UpdateAsync(TileEntity tile) { using var connection = new NpgsqlConnection(_connectionString); const string sql = @" UPDATE tiles SET tile_zoom = @TileZoom, tile_x = @TileX, tile_y = @TileY, latitude = @Latitude, longitude = @Longitude, tile_size_meters = @TileSizeMeters, tile_size_pixels = @TileSizePixels, image_type = @ImageType, maps_version = @MapsVersion, version = @Version, file_path = @FilePath, source = @Source, captured_at = @CapturedAt, updated_at = @UpdatedAt WHERE id = @Id"; return await connection.ExecuteAsync(sql, tile); } public async Task DeleteAsync(Guid id) { using var connection = new NpgsqlConnection(_connectionString); const string sql = "DELETE FROM tiles WHERE id = @Id"; return await connection.ExecuteAsync(sql, new { Id = id }); } }