using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using System.Globalization;
using Npgsql;
using SatelliteProvider.Common.Utils;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.PixelFormats;

namespace SatelliteProvider.IntegrationTests;

public static class UavUploadTests
{
    private const string UploadPath = "/api/satellite/upload";
    private const string GpsPermission = "GPS";
    private const string PermissionsClaimType = "permissions";

    public static async Task RunAll(string apiUrl, string secret)
    {
        RouteTestHelpers.PrintTestHeader("Test: UAV tile upload (AZ-488)");

        var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING")
            ?? "Host=postgres;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres";

        await HappyPathSingleItem_PersistsRow(apiUrl, secret, connectionString);
        await MixedBatch_ReturnsPerItemResults(apiUrl, secret, connectionString);
        await MultiSourceCoexistence_AZ484_Cycle2(apiUrl, secret, connectionString);
        await SameSourceUpsert_AZ484_Cycle2(apiUrl, secret, connectionString);
        await MultiFlightUavRowsCoexist_AZ503_AC3(apiUrl, secret, connectionString);
        await FloatRoundingDoesNotBreakIdempotence_AZ503_AC4(apiUrl, secret, connectionString);
        await NoToken_Returns401(apiUrl);
        await ValidTokenWithoutGpsPermission_Returns403(apiUrl, secret);
        await OversizedBatch_Returns400(apiUrl, secret);

        Console.WriteLine("✓ UAV upload tests: PASSED");
    }

