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

namespace SatelliteProvider.IntegrationTests;

// AZ-810: end-to-end coverage for POST /api/satellite/upload strict metadata
// validation. Each test exercises one of the 14 rules listed in the AZ-810
// task spec and asserts the response conforms to the RFC 7807
// ValidationProblemDetails contract in
// `_docs/02_document/contracts/api/error-shape.md` v1.0.0.
//
// The endpoint is multipart/form-data, so the validator wires in through the
// custom `UavUploadValidationFilter` (NOT the generic `WithValidation<T>()`
// filter that the JSON-body endpoints use). Three enforcement layers compose:
//   1. UnmappedMemberHandling.Disallow + [JsonRequired] — the metadata JSON
//      is deserialized inside the filter via the strict global
//      `JsonSerializerOptions`; missing-required and unknown fields raise
//      JsonException which the filter surfaces under `errors["metadata"]`.
//   2. UavTileBatchMetadataPayloadValidator + UavTileMetadataValidator —
//      FluentValidation rules on the deserialized payload (item count, per-
//      item lat/lon/zoom/size/freshness). Errors are prefixed with
//      `metadata.` so paths look like `errors["metadata.items[0].latitude"]`.
//   3. Cross-field envelope rule (items.Count == files.Count) — runs after
//      the per-payload validator; surfaces under `errors["metadata.items"]`
//      AND `errors["files"]`.
//
// AC-9 (no regression in existing UavUploadTests) is enforced by leaving the
// pre-AZ-810 happy path here as a separate scenario and by exercising the
// existing AZ-488 suite unchanged from Program.Main.
public static class UavUploadValidationTests
{
    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: POST /api/satellite/upload strict metadata validation (AZ-810)");

        // AC-2: happy path unchanged (well-formed multipart envelope still 200).
        await HappyPath_Returns200(apiUrl, secret);

        // Rule 2: metadata form field absent
        await MissingMetadataField_Returns400(apiUrl, secret);

        // Rule 3: metadata JSON malformed
        await MalformedMetadataJson_Returns400(apiUrl, secret);

        // Rule 4: items missing (empty)
        await EmptyItems_Returns400(apiUrl, secret);

        // Rule 5: items count > MaxBatchSize
        await ItemsOverCap_Returns400(apiUrl, secret);

        // Rule 6: items.Count != files.Count
        await ItemsFilesMismatch_Returns400(apiUrl, secret);

        // Rule 7: per-item lat out of range
        await ItemLatOutOfRange_Returns400(apiUrl, secret);

        // Rule 8: per-item lon out of range
        await ItemLonOutOfRange_Returns400(apiUrl, secret);

        // Rule 9: per-item tileZoom out of range
        await ItemTileZoomOutOfRange_Returns400(apiUrl, secret);

        // Rule 10: per-item tileSizeMeters <= 0
        await ItemTileSizeMetersNonPositive_Returns400(apiUrl, secret);

        // Rule 11a: capturedAt too far in the future
        await ItemCapturedAtFuture_Returns400(apiUrl, secret);

        // Rule 11b: capturedAt older than MaxAgeDays
        await ItemCapturedAtTooOld_Returns400(apiUrl, secret);

        // Rule 12: malformed flightId UUID (deserializer JsonException path)
        await ItemFlightIdMalformed_Returns400(apiUrl, secret);

        // Rule 13: unknown field at the root of metadata
        await UnknownRootField_Returns400(apiUrl, secret);

        // Rule 13b: unknown field nested under items[i]
        await UnknownNestedField_Returns400(apiUrl, secret);

        // Rule 14: type mismatch (lat as string)
        await ItemLatTypeMismatch_Returns400(apiUrl, secret);

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

    private static async Task HappyPath_Returns200(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 AC-2: well-formed metadata + 1 valid file → HTTP 200");

        // 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 = CreateClientWithGpsToken(apiUrl, secret);

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

        // Assert
        await EnsureStatus(response, HttpStatusCode.OK, "AZ-810 AC-2 happy path");
        Console.WriteLine("  ✓ Well-formed multipart envelope accepted with HTTP 200");
    }

    private static async Task MissingMetadataField_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 2: missing `metadata` form field → HTTP 400");

        // Arrange — multipart body with only the `files` part, no `metadata`.
        using var client = CreateClientWithGpsToken(apiUrl, secret);
        using var content = new MultipartFormDataContent();
        var file = new ByteArrayContent(CreateValidJpeg());
        file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
        content.Add(file, "files", "tile_0.jpg");

