using Npgsql;

namespace SatelliteProvider.IntegrationTests;

public static class MigrationTests
{
    public static async Task RunAll()
    {
        Console.WriteLine();
        Console.WriteLine("Test: Migration 012 (AZ-357 / C06)");
        Console.WriteLine("==================================");
        Console.WriteLine();

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

        await DedupeSqlCollapsesDuplicatesByLatestUpdatedAt_AZ357_AC2(connectionString);

        Console.WriteLine("✓ Migration 012 tests: PASSED");

        Console.WriteLine();
        Console.WriteLine("Test: Migration 013 (AZ-484)");
        Console.WriteLine("============================");
        Console.WriteLine();

        await BackfillUpdateAssignsGoogleMapsAndCapturedAt_AZ484_AC4(connectionString);
        await MultiSourceInsertCoexistsUnderNewIndex_AZ484_AC1(connectionString);
        await MostRecentAcrossSourcesSelection_AZ484_AC2(connectionString);
        await SameSourceUpsertReplacesPreviousRow_AZ484_AC3(connectionString);
        await Az503MigrationSupersedesAz484UniqueIndex(connectionString);

        Console.WriteLine("✓ Migration 013 tests: PASSED");

        Console.WriteLine();
        Console.WriteLine("Test: Migration 014 (AZ-503-foundation)");
        Console.WriteLine("========================================");
        Console.WriteLine();

        await Az503ColumnsExistAndLocationHashIsNotNull(connectionString);
        await Az503NewUniqueIndexCoversIntegerKeyAndFlightId(connectionString);
        await Az503LocationHashBackfillIsDeterministic(connectionString);

        Console.WriteLine("✓ Migration 014 tests: PASSED");
    }

    private static async Task DedupeSqlCollapsesDuplicatesByLatestUpdatedAt_AZ357_AC2(string connectionString)
    {
        Console.WriteLine("AZ-357 AC-2 part 1: dedupe SQL keeps row with highest updated_at, tie-breaks on id");

        // Arrange
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();
        // Session-scoped TEMP table (auto-dropped when the connection closes).
        // Do NOT use ON COMMIT DROP — Npgsql commits implicitly after each command,
        // which would drop the table before the next INSERT runs.
        await ExecAsync(conn, """
            CREATE TEMP TABLE tiles_dedupe_test (
                id UUID PRIMARY KEY,
                latitude DOUBLE PRECISION NOT NULL,
                longitude DOUBLE PRECISION NOT NULL,
                tile_zoom INT NOT NULL,
                tile_size_meters DOUBLE PRECISION NOT NULL,
                version INT,
                updated_at TIMESTAMP NOT NULL
            );
            """);

        // Three rows that all share (lat=10.0, lon=20.0, zoom=18, size=100):
        //  - row A: 2024 version, oldest updated_at  -> should be deleted
        //  - row B: 2025 version, middle updated_at  -> should be deleted
        //  - row C: 2026 version, newest updated_at  -> should survive
        // Two rows that share (lat=11.0, lon=21.0, zoom=18, size=100) but tie on updated_at:
        //  - row D: id larger, same updated_at as E  -> should survive (id-tiebreak wins)
        //  - row E: id smaller, same updated_at as D -> should be deleted
        // One unique row (lat=12.0, lon=22.0, zoom=18, size=100):
        //  - row F: should always survive
        var idA = Guid.Parse("11111111-1111-1111-1111-111111111111");
        var idB = Guid.Parse("22222222-2222-2222-2222-222222222222");
        var idC = Guid.Parse("33333333-3333-3333-3333-333333333333");
        var idD = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff");
        var idE = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
        var idF = Guid.Parse("99999999-9999-9999-9999-999999999999");

        await ExecAsync(conn, """
            INSERT INTO tiles_dedupe_test (id, latitude, longitude, tile_zoom, tile_size_meters, version, updated_at) VALUES
                (@idA, 10.0, 20.0, 18, 100, 2024, '2024-06-01 00:00:00'),
                (@idB, 10.0, 20.0, 18, 100, 2025, '2025-06-01 00:00:00'),
                (@idC, 10.0, 20.0, 18, 100, 2026, '2026-06-01 00:00:00'),
                (@idD, 11.0, 21.0, 18, 100, 2025, '2025-09-01 00:00:00'),
                (@idE, 11.0, 21.0, 18, 100, 2026, '2025-09-01 00:00:00'),
                (@idF, 12.0, 22.0, 18, 100, 2025, '2025-01-01 00:00:00');
            """,
            ("idA", idA), ("idB", idB), ("idC", idC), ("idD", idD), ("idE", idE), ("idF", idF));

        // Act — run the same dedupe pattern that 012_DropTileVersionConstraint.sql uses, against the temp table
        await ExecAsync(conn, """
            DELETE FROM tiles_dedupe_test
            WHERE id IN (
                SELECT id FROM (
                    SELECT id,
                           ROW_NUMBER() OVER (
                               PARTITION BY latitude, longitude, tile_zoom, tile_size_meters
                               ORDER BY updated_at DESC, id DESC
                           ) AS rn
                    FROM tiles_dedupe_test
                ) ranked
                WHERE rn > 1
            );
            """);

        // Assert
        var survivors = await QueryGuidsAsync(conn, "SELECT id FROM tiles_dedupe_test ORDER BY id;");
        var expected = new HashSet<Guid> { idC, idD, idF };
        var actual = new HashSet<Guid>(survivors);

        if (!actual.SetEquals(expected))
        {
            throw new Exception(
                $"AZ-357 AC-2 dedupe failed.\n" +
                $"  Expected survivors: {string.Join(", ", expected)}\n" +
                $"  Actual survivors:   {string.Join(", ", actual)}");
        }

        Console.WriteLine("  ✓ Dedupe collapsed 3-way duplicate to row with newest updated_at (idC)");
        Console.WriteLine("  ✓ Dedupe broke updated_at tie by largest id (idD survived, idE removed)");
        Console.WriteLine("  ✓ Unique row (idF) preserved");
    }