    private static async Task HappyPathSingleItem_PersistsRow(string apiUrl, string secret, string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-488 AC-1: Happy path — 1-item batch persists with source='uav'");

        // Arrange
        var coord = NextTestCoordinate();
        var metadata = new
        {
            items = new[]
            {
                new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }
            }
        };
        using var client = CreateClient(apiUrl);
        AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));

        // Act
        var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });

        // Assert
        await EnsureStatus(response, HttpStatusCode.OK, "AC-1 happy path");
        var body = await response.Content.ReadFromJsonAsync<UavResponseEnvelope>();
        if (body is null || body.Items.Count != 1 || body.Items[0].Status != "accepted")
        {
            throw new Exception($"AC-1: expected single accepted item, got {await response.Content.ReadAsStringAsync()}");
        }

        var rowCount = await CountUavRowsAsync(connectionString, coord.Latitude, coord.Longitude);
        if (rowCount != 1)
        {
            throw new Exception($"AC-1: expected one uav row, got {rowCount}");
        }

        Console.WriteLine("  ✓ HTTP 200, accepted, row inserted with source='uav'");
    }

    private static async Task MixedBatch_ReturnsPerItemResults(string apiUrl, string secret, string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-488 AC-2: 3-item batch with 1 valid + 2 invalid returns per-item results");

        // Arrange
        var coords = new[] { NextTestCoordinate(), NextTestCoordinate(), NextTestCoordinate() };
        var metadata = new
        {
            items = coords.Select(c => new { latitude = c.Latitude, longitude = c.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }).ToArray()
        };
        using var client = CreateClient(apiUrl);
        AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));

        var good = CreateValidJpeg();
        var wrongDimensions = CreateValidJpeg(width: 512, height: 512);
        var bogus = new byte[6000]; bogus[0] = 0x89; bogus[1] = 0x50; bogus[2] = 0x4E; bogus[3] = 0x47;

        // Act
        var response = await PostBatch(client, metadata, new[] { good, wrongDimensions, bogus });

        // Assert
        await EnsureStatus(response, HttpStatusCode.OK, "AC-2 mixed batch");
        var body = await response.Content.ReadFromJsonAsync<UavResponseEnvelope>();
        if (body is null || body.Items.Count != 3)
        {
            throw new Exception($"AC-2: expected 3 result items, got {await response.Content.ReadAsStringAsync()}");
        }
        if (body.Items[0].Status != "accepted")
        {
            throw new Exception($"AC-2: item 0 expected accepted, got '{body.Items[0].Status}'");
        }
        if (body.Items[1].Status != "rejected" || body.Items[1].RejectReason != "WRONG_DIMENSIONS")
        {
            throw new Exception($"AC-2: item 1 expected rejected/WRONG_DIMENSIONS, got '{body.Items[1].Status}'/'{body.Items[1].RejectReason}'");
        }
        if (body.Items[2].Status != "rejected" || body.Items[2].RejectReason != "INVALID_FORMAT")
        {
            throw new Exception($"AC-2: item 2 expected rejected/INVALID_FORMAT, got '{body.Items[2].Status}'/'{body.Items[2].RejectReason}'");
        }

        var rowCount = await CountUavRowsAsync(connectionString, coords[0].Latitude, coords[0].Longitude);
        if (rowCount != 1)
        {
            throw new Exception($"AC-2: expected exactly 1 row from accepted item, got {rowCount}");
        }

        Console.WriteLine("  ✓ Per-item results: [accepted, rejected:WRONG_DIMENSIONS, rejected:INVALID_FORMAT]");
    }

    private static async Task MultiSourceCoexistence_AZ484_Cycle2(string apiUrl, string secret, string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-488 AC-3: UAV upload coexists with a pre-seeded google_maps row");

        // Arrange — pre-seed a google_maps row at T1 directly via SQL.
        // AZ-503: location_hash is NOT NULL after migration 014; compute it
        // inline using the same Uuidv5 algorithm production code uses (see
        // SatelliteProvider.Services.TileDownloader.TileService.BuildTileEntity).
        var coord = NextTestCoordinate();
        const int zoom = 18;
        const double sizeMeters = 200.0;
        var t1 = DateTime.UtcNow.AddHours(-2);
        var googleRowId = Guid.NewGuid();
        var seedLocationHash = Uuidv5.Create(
            Uuidv5.TileNamespace,
            string.Create(CultureInfo.InvariantCulture, $"{zoom}/0/0"));
        await ExecuteAsync(connectionString, """
            INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters,
                               tile_size_pixels, image_type, file_path, source, captured_at,
                               created_at, updated_at, location_hash)
            VALUES (@id, @zoom, 0, 0, @lat, @lon, @size, 256, 'jpg', 'tiles/seed.jpg', 'google_maps', @t1, @t1, @t1, @loc);
            """,
            ("id", googleRowId), ("zoom", zoom), ("lat", coord.Latitude), ("lon", coord.Longitude),
            ("size", sizeMeters), ("t1", t1), ("loc", seedLocationHash));

        var metadata = new
        {
            items = new[]
            {
                new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o") }
            }
        };
        using var client = CreateClient(apiUrl);
        AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));

        // Act
        var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });

        // Assert
        await EnsureStatus(response, HttpStatusCode.OK, "AC-3 coexistence");
        var bothSources = await QuerySourcesAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters);
        if (!bothSources.Contains("google_maps") || !bothSources.Contains("uav"))
        {
            throw new Exception($"AC-3: expected both google_maps and uav rows, got [{string.Join(", ", bothSources)}]");
        }

        Console.WriteLine("  ✓ Both google_maps and uav rows coexist for the same cell");
    }

    private static async Task SameSourceUpsert_AZ484_Cycle2(string apiUrl, string secret, string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-488 AC-4: Same-source UPSERT — second UAV upload refreshes the existing row");

        // Arrange
        var coord = NextTestCoordinate();
        using var client = CreateClient(apiUrl);
        AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));

        var firstMetadata = new
        {
            items = new[]
            {
                new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddMinutes(-30).ToString("o") }
            }
        };

        // Act 1 — first UAV upload
        var first = await PostBatch(client, firstMetadata, new[] { CreateValidJpeg(seed: 1) });
        await EnsureStatus(first, HttpStatusCode.OK, "AC-4 first upload");

        var secondMetadata = new
        {
            items = new[]
            {
                new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }
            }
        };

        // Act 2 — second UAV upload for the same cell with newer captured_at
        var second = await PostBatch(client, secondMetadata, new[] { CreateValidJpeg(seed: 2) });

        // Assert
        await EnsureStatus(second, HttpStatusCode.OK, "AC-4 second upload");

        var uavRows = await CountUavRowsAsync(connectionString, coord.Latitude, coord.Longitude);
        if (uavRows != 1)
        {
            throw new Exception($"AC-4: expected exactly 1 uav row after UPSERT, got {uavRows}");
        }

        Console.WriteLine("  ✓ Same-source UPSERT collapsed to exactly one uav row");
    }

    private static async Task MultiFlightUavRowsCoexist_AZ503_AC3(string apiUrl, string secret, string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-503 AC-3: two UAV uploads at the same (z, x, y) from different flight_ids coexist as distinct DB rows sharing the same location_hash");

        // Arrange — two distinct flightIds, identical lat/lon/zoom/size.
        var coord = NextTestCoordinate();
        const int zoom = 18;
        const double sizeMeters = 200.0;
        var flightA = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
        var flightB = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");

        using var client = CreateClient(apiUrl);
        AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));

        var metaA = new
        {
            items = new[]
            {
                new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.AddMinutes(-10).ToString("o"), flightId = flightA }
            }
        };
        var metaB = new
        {
            items = new[]
            {
                new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o"), flightId = flightB }
            }
        };

        // Act
        var first = await PostBatch(client, metaA, new[] { CreateValidJpeg(seed: 11) });
        await EnsureStatus(first, HttpStatusCode.OK, "AC-3 first flight upload");
        var second = await PostBatch(client, metaB, new[] { CreateValidJpeg(seed: 22) });
        await EnsureStatus(second, HttpStatusCode.OK, "AC-3 second flight upload");

        // Assert
        var rows = await QueryUavRowsByFlightAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters);
        if (rows.Count != 2)
        {
            throw new Exception(
                $"AZ-503 AC-3: expected 2 distinct uav rows for the same cell with different flight_ids, got {rows.Count}. Rows: [{string.Join(", ", rows.Select(r => $"flight_id={r.FlightId} id={r.Id}"))}]");
        }
        if (!rows.Any(r => r.FlightId == flightA) || !rows.Any(r => r.FlightId == flightB))
        {
            throw new Exception(
                $"AZ-503 AC-3: expected rows with flight_id={flightA} AND flight_id={flightB}, got [{string.Join(", ", rows.Select(r => r.FlightId?.ToString() ?? "NULL"))}]");
        }
        var ids = rows.Select(r => r.Id).Distinct().ToList();
        if (ids.Count != 2)
        {
            throw new Exception($"AZ-503 AC-3: per-flight rows must have distinct ids, got {ids.Count} distinct id(s).");
        }
        var locationHashes = rows.Select(r => r.LocationHash).Distinct().ToList();
        if (locationHashes.Count != 1)
        {
            throw new Exception(
                $"AZ-503 AC-3: per-flight rows must share the same location_hash (same (z, x, y)), got {locationHashes.Count} distinct hashes: [{string.Join(", ", locationHashes)}]");
        }

        // AC-11 cross-check at the DB level: each row's file_path embeds its flight_id.
        var rowA = rows.Single(r => r.FlightId == flightA);
        var rowB = rows.Single(r => r.FlightId == flightB);
        if (!rowA.FilePath.Contains(flightA.ToString()) || !rowB.FilePath.Contains(flightB.ToString()))
        {
            throw new Exception(
                $"AZ-503 AC-11: per-flight file_path must contain the flight_id segment. " +
                $"rowA.file_path='{rowA.FilePath}', rowB.file_path='{rowB.FilePath}'.");
        }
        if (string.Equals(rowA.FilePath, rowB.FilePath, StringComparison.Ordinal))
        {
            throw new Exception(
                $"AZ-503 AC-11: per-flight file_path must differ between flights, got identical '{rowA.FilePath}'.");
        }

        Console.WriteLine($"  ✓ Two distinct uav rows for flight_id={flightA} and flight_id={flightB} coexist");
        Console.WriteLine($"  ✓ Both rows share location_hash={locationHashes[0]}");
        Console.WriteLine($"  ✓ Per-flight file_path differs ({rowA.FilePath} != {rowB.FilePath})");
    }

    private static async Task FloatRoundingDoesNotBreakIdempotence_AZ503_AC4(string apiUrl, string secret, string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-503 AC-4: two UAV uploads for the same (z, x, y) with float-different lat/lon collapse to one row");

        // Arrange — same (z, x, y) coords but two slightly-different lat/lon values.
        // The new integer-keyed UPSERT must collapse them; the AZ-484 lat/lon-keyed
        // UPSERT would have left two duplicate rows.
        var coord = NextTestCoordinate();
        const int zoom = 18;
        const double sizeMeters = 200.0;
        var flightId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");

        using var client = CreateClient(apiUrl);
        AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));

        // First upload: exact center of the cell as returned by NextTestCoordinate.
        var firstMeta = new
        {
            items = new[]
            {
                new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.AddMinutes(-20).ToString("o"), flightId }
            }
        };

        // Second upload: a coordinate offset by < 1 m so it lands in the same (tile_x,
        // tile_y) bucket but with a different float bit pattern.
        var nudgedLat = coord.Latitude + 1e-7;
        var nudgedLon = coord.Longitude + 1e-7;
        var secondMeta = new
        {
            items = new[]
            {
                new { latitude = nudgedLat, longitude = nudgedLon, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o"), flightId }
            }
        };

        // Act
        var first = await PostBatch(client, firstMeta, new[] { CreateValidJpeg(seed: 31) });
        await EnsureStatus(first, HttpStatusCode.OK, "AC-4 first upload");
        var second = await PostBatch(client, secondMeta, new[] { CreateValidJpeg(seed: 32) });
        await EnsureStatus(second, HttpStatusCode.OK, "AC-4 second upload");

        // Assert
        var rows = await QueryUavRowsByFlightAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters, alsoTryLatitude: nudgedLat, alsoTryLongitude: nudgedLon);
        var flightRows = rows.Where(r => r.FlightId == flightId).ToList();
        if (flightRows.Count != 1)
        {
            throw new Exception(
                $"AZ-503 AC-4: expected exactly 1 uav row after float-different upload (integer-keyed UPSERT must collapse), got {flightRows.Count}. " +
                $"Rows: [{string.Join(", ", flightRows.Select(r => $"id={r.Id} lat={r.Latitude} lon={r.Longitude}"))}]");
        }

        Console.WriteLine("  ✓ Two uploads at float-different lat/lon but same (tile_x, tile_y) collapsed to a single row");
    }

    private static async Task NoToken_Returns401(string apiUrl)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-488 AC-5: Unauthenticated request returns 401");

        // Arrange
        using var client = CreateClient(apiUrl);
        var coord = NextTestCoordinate();
        var metadata = new
        {
            items = new[]
            {
                new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }
            }
        };

        // Act
        var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });

        // Assert
        await EnsureStatus(response, HttpStatusCode.Unauthorized, "AC-5 no token");
        Console.WriteLine("  ✓ Anonymous upload returns HTTP 401");
    }

    private static async Task ValidTokenWithoutGpsPermission_Returns403(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-488 AC-6: Authenticated request without GPS permission returns 403");

        // Arrange
        using var client = CreateClient(apiUrl);
        AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: new[] { new Claim(PermissionsClaimType, "FL") }));
        var coord = NextTestCoordinate();
        var metadata = new
        {
            items = new[]
            {
                new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }
            }
        };

        // Act
        var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });

        // Assert
        await EnsureStatus(response, HttpStatusCode.Forbidden, "AC-6 missing permission");
        Console.WriteLine("  ✓ Token without GPS permission returns HTTP 403");
    }

    private static async Task OversizedBatch_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-488 AC-8: Oversized batch (>MaxBatchSize) returns 400");

        // Arrange — 101 metadata entries + 101 tiny placeholders.
        const int oversize = 101;
        var coord = NextTestCoordinate();
        var metadata = new
        {
            items = Enumerable.Range(0, oversize).Select(i => new
            {
                latitude = coord.Latitude + i * 0.0001,
                longitude = coord.Longitude,
                tileZoom = 18,
                tileSizeMeters = 200.0,
                capturedAt = DateTime.UtcNow.ToString("o")
            }).ToArray()
        };
        // Use tiny placeholder bytes so the body stays well under Kestrel's limit while
        // still exceeding the per-batch MaxBatchSize cap that the handler enforces.
        var placeholder = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 };
        var files = Enumerable.Range(0, oversize).Select(_ => placeholder).ToArray();
        using var client = CreateClient(apiUrl);
        AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));

        // Act
        var response = await PostBatch(client, metadata, files);

        // Assert
        await EnsureStatus(response, HttpStatusCode.BadRequest, "AC-8 oversized batch");
        Console.WriteLine("  ✓ Oversized batch returns HTTP 400");
    }

    private static async Task<HttpResponseMessage> PostBatch(HttpClient client, object metadata, IReadOnlyList<byte[]> files)
    {
        using var content = new MultipartFormDataContent();
        content.Add(new StringContent(JsonSerializer.Serialize(metadata)), "metadata");
        for (var i = 0; i < files.Count; i++)
        {
            var item = new ByteArrayContent(files[i]);
            item.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
            content.Add(item, "files", $"tile_{i}.jpg");
        }

        return await client.PostAsync(UploadPath, content);
    }

    private static async Task EnsureStatus(HttpResponseMessage response, HttpStatusCode expected, string label)
    {
        if (response.StatusCode != expected)
        {
            var body = await response.Content.ReadAsStringAsync();
            throw new Exception($"{label}: expected HTTP {(int)expected}, got HTTP {(int)response.StatusCode}. Body: {body}");
        }
    }

    private static HttpClient CreateClient(string apiUrl) =>
        new() { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };

    private static void AttachToken(HttpClient client, string token)
    {
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
    }

    private static IEnumerable<Claim> GpsClaim() => new[] { new Claim(PermissionsClaimType, GpsPermission) };

    private static byte[] CreateValidJpeg(int width = 256, int height = 256, int seed = 42)
    {
        using var image = new Image<Rgba32>(width, height);
        var random = new Random(seed);
        image.ProcessPixelRows(accessor =>
        {
            for (var y = 0; y < accessor.Height; y++)
            {
                var row = accessor.GetRowSpan(y);
                for (var x = 0; x < row.Length; x++)
                {
                    row[x] = new Rgba32(
                        (byte)random.Next(256),
                        (byte)random.Next(256),
                        (byte)random.Next(256));
                }
            }
        });

        using var stream = new MemoryStream();
        image.Save(stream, new JpegEncoder { Quality = 95 });
        return stream.ToArray();
    }

    // Seed the counter from a wall-clock value so each test-runner process picks a
    // distinct coordinate band. Postgres state persists across docker-compose runs
    // (named volume); a monotonic counter from 0 would collide with prior runs on
    // the per-source unique index, especially for tests that seed rows via raw
    // INSERT rather than the API's UPSERT path.
    // Kept as defense-in-depth after AZ-493 introduced a Program.cs startup DB
    // reset. If the reset is skipped via --keep-state OR fails silently, the
    // wallclock seed still spreads coordinates across runs so the per-source
    // unique index does not collide. Safe to remove if/when the DB-reset path
    // becomes load-bearing for every run.
    private static int _coordinateCounter = (int)((DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond) % 1_000_000);

    private static (double Latitude, double Longitude) NextTestCoordinate()
    {
        // Spread test coordinates far enough apart to fall into distinct tile cells
        // so concurrent runs don't collide on the per-source unique index. Wrap on
        // 40_000-cell axes so the result always stays strictly inside the
        // OSM-valid ranges enforced by UavTileMetadataValidator (AZ-810):
        // lat in [50.0, 70.0), lon in [10.0, 40.0).
        var n = Interlocked.Increment(ref _coordinateCounter);
        var lat = 50.0 + ((uint)n % 40_000u) * 0.0005;
        var lon = 10.0 + ((uint)n % 60_000u) * 0.0005;
        return (lat, lon);
    }

    private static async Task<int> CountUavRowsAsync(string connectionString, double latitude, double longitude)
    {
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();
        await using var cmd = new NpgsqlCommand(
            "SELECT COUNT(*) FROM tiles WHERE source = 'uav' AND latitude = @lat AND longitude = @lon;", conn);
        cmd.Parameters.AddWithValue("lat", latitude);
        cmd.Parameters.AddWithValue("lon", longitude);
        var scalar = await cmd.ExecuteScalarAsync();
        return scalar is long l ? (int)l : Convert.ToInt32(scalar);
    }

    private static async Task<HashSet<string>> QuerySourcesAsync(string connectionString, double latitude, double longitude, int zoom, double sizeMeters)
    {
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();
        await using var cmd = new NpgsqlCommand(
            "SELECT source FROM tiles WHERE latitude = @lat AND longitude = @lon AND tile_zoom = @zoom AND tile_size_meters = @size;", conn);
        cmd.Parameters.AddWithValue("lat", latitude);
        cmd.Parameters.AddWithValue("lon", longitude);
        cmd.Parameters.AddWithValue("zoom", zoom);
        cmd.Parameters.AddWithValue("size", sizeMeters);

        var sources = new HashSet<string>(StringComparer.Ordinal);
        await using var reader = await cmd.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            sources.Add(reader.GetString(0));
        }
        return sources;
    }

    private sealed record UavRowProjection(Guid Id, Guid? FlightId, Guid LocationHash, double Latitude, double Longitude, string FilePath);

    private static async Task<List<UavRowProjection>> QueryUavRowsByFlightAsync(
        string connectionString,
        double latitude,
        double longitude,
        int zoom,
        double sizeMeters,
        double? alsoTryLatitude = null,
        double? alsoTryLongitude = null)
    {
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();

        // The UPSERT preserves the latitude/longitude of the row that won the
        // race; for AC-3 / AC-4 we need to find rows produced from EITHER input
        // coordinate, so widen the lookup by a few meters of float wiggle room.
        const string sql = @"
            SELECT id, flight_id, location_hash, latitude, longitude, file_path
            FROM tiles
            WHERE source = 'uav'
              AND tile_zoom = @zoom
              AND tile_size_meters = @size
              AND (
                (latitude = @lat AND longitude = @lon)
                 OR (latitude = @lat2 AND longitude = @lon2)
              );";

        var rows = new List<UavRowProjection>();
        await using var cmd = new NpgsqlCommand(sql, conn);
        cmd.Parameters.AddWithValue("lat", latitude);
        cmd.Parameters.AddWithValue("lon", longitude);
        cmd.Parameters.AddWithValue("lat2", alsoTryLatitude ?? latitude);
        cmd.Parameters.AddWithValue("lon2", alsoTryLongitude ?? longitude);
        cmd.Parameters.AddWithValue("zoom", zoom);
        cmd.Parameters.AddWithValue("size", sizeMeters);
        await using var reader = await cmd.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            rows.Add(new UavRowProjection(
                reader.GetGuid(0),
                reader.IsDBNull(1) ? null : reader.GetGuid(1),
                reader.GetGuid(2),
                reader.GetDouble(3),
                reader.GetDouble(4),
                reader.GetString(5)));
        }
        return rows;
    }

    private static async Task ExecuteAsync(string connectionString, string sql, params (string Name, object Value)[] parameters)
    {
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();
        await using var cmd = new NpgsqlCommand(sql, conn);
        foreach (var (name, value) in parameters)
        {
            cmd.Parameters.AddWithValue(name, value);
        }
        await cmd.ExecuteNonQueryAsync();
    }

    private sealed record UavResponseEnvelope
    {
        public List<UavResponseItem> Items { get; init; } = new();
    }

    private sealed record UavResponseItem
    {
        public int Index { get; init; }
        public string Status { get; init; } = string.Empty;
        public Guid? TileId { get; init; }
        public string? RejectReason { get; init; }
        public string? RejectDetails { get; init; }
    }
}