        // Act
        using var response = await client.PostAsync(UploadPath, content);
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 missing metadata");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 missing metadata");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 missing metadata");

        Console.WriteLine("  ✓ Missing `metadata` form field rejected with HTTP 400");
    }

    private static async Task MalformedMetadataJson_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 3: malformed metadata JSON → HTTP 400");

        // Arrange — unterminated JSON object.
        using var client = CreateClientWithGpsToken(apiUrl, secret);
        const string brokenJson = "{\"items\": [{ \"latitude\": 50.10, \"longitude\": 36.10";
        using var content = new MultipartFormDataContent
        {
            { new StringContent(brokenJson), "metadata" },
        };
        var file = new ByteArrayContent(CreateValidJpeg());
        file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
        content.Add(file, "files", "tile_0.jpg");

        // Act
        using var response = await client.PostAsync(UploadPath, content);
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 malformed JSON");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 malformed JSON");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 malformed JSON");
        var metadataError = problem.GetProperty("errors").GetProperty("metadata")[0].GetString();
        if (metadataError is not null && metadataError.Contains("System.", StringComparison.Ordinal))
        {
            throw new InvalidOperationException("Malformed metadata response must not leak System.* type names.");
        }

        Console.WriteLine("  ✓ Malformed metadata JSON rejected with errors[\"metadata\"]");
    }

    private static async Task EmptyItems_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 4: empty `items` → HTTP 400");

        // Arrange — well-formed JSON, but items: [] tripping FluentValidation.
        using var client = CreateClientWithGpsToken(apiUrl, secret);
        var metadata = new { items = Array.Empty<object>() };

        // Act — no files either; the items rule fires before the count-mismatch rule.
        using var response = await PostBatch(client, metadata, Array.Empty<byte[]>());
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 empty items");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 empty items");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 empty items");

        Console.WriteLine("  ✓ Empty items rejected with errors mention of `items`");
    }

    private static async Task ItemsOverCap_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 5: items.Count > MaxBatchSize → HTTP 400 from validator");

        // Arrange — 101 metadata entries + 101 tiny placeholders so this exercises
        // the AZ-810 validator path specifically (the count-mismatch rule does not
        // fire because items.Count == files.Count).
        const int oversize = 101;
        var baseCoord = NextTestCoordinate();
        var metadata = new
        {
            items = Enumerable.Range(0, oversize).Select(i => new
            {
                latitude = baseCoord.Latitude + i * 0.0001,
                longitude = baseCoord.Longitude,
                tileZoom = 18,
                tileSizeMeters = 200.0,
                capturedAt = DateTime.UtcNow.ToString("o"),
            }).ToArray(),
        };
        var placeholder = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 };
        var files = Enumerable.Range(0, oversize).Select(_ => placeholder).ToArray();
        using var client = CreateClientWithGpsToken(apiUrl, secret);

        // Act
        using var response = await PostBatch(client, metadata, files);
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 items over cap");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 items over cap");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 items over cap");

        Console.WriteLine("  ✓ items.Count=101 (> 100 cap) rejected with errors mention of `items`");
    }

    private static async Task ItemsFilesMismatch_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 6: items.Count != files.Count → HTTP 400");

        // Arrange — 2 metadata items but only 1 file.
        var c1 = NextTestCoordinate();
        var c2 = NextTestCoordinate();
        var metadata = new
        {
            items = new[]
            {
                new { latitude = c1.Latitude, longitude = c1.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
                new { latitude = c2.Latitude, longitude = c2.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
            },
        };
        using var client = CreateClientWithGpsToken(apiUrl, secret);

        // Act
        using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 items/files mismatch");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 items/files mismatch");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 items/files mismatch");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "files", label: "AZ-810 items/files mismatch");

        Console.WriteLine("  ✓ items.Count=2 / files.Count=1 rejected with both `items` and `files` mentioned");
    }

    private static async Task ItemLatOutOfRange_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 7: per-item latitude out of range → HTTP 400 (errors[metadata.items[i].latitude])");

        // Arrange — second item has lat = 91.0 (above the +90 bound).
        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") },
                new { latitude = 91.0,           longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
            },
        };
        using var client = CreateClientWithGpsToken(apiUrl, secret);

        // Act
        using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg(), CreateValidJpeg(seed: 2) });
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item lat out of range");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item lat out of range");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[1].latitude", label: "AZ-810 item lat out of range");

        Console.WriteLine("  ✓ items[1].latitude=91.0 rejected with indexed errors path");
    }

    private static async Task ItemLonOutOfRange_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 8: per-item longitude out of range → HTTP 400 (errors[metadata.items[i].longitude])");

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

        // Act
        using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item lon out of range");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item lon out of range");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].longitude", label: "AZ-810 item lon out of range");

        Console.WriteLine("  ✓ items[0].longitude=181 rejected with indexed errors path");
    }

    private static async Task ItemTileZoomOutOfRange_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 9: per-item tileZoom out of range → HTTP 400");

        // Arrange — zoom = 30 (above the 22 cap).
        var coord = NextTestCoordinate();
        var metadata = new
        {
            items = new[]
            {
                new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 30, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
            },
        };
        using var client = CreateClientWithGpsToken(apiUrl, secret);

        // Act
        using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item tileZoom out of range");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item tileZoom out of range");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].tileZoom", label: "AZ-810 item tileZoom out of range");

        Console.WriteLine("  ✓ items[0].tileZoom=30 rejected with indexed errors path");
    }

    private static async Task ItemTileSizeMetersNonPositive_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 10: per-item tileSizeMeters <= 0 → HTTP 400");

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

        // Act
        using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item tileSizeMeters non-positive");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item tileSizeMeters non-positive");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].tileSizeMeters", label: "AZ-810 item tileSizeMeters non-positive");

        Console.WriteLine("  ✓ items[0].tileSizeMeters=0.0 rejected with indexed errors path");
    }

    private static async Task ItemCapturedAtFuture_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 11a: per-item capturedAt > now + CapturedAtFutureSkewSeconds → HTTP 400");

        // Arrange — 1 hour in the future (default skew is 30s).
        var coord = NextTestCoordinate();
        var metadata = new
        {
            items = new[]
            {
                new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddHours(1).ToString("o") },
            },
        };
        using var client = CreateClientWithGpsToken(apiUrl, secret);

        // Act
        using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item capturedAt future");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item capturedAt future");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].capturedAt", label: "AZ-810 item capturedAt future");

        Console.WriteLine("  ✓ items[0].capturedAt = now+1h rejected with indexed errors path");
    }

    private static async Task ItemCapturedAtTooOld_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 11b: per-item capturedAt older than MaxAgeDays → HTTP 400");

        // Arrange — 60 days old (default MaxAgeDays is 7).
        var coord = NextTestCoordinate();
        var metadata = new
        {
            items = new[]
            {
                new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddDays(-60).ToString("o") },
            },
        };
        using var client = CreateClientWithGpsToken(apiUrl, secret);

        // Act
        using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item capturedAt too old");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item capturedAt too old");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].capturedAt", label: "AZ-810 item capturedAt too old");

        Console.WriteLine("  ✓ items[0].capturedAt = now-60d rejected with indexed errors path");
    }

    private static async Task ItemFlightIdMalformed_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 12: malformed flightId → HTTP 400 (JsonException at deserializer)");

        // Arrange — flightId is a non-UUID string. System.Text.Json rejects this at
        // the deserializer; the filter catches the JsonException and surfaces it as
        // errors["metadata"].
        var coord = NextTestCoordinate();
        var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$"""
            {
              "items": [
                {
                  "latitude": {{{coord.Latitude}}},
                  "longitude": {{{coord.Longitude}}},
                  "tileZoom": 18,
                  "tileSizeMeters": 200.0,
                  "capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}",
                  "flightId": "not-a-uuid"
                }
              ]
            }
            """);
        using var client = CreateClientWithGpsToken(apiUrl, secret);
        using var content = new MultipartFormDataContent
        {
            { new StringContent(metadataJson), "metadata" },
        };
        var file = new ByteArrayContent(CreateValidJpeg());
        file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
        content.Add(file, "files", "tile_0.jpg");

        // Act
        using var response = await client.PostAsync(UploadPath, content);
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 flightId malformed");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 flightId malformed");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 flightId malformed");

        Console.WriteLine("  ✓ flightId=\"not-a-uuid\" rejected with errors[\"metadata\"]");
    }

    private static async Task UnknownRootField_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 13: unknown root field in metadata → HTTP 400 (UnmappedMemberHandling.Disallow)");

        // Arrange — `debug` is not a member of UavTileBatchMetadataPayload.
        var coord = NextTestCoordinate();
        var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$"""
            {
              "items": [
                {
                  "latitude": {{{coord.Latitude}}},
                  "longitude": {{{coord.Longitude}}},
                  "tileZoom": 18,
                  "tileSizeMeters": 200.0,
                  "capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}"
                }
              ],
              "debug": "fingerprint-probe"
            }
            """);
        using var client = CreateClientWithGpsToken(apiUrl, secret);
        using var content = new MultipartFormDataContent
        {
            { new StringContent(metadataJson), "metadata" },
        };
        var file = new ByteArrayContent(CreateValidJpeg());
        file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
        content.Add(file, "files", "tile_0.jpg");

        // Act
        using var response = await client.PostAsync(UploadPath, content);
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 unknown root field");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 unknown root field");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 unknown root field");

        Console.WriteLine("  ✓ Unknown root field `debug` rejected with errors[\"metadata\"]");
    }

    private static async Task UnknownNestedField_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 13b: unknown nested field under items[i] → HTTP 400");

        // Arrange — `altitude` is not a member of UavTileMetadata.
        var coord = NextTestCoordinate();
        var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$"""
            {
              "items": [
                {
                  "latitude": {{{coord.Latitude}}},
                  "longitude": {{{coord.Longitude}}},
                  "tileZoom": 18,
                  "tileSizeMeters": 200.0,
                  "capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}",
                  "altitude": 500.0
                }
              ]
            }
            """);
        using var client = CreateClientWithGpsToken(apiUrl, secret);
        using var content = new MultipartFormDataContent
        {
            { new StringContent(metadataJson), "metadata" },
        };
        var file = new ByteArrayContent(CreateValidJpeg());
        file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
        content.Add(file, "files", "tile_0.jpg");

        // Act
        using var response = await client.PostAsync(UploadPath, content);
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 unknown nested field");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 unknown nested field");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 unknown nested field");

        Console.WriteLine("  ✓ Unknown nested field `altitude` rejected with errors[\"metadata\"]");
    }

    private static async Task ItemLatTypeMismatch_Returns400(string apiUrl, string secret)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-810 rule 14: nested type mismatch (`items[0].latitude` as string) → HTTP 400");

        // Arrange
        var coord = NextTestCoordinate();
        var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$"""
            {
              "items": [
                {
                  "latitude": "fifty",
                  "longitude": {{{coord.Longitude}}},
                  "tileZoom": 18,
                  "tileSizeMeters": 200.0,
                  "capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}"
                }
              ]
            }
            """);
        using var client = CreateClientWithGpsToken(apiUrl, secret);
        using var content = new MultipartFormDataContent
        {
            { new StringContent(metadataJson), "metadata" },
        };
        var file = new ByteArrayContent(CreateValidJpeg());
        file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
        content.Add(file, "files", "tile_0.jpg");

        // Act
        using var response = await client.PostAsync(UploadPath, content);
        var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 lat type mismatch");

        // Assert
        ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 lat type mismatch");
        ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 lat type mismatch");

        Console.WriteLine("  ✓ items[0].latitude=\"fifty\" rejected with errors[\"metadata\"]");
    }

    private static HttpClient CreateClientWithGpsToken(string apiUrl, string secret)
    {
        var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
        var token = JwtTestHelpers.MintAuthenticated(secret, extraClaims: new[] { new Claim(PermissionsClaimType, GpsPermission) });
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
        return client;
    }

    private static async Task<HttpResponseMessage> PostBatch(HttpClient client, object metadata, IReadOnlyList<byte[]> files)
    {
        using var content = new MultipartFormDataContent
        {
            { 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 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();
    }

    // Use a southern-hemisphere range that does NOT overlap UavUploadTests'
    // northern range ([50,70) x [10,40)). Non-overlap (not counter offset) is
    // what guarantees the AZ-488 and AZ-810 suites don't collide on the
    // per-source UNIQUE index when both run against the same DB. Wrap on
    // 40_000-cell axes so the result always stays strictly inside the
    // OSM-valid ranges enforced by UavTileMetadataValidator:
    // lat in [-70.0, -50.0), lon in [-40.0, -10.0).
    private static int _coordinateCounter = (int)((DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond) % 1_000_000);

    private static (double Latitude, double Longitude) NextTestCoordinate()
    {
        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);
    }
}