    private static async Task Az503MigrationSupersedesAz484UniqueIndex(string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-484/AZ-503 supersession: AZ-503 migration 014 drops the AZ-484 unique index in favour of the integer-key + flight_id index");

        // Arrange / Act
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();

        var rows = await QueryIndexesAsync(conn);

        // Assert — AZ-484's idx_tiles_unique_location_source must NOT exist anymore after migration 014.
        var supersededIndex = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_location_source", StringComparison.Ordinal));
        if (supersededIndex.Def is not null)
        {
            throw new Exception(
                "AZ-503: legacy AZ-484 index 'idx_tiles_unique_location_source' still exists after migration 014 — migration did not drop it. " +
                $"Definition: {supersededIndex.Def}");
        }

        // Pre-AZ-484 4-column index must also remain dropped.
        var preAz484Index = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_location", StringComparison.Ordinal));
        if (preAz484Index.Def is not null)
        {
            throw new Exception(
                "AZ-503: pre-AZ-484 4-column index 'idx_tiles_unique_location' reappeared after migration 014. " +
                $"Definition: {preAz484Index.Def}");
        }

        Console.WriteLine("  ✓ AZ-484 'idx_tiles_unique_location_source' dropped by migration 014 (superseded)");
        Console.WriteLine("  ✓ Pre-AZ-484 'idx_tiles_unique_location' remains dropped");
    }

    private static async Task Az503ColumnsExistAndLocationHashIsNotNull(string connectionString)
    {
        Console.WriteLine("AZ-503 AC-6: migration 014 adds flight_id, location_hash, content_sha256, legacy_id with correct nullability");

        // Arrange
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();

        const string sql = @"
            SELECT column_name, data_type, is_nullable
            FROM information_schema.columns
            WHERE table_schema = 'public' AND table_name = 'tiles'
              AND column_name IN ('flight_id', 'location_hash', 'content_sha256', 'legacy_id');";

        var columns = new Dictionary<string, (string DataType, bool IsNullable)>(StringComparer.Ordinal);
        await using (var cmd = new NpgsqlCommand(sql, conn))
        await using (var reader = await cmd.ExecuteReaderAsync())
        {
            while (await reader.ReadAsync())
            {
                columns[reader.GetString(0)] = (
                    reader.GetString(1),
                    string.Equals(reader.GetString(2), "YES", StringComparison.OrdinalIgnoreCase));
            }
        }

        // Assert — flight_id, location_hash, content_sha256, legacy_id must exist with the contractual shape.
        AssertColumn(columns, "flight_id", expectedType: "uuid", expectedNullable: true);
        AssertColumn(columns, "location_hash", expectedType: "uuid", expectedNullable: false);
        AssertColumn(columns, "content_sha256", expectedType: "bytea", expectedNullable: true);
        AssertColumn(columns, "legacy_id", expectedType: "uuid", expectedNullable: true);

        Console.WriteLine("  ✓ flight_id (uuid, nullable), location_hash (uuid, NOT NULL), content_sha256 (bytea, nullable), legacy_id (uuid, nullable)");
    }

    private static async Task Az503NewUniqueIndexCoversIntegerKeyAndFlightId(string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-503 AC-9: idx_tiles_unique_identity is unique on (tile_zoom, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, ...))");

        // Arrange / Act
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();

        var rows = await QueryIndexesAsync(conn);

        // Assert
        var newIndex = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_identity", StringComparison.Ordinal));
        if (newIndex.Def is null)
        {
            throw new Exception(
                "AZ-503 AC-9: expected unique index 'idx_tiles_unique_identity' on tiles after migration 014, but it is not present. " +
                $"Found indexes: {string.Join(", ", rows.Select(r => r.Name))}");
        }

        var lower = newIndex.Def.ToLowerInvariant();
        if (!lower.Contains("unique"))
        {
            throw new Exception($"AZ-503 AC-9: idx_tiles_unique_identity is not UNIQUE. Definition: {newIndex.Def}");
        }
        foreach (var col in new[] { "tile_zoom", "tile_x", "tile_y", "tile_size_meters", "source", "flight_id" })
        {
            if (!lower.Contains(col))
            {
                throw new Exception($"AZ-503 AC-9: idx_tiles_unique_identity missing column '{col}'. Definition: {newIndex.Def}");
            }
        }
        if (!lower.Contains("coalesce"))
        {
            throw new Exception(
                $"AZ-503 AC-9: idx_tiles_unique_identity must wrap flight_id in COALESCE so NULL flights collide deterministically. Definition: {newIndex.Def}");
        }

        // An index whose leading column is `location_hash` must exist so equality lookups
        // by hash have an index-driven access path. AZ-503 introduced this as
        // `idx_tiles_location_hash` in migration 014; AZ-505 supersedes it in migration 015
        // with `tiles_leaflet_path` (a covering index that keeps location_hash as the
        // leading column and adds ORDER BY columns + INCLUDE projection). Either name
        // satisfies the AC-9 intent — accept both so the AZ-503 contract remains
        // verifiable after migration 015 has applied.
        var locationHashIndex = rows.FirstOrDefault(r =>
            string.Equals(r.Name, "idx_tiles_location_hash", StringComparison.Ordinal) ||
            string.Equals(r.Name, "tiles_leaflet_path", StringComparison.Ordinal));
        if (locationHashIndex.Def is null)
        {
            throw new Exception(
                "AZ-503 AC-9: expected an index keyed by location_hash (either 'idx_tiles_location_hash' from migration 014 " +
                "or its AZ-505 successor 'tiles_leaflet_path' from migration 015) on tiles, but neither is present. " +
                $"Found indexes: {string.Join(", ", rows.Select(r => r.Name))}");
        }

        Console.WriteLine($"  ✓ New unique index present: {newIndex.Def}");
        Console.WriteLine($"  ✓ Supporting location_hash index present: {locationHashIndex.Def}");
    }

    private static async Task Az503LocationHashBackfillIsDeterministic(string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-503 AC-6: the location_hash backfill function used by migration 014 is deterministic and matches RFC 9562 §5.5");

        // Arrange — the migration installs pg_temp.uuidv5 then drops it; replay the same SHA-1 logic in a session
        // to confirm that two identical inputs produce byte-identical UUIDv5 values, and that two distinct inputs
        // produce different values.
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();

        await ExecAsync(conn, "CREATE EXTENSION IF NOT EXISTS pgcrypto;");
        await ExecAsync(conn, """
            CREATE OR REPLACE FUNCTION pg_temp.uuidv5_probe(namespace uuid, name text)
            RETURNS uuid
            LANGUAGE plpgsql
            IMMUTABLE
            AS $$
            DECLARE
                namespace_bytes bytea;
                input_bytes bytea;
                hash_bytes bytea;
                v5_bytes bytea;
            BEGIN
                namespace_bytes := decode(replace(namespace::text, '-', ''), 'hex');
                input_bytes := namespace_bytes || convert_to(name, 'UTF8');
                hash_bytes := digest(input_bytes, 'sha1');
                v5_bytes := substring(hash_bytes from 1 for 16);
                v5_bytes := set_byte(v5_bytes, 6, (get_byte(v5_bytes, 6) & 15) | 80);
                v5_bytes := set_byte(v5_bytes, 8, (get_byte(v5_bytes, 8) & 63) | 128);
                RETURN encode(v5_bytes, 'hex')::uuid;
            END;
            $$;
            """);

        // Act — location_hash canonical name is "{zoom}/{x}/{y}" (matches the migration backfill
        // and SatelliteProvider.Services.TileDownloader.TileService.BuildTileEntity).
        const string probeSql = @"
            SELECT
                pg_temp.uuidv5_probe('5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid, '18/12345/23456') AS v1,
                pg_temp.uuidv5_probe('5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid, '18/12345/23456') AS v1_again,
                pg_temp.uuidv5_probe('5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid, '18/12346/23456') AS v2;";

        Guid v1, v1Again, v2;
        await using (var cmd = new NpgsqlCommand(probeSql, conn))
        await using (var reader = await cmd.ExecuteReaderAsync())
        {
            if (!await reader.ReadAsync())
            {
                throw new Exception("AZ-503 AC-6: backfill probe returned no rows.");
            }
            v1 = reader.GetGuid(0);
            v1Again = reader.GetGuid(1);
            v2 = reader.GetGuid(2);
        }

        // Assert
        if (v1 != v1Again)
        {
            throw new Exception(
                $"AZ-503 AC-6: location_hash backfill is non-deterministic. v1={v1}, v1_again={v1Again}.");
        }
        if (v1 == v2)
        {
            throw new Exception(
                $"AZ-503 AC-6: location_hash backfill produced the same UUID for different (x,y) tuples. v1={v1}, v2={v2}.");
        }

        // Cross-check that the live tiles.location_hash column matches the same function for at least one row, if any rows exist.
        // (Pre-existing rows are backfilled by migration 014; new rows would be written by app code that uses the C# Uuidv5.Create.)
        long sampleRowCount = await ScalarLongAsync(conn, "SELECT COUNT(*) FROM tiles;");
        if (sampleRowCount > 0)
        {
            const string sampleSql = @"
                SELECT
                    location_hash,
                    pg_temp.uuidv5_probe(
                        '5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid,
                        tile_zoom::text || '/' || tile_x::text || '/' || tile_y::text
                    ) AS expected_hash
                FROM tiles
                LIMIT 1;";

            Guid storedHash, expectedHash;
            await using (var cmd = new NpgsqlCommand(sampleSql, conn))
            await using (var reader = await cmd.ExecuteReaderAsync())
            {
                if (await reader.ReadAsync())
                {
                    storedHash = reader.GetGuid(0);
                    expectedHash = reader.GetGuid(1);
                    if (storedHash != expectedHash)
                    {
                        throw new Exception(
                            $"AZ-503 AC-6: tiles.location_hash drift for sample row. stored={storedHash}, expected={expectedHash}. " +
                            "Backfill formula and live UUIDv5 implementation must agree on the canonical name string.");
                    }
                    Console.WriteLine($"  ✓ Sample row location_hash matches the canonical UUIDv5 formula: {storedHash}");
                }
            }
        }
        else
        {
            Console.WriteLine("  (no rows in tiles table; deterministic-probe-only assertion)");
        }

        Console.WriteLine("  ✓ UUIDv5 backfill probe is deterministic across two identical inputs");
        Console.WriteLine("  ✓ UUIDv5 backfill probe distinguishes different (x,y) tuples");
    }

    private static async Task<List<(string Name, string Def)>> QueryIndexesAsync(NpgsqlConnection conn)
    {
        const string sql = @"
            SELECT indexname, indexdef
            FROM pg_indexes
            WHERE schemaname = 'public'
              AND tablename = 'tiles';";

        var rows = new List<(string Name, string Def)>();
        await using var cmd = new NpgsqlCommand(sql, conn);
        await using var reader = await cmd.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            rows.Add((reader.GetString(0), reader.GetString(1)));
        }
        return rows;
    }

    private static void AssertColumn(
        Dictionary<string, (string DataType, bool IsNullable)> columns,
        string columnName,
        string expectedType,
        bool expectedNullable)
    {
        if (!columns.TryGetValue(columnName, out var info))
        {
            throw new Exception(
                $"AZ-503 AC-6: column 'tiles.{columnName}' was not created by migration 014. " +
                $"Found columns: {string.Join(", ", columns.Keys)}");
        }
        if (!string.Equals(info.DataType, expectedType, StringComparison.OrdinalIgnoreCase))
        {
            throw new Exception(
                $"AZ-503 AC-6: column 'tiles.{columnName}' has data_type='{info.DataType}', expected '{expectedType}'.");
        }
        if (info.IsNullable != expectedNullable)
        {
            throw new Exception(
                $"AZ-503 AC-6: column 'tiles.{columnName}' is_nullable={info.IsNullable}, expected {expectedNullable}.");
        }
    }

    private static async Task BackfillUpdateAssignsGoogleMapsAndCapturedAt_AZ484_AC4(string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-484 AC-4: backfill UPDATE assigns source='google_maps' and captured_at = created_at, preserving row count");

        // Arrange — TEMP table simulating the pre-migration tiles shape with 3 sample rows.
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();

        await ExecAsync(conn, """
            CREATE TEMP TABLE tiles_backfill_test (
                id UUID PRIMARY KEY,
                created_at TIMESTAMP NOT NULL,
                source VARCHAR(32),
                captured_at TIMESTAMP
            );
            """);

        var idA = Guid.Parse("aaaaaaaa-1111-1111-1111-111111111111");
        var idB = Guid.Parse("bbbbbbbb-2222-2222-2222-222222222222");
        var idC = Guid.Parse("cccccccc-3333-3333-3333-333333333333");

        await ExecAsync(conn, """
            INSERT INTO tiles_backfill_test (id, created_at) VALUES
                (@idA, '2024-01-15 12:34:56'),
                (@idB, '2025-06-20 03:00:00'),
                (@idC, '2026-05-11 06:00:00');
            """,
            ("idA", idA), ("idB", idB), ("idC", idC));

        // Act — apply the same UPDATE pattern that migration 013 uses.
        await ExecAsync(conn, """
            UPDATE tiles_backfill_test SET source = 'google_maps' WHERE source IS NULL;
            UPDATE tiles_backfill_test SET captured_at = created_at WHERE captured_at IS NULL;
            """);

        // Assert
        var rows = new List<(Guid Id, string Source, DateTime CreatedAt, DateTime CapturedAt)>();
        await using (var cmd = new NpgsqlCommand(
            "SELECT id, source, created_at, captured_at FROM tiles_backfill_test ORDER BY id;", conn))
        await using (var reader = await cmd.ExecuteReaderAsync())
        {
            while (await reader.ReadAsync())
            {
                rows.Add((
                    reader.GetGuid(0),
                    reader.GetString(1),
                    reader.GetDateTime(2),
                    reader.GetDateTime(3)));
            }
        }

        if (rows.Count != 3)
        {
            throw new Exception($"AZ-484 AC-4 backfill changed row count. Expected 3, got {rows.Count}.");
        }
        foreach (var row in rows)
        {
            if (row.Source != "google_maps")
            {
                throw new Exception($"AZ-484 AC-4: row {row.Id} has source='{row.Source}', expected 'google_maps'.");
            }
            if (row.CapturedAt != row.CreatedAt)
            {
                throw new Exception($"AZ-484 AC-4: row {row.Id} captured_at={row.CapturedAt:o} does not equal created_at={row.CreatedAt:o}.");
            }
        }

        Console.WriteLine("  ✓ All 3 backfilled rows have source='google_maps' and captured_at = created_at");
    }

    private static async Task MultiSourceInsertCoexistsUnderNewIndex_AZ484_AC1(string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-484 AC-1: per-source unique index lets two producers store distinct rows for the same cell");

        // Arrange — TEMP table replicating the 5-column unique index shape.
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();

        await CreateTempTilesTable(conn, "tiles_multisource_test");
        await ExecAsync(conn, """
            CREATE UNIQUE INDEX idx_tiles_multisource_test_unique
                ON tiles_multisource_test (latitude, longitude, tile_zoom, tile_size_meters, source);
            """);

        var idGoogle = Guid.NewGuid();
        var idUav = Guid.NewGuid();

        // Act
        await ExecAsync(conn, """
            INSERT INTO tiles_multisource_test (id, latitude, longitude, tile_zoom, tile_size_meters, source, captured_at, file_path, updated_at)
            VALUES (@id, 47.5, 37.6, 18, 100, 'google_maps', '2026-05-10 00:00:00', 'tiles/google.jpg', '2026-05-10 00:00:00');
            """, ("id", idGoogle));

        await ExecAsync(conn, """
            INSERT INTO tiles_multisource_test (id, latitude, longitude, tile_zoom, tile_size_meters, source, captured_at, file_path, updated_at)
            VALUES (@id, 47.5, 37.6, 18, 100, 'uav', '2026-05-11 00:00:00', 'tiles/uav.jpg', '2026-05-11 00:00:00');
            """, ("id", idUav));

        // Assert
        var rowCount = await ScalarLongAsync(conn,
            "SELECT COUNT(*) FROM tiles_multisource_test WHERE latitude = 47.5 AND longitude = 37.6 AND tile_zoom = 18 AND tile_size_meters = 100;");
        if (rowCount != 2)
        {
            throw new Exception($"AZ-484 AC-1: expected 2 rows for the cell after multi-source insert, got {rowCount}.");
        }

        Console.WriteLine("  ✓ Both google_maps and uav rows coexist under the 5-column unique index");
    }

    private static async Task MostRecentAcrossSourcesSelection_AZ484_AC2(string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-484 AC-2: most-recent-across-sources selection rule returns the latest captured_at row");

        // Arrange — TEMP table with two rows for the same cell, distinct sources, T2 > T1.
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();

        await CreateTempTilesTable(conn, "tiles_selection_test");

        var idGoogleT1 = Guid.NewGuid();
        var idUavT2 = Guid.NewGuid();

        await ExecAsync(conn, """
            INSERT INTO tiles_selection_test (id, latitude, longitude, tile_zoom, tile_size_meters, source, captured_at, file_path, updated_at)
            VALUES
                (@idG, 48.0, 38.0, 18, 100, 'google_maps', '2026-04-01 00:00:00', 'g.jpg', '2026-04-01 00:00:00'),
                (@idU, 48.0, 38.0, 18, 100, 'uav',         '2026-05-01 00:00:00', 'u.jpg', '2026-05-01 00:00:00');
            """,
            ("idG", idGoogleT1), ("idU", idUavT2));

        // Act — same SELECT shape used by TileRepository.GetByTileCoordinatesAsync.
        Guid? winnerId;
        string? winnerSource;
        await using (var cmd = new NpgsqlCommand("""
            SELECT id, source FROM tiles_selection_test
            WHERE latitude = 48.0 AND longitude = 38.0 AND tile_zoom = 18 AND tile_size_meters = 100
            ORDER BY captured_at DESC, updated_at DESC, id DESC
            LIMIT 1;
            """, conn))
        await using (var reader = await cmd.ExecuteReaderAsync())
        {
            if (!await reader.ReadAsync())
            {
                throw new Exception("AZ-484 AC-2: selection query returned no rows; expected the uav row.");
            }
            winnerId = reader.GetGuid(0);
            winnerSource = reader.GetString(1);
        }

        // Assert
        if (winnerId != idUavT2 || winnerSource != "uav")
        {
            throw new Exception(
                $"AZ-484 AC-2: expected uav row (id={idUavT2}) to win, got id={winnerId} source='{winnerSource}'.");
        }

        Console.WriteLine("  ✓ Selection rule picked the uav row (captured_at T2 > T1) deterministically");
    }

    private static async Task SameSourceUpsertReplacesPreviousRow_AZ484_AC3(string connectionString)
    {
        Console.WriteLine();
        Console.WriteLine("AZ-484 AC-3: same-source UPSERT keeps a single row with refreshed captured_at and file_path");

        // Arrange — TEMP table with the 5-column unique index so ON CONFLICT works.
        await using var conn = new NpgsqlConnection(connectionString);
        await conn.OpenAsync();

        await CreateTempTilesTable(conn, "tiles_upsert_test");
        await ExecAsync(conn, """
            CREATE UNIQUE INDEX idx_tiles_upsert_test_unique
                ON tiles_upsert_test (latitude, longitude, tile_zoom, tile_size_meters, source);
            """);

        var idFirst = Guid.NewGuid();
        var idSecond = Guid.NewGuid();

        await ExecAsync(conn, """
            INSERT INTO tiles_upsert_test (id, latitude, longitude, tile_zoom, tile_size_meters, source, captured_at, file_path, updated_at)
            VALUES (@id, 49.0, 39.0, 18, 100, 'uav', '2026-04-01 00:00:00', 'first.jpg', '2026-04-01 00:00:00');
            """, ("id", idFirst));

        // Act — second insert for the same cell+source uses the same UPSERT pattern as TileRepository.InsertAsync.
        await ExecAsync(conn, """
            INSERT INTO tiles_upsert_test (id, latitude, longitude, tile_zoom, tile_size_meters, source, captured_at, file_path, updated_at)
            VALUES (@id, 49.0, 39.0, 18, 100, 'uav', '2026-05-01 00:00:00', 'second.jpg', '2026-05-01 00:00:00')
            ON CONFLICT (latitude, longitude, tile_zoom, tile_size_meters, source)
            DO UPDATE SET
                file_path = EXCLUDED.file_path,
                captured_at = EXCLUDED.captured_at,
                updated_at = EXCLUDED.updated_at;
            """, ("id", idSecond));

        // Assert
        long rowCount = 0;
        DateTime capturedAt = DateTime.MinValue;
        string? filePath = null;
        await using (var cmd = new NpgsqlCommand("""
            SELECT COUNT(*) OVER () AS total, captured_at, file_path
            FROM tiles_upsert_test
            WHERE latitude = 49.0 AND longitude = 39.0 AND tile_zoom = 18 AND tile_size_meters = 100 AND source = 'uav';
            """, conn))
        await using (var reader = await cmd.ExecuteReaderAsync())
        {
            if (!await reader.ReadAsync())
            {
                throw new Exception("AZ-484 AC-3: no rows after UPSERT — expected exactly one.");
            }
            rowCount = reader.GetInt64(0);
            capturedAt = reader.GetDateTime(1);
            filePath = reader.GetString(2);
        }

        if (rowCount != 1)
        {
            throw new Exception($"AZ-484 AC-3: expected exactly 1 uav row after UPSERT, got {rowCount}.");
        }
        if (capturedAt != new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Unspecified))
        {
            throw new Exception($"AZ-484 AC-3: captured_at not refreshed. Got {capturedAt:o}, expected 2026-05-01.");
        }
        if (filePath != "second.jpg")
        {
            throw new Exception($"AZ-484 AC-3: file_path not refreshed. Got '{filePath}', expected 'second.jpg'.");
        }

        Console.WriteLine("  ✓ Same-source UPSERT collapsed to 1 row with refreshed captured_at and file_path");
    }

    private static async Task CreateTempTilesTable(NpgsqlConnection conn, string tableName)
    {
        // Mirrors the post-migration-013 column shape relevant to AZ-484 (omits
        // vestigial maps_version/version columns that AC-1..AC-3 do not exercise).
        await ExecAsync(conn, $$"""
            CREATE TEMP TABLE {{tableName}} (
                id UUID PRIMARY KEY,
                latitude DOUBLE PRECISION NOT NULL,
                longitude DOUBLE PRECISION NOT NULL,
                tile_zoom INT NOT NULL,
                tile_size_meters DOUBLE PRECISION NOT NULL,
                source VARCHAR(32) NOT NULL,
                captured_at TIMESTAMP NOT NULL,
                file_path VARCHAR(500) NOT NULL,
                updated_at TIMESTAMP NOT NULL
            );
            """);
    }

    private static async Task<long> ScalarLongAsync(NpgsqlConnection conn, string sql)
    {
        await using var cmd = new NpgsqlCommand(sql, conn);
        var result = await cmd.ExecuteScalarAsync();
        return result switch
        {
            long l => l,
            int i => i,
            _ => throw new Exception($"Unexpected scalar type {result?.GetType()} for SQL: {sql}"),
        };
    }

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

    private static async Task<List<Guid>> QueryGuidsAsync(NpgsqlConnection conn, string sql)
    {
        await using var cmd = new NpgsqlCommand(sql, conn);
        await using var reader = await cmd.ExecuteReaderAsync();
        var result = new List<Guid>();
        while (await reader.ReadAsync())
        {
            result.Add(reader.GetGuid(0));
        }
        return result;
    }
}
