#:package MySqlConnector@2.3.7

using System.Text;
using MySqlConnector;

// ── Config ────────────────────────────────────────────────────────────────────

const string DSN         = "Server=127.0.0.1;Port=13306;Database=hotel_reservations;Uid=root;Pwd=hotel2025root;AllowLoadLocalInfile=true;";
const int    HOTEL_COUNT = 200;
const int    GUEST_COUNT = 100_000;
const int    BOOKING_COUNT = 500_000;
const int    BATCH        = 500;
const int    SEED         = 42;

var rng = new Random(SEED);

// ── DB helpers ────────────────────────────────────────────────────────────────

await using var conn = new MySqlConnection(DSN);
await conn.OpenAsync();
Console.WriteLine("Connected.");
await new MySqlCommand("SET foreign_key_checks=0, unique_checks=0", conn).ExecuteNonQueryAsync();

async Task Exec(string sql)
{
    await using var cmd = new MySqlCommand(sql, conn);
    cmd.CommandTimeout = 300;
    await cmd.ExecuteNonQueryAsync();
}

async Task<long> ExecScalar(string sql)
{
    await using var cmd = new MySqlCommand(sql, conn);
    return Convert.ToInt64(await cmd.ExecuteScalarAsync());
}

// Bulk insert: builds a single INSERT ... VALUES (...),(...),...
async Task BulkInsert(string table, string columns, List<string> valueTuples)
{
    for (int i = 0; i < valueTuples.Count; i += BATCH)
    {
        var batch = valueTuples.Skip(i).Take(BATCH);
        await Exec($"INSERT INTO {table} ({columns}) VALUES {string.Join(',', batch)}");
    }
}

string S(string? s) => s == null ? "NULL" : $"'{s.Replace("'", "''")}'";
string N(object? n) => n == null ? "NULL" : n.ToString()!;
string D(DateTime d) => $"'{d:yyyy-MM-dd}'";
string DT(DateTime d) => $"'{d:yyyy-MM-dd HH:mm:ss}'";

// ── Reference data ────────────────────────────────────────────────────────────

// ── 1. hotel_chain ────────────────────────────────────────────────────────────
Console.WriteLine("[1/8] hotel_chain");

var chains = new (string Code, string Name)[]
{
    ("HLT",  "Hilton Worldwide"),
    ("MRT",  "Marriott International"),
    ("HYT",  "Hyatt Hotels Corporation"),
    ("IHG",  "InterContinental Hotels Group"),
    ("WYN",  "Wyndham Hotels & Resorts"),
    ("ACC",  "Accor"),
    ("BW",   "Best Western Hotels"),
    ("RAD",  "Radisson Hotels"),
    ("MEL",  "Meliá Hotels International"),
    ("NH",   "NH Hotel Group"),
};

await BulkInsert("hotel_chain", "code, name",
    chains.Select(c => $"({S(c.Code)},{S(c.Name)})").ToList());

var chainIds = new Dictionary<string, int>();
{
    await using var cmd = new MySqlCommand("SELECT hotel_chain_id, code FROM hotel_chain", conn);
    await using var r = await cmd.ExecuteReaderAsync();
    while (await r.ReadAsync()) chainIds[r.GetString(1)] = r.GetInt32(0);
}

// ── 2. country ────────────────────────────────────────────────────────────────
Console.WriteLine("[2/8] country");

var countries = new (string Code, string Name, string Currency)[]
{
    ("GB","United Kingdom","GBP"), ("FR","France","EUR"),       ("DE","Germany","EUR"),
    ("ES","Spain","EUR"),          ("IT","Italy","EUR"),         ("PT","Portugal","EUR"),
    ("NL","Netherlands","EUR"),    ("BE","Belgium","EUR"),       ("AT","Austria","EUR"),
    ("CH","Switzerland","CHF"),    ("SE","Sweden","SEK"),        ("NO","Norway","NOK"),
    ("DK","Denmark","DKK"),        ("PL","Poland","PLN"),        ("CZ","Czech Republic","CZK"),
    ("HU","Hungary","HUF"),        ("HR","Croatia","EUR"),       ("GR","Greece","EUR"),
    ("TR","Turkey","TRY"),         ("US","United States","USD"), ("CA","Canada","CAD"),
    ("MX","Mexico","MXN"),         ("BR","Brazil","BRL"),        ("AR","Argentina","ARS"),
    ("AU","Australia","AUD"),      ("NZ","New Zealand","NZD"),   ("JP","Japan","JPY"),
    ("CN","China","CNY"),          ("KR","South Korea","KRW"),   ("SG","Singapore","SGD"),
    ("TH","Thailand","THB"),       ("AE","United Arab Emirates","AED"),
    ("SA","Saudi Arabia","SAR"),   ("EG","Egypt","EGP"),         ("ZA","South Africa","ZAR"),
    ("IN","India","INR"),          ("MA","Morocco","MAD"),       ("TN","Tunisia","TND"),
    ("ID","Indonesia","IDR"),      ("MY","Malaysia","MYR"),
};

await BulkInsert("country", "code, name, currency",
    countries.Select(c => $"({S(c.Code)},{S(c.Name)},{S(c.Currency)})").ToList());

var countryIds = new Dictionary<string, int>();
{
    await using var cmd = new MySqlCommand("SELECT country_id, code FROM country", conn);
    await using var r = await cmd.ExecuteReaderAsync();
    while (await r.ReadAsync()) countryIds[r.GetString(1)] = r.GetInt32(0);
}

// ── 3. star_rating ────────────────────────────────────────────────────────────
Console.WriteLine("[3/8] star_rating");

await BulkInsert("star_rating", "code, description",
    Enumerable.Range(1, 5).Select(i => $"({i},{S(i + " Star")})").ToList());

var starIds = new Dictionary<int, int>();
{
    await using var cmd = new MySqlCommand("SELECT star_rating_id, code FROM star_rating", conn);
    await using var r = await cmd.ExecuteReaderAsync();
    while (await r.ReadAsync()) starIds[r.GetInt32(1)] = r.GetInt32(0);
}

// ── 4. hotel_characteristic ───────────────────────────────────────────────────
Console.WriteLine("[4/8] hotel_characteristic");

var characteristics = new (string Code, string Desc)[]
{
    ("WIFI",        "Free WiFi"),          ("POOL",     "Swimming Pool"),
    ("GYM",         "Fitness Center"),     ("SPA",      "Spa & Wellness"),
    ("RESTAURANT",  "On-site Restaurant"), ("BAR",      "Hotel Bar"),
    ("PARKING",     "Free Parking"),       ("VALET",    "Valet Parking"),
    ("CONFERENCE",  "Conference Rooms"),   ("SHUTTLE",  "Airport Shuttle"),
    ("ROOM_SVC",    "Room Service"),       ("PETS",     "Pet Friendly"),
};

await BulkInsert("hotel_characteristic", "code, description",
    characteristics.Select(c => $"({S(c.Code)},{S(c.Desc)})").ToList());

var charIds = new Dictionary<string, int>();
{
    await using var cmd = new MySqlCommand("SELECT characteristic_id, code FROM hotel_characteristic", conn);
    await using var r = await cmd.ExecuteReaderAsync();
    while (await r.ReadAsync()) charIds[r.GetString(1)] = r.GetInt32(0);
}

// ── 5. room_type + rate_period + period_room_rate ─────────────────────────────
Console.WriteLine("[5/8] room_type / rate_period / period_room_rate");

var roomTypes = new (string Code, string Desc, decimal BaseRate, bool Smoking)[]
{
    ("SINGLE",  "Single Room",        80m,  false),
    ("DOUBLE",  "Double Room",       120m,  false),
    ("TWIN",    "Twin Room",         115m,  false),
    ("DELUXE",  "Deluxe Double",     180m,  false),
    ("SUITE",   "Junior Suite",      280m,  false),
    ("EXEC",    "Executive Suite",   450m,  false),
    ("FAMILY",  "Family Room",       200m,  false),
};

await BulkInsert("room_type", "code, description, standard_rate, smoking_yn",
    roomTypes.Select(rt => $"({S(rt.Code)},{S(rt.Desc)},{rt.BaseRate},0)").ToList());

var roomTypeIds = new Dictionary<string, int>();
{
    await using var cmd = new MySqlCommand("SELECT room_type_id, code FROM room_type", conn);
    await using var r = await cmd.ExecuteReaderAsync();
    while (await r.ReadAsync()) roomTypeIds[r.GetString(1)] = r.GetInt32(0);
}

// Seasons: month → multiplier
var ratePeriods = new (string Code, string Desc, int MonthFrom, int MonthTo, decimal Multiplier)[]
{
    ("PEAK",    "Peak Season (Jun-Aug)",       6,  8,  1.5m),
    ("HIGH",    "High Season (Mar-May)",        3,  5,  1.2m),
    ("AUTUMN",  "Autumn Season (Sep-Nov)",      9,  11, 1.1m),
    ("WINTER",  "Winter Season (Dec-Feb)",      12, 2,  0.9m),
};

await BulkInsert("rate_period", "code, description, month_from, month_to",
    ratePeriods.Select(rp => $"({S(rp.Code)},{S(rp.Desc)},{rp.MonthFrom},{rp.MonthTo})").ToList());

var ratePeriodIds = new Dictionary<string, int>();
{
    await using var cmd = new MySqlCommand("SELECT rate_period_id, code FROM rate_period", conn);
    await using var r = await cmd.ExecuteReaderAsync();
    while (await r.ReadAsync()) ratePeriodIds[r.GetString(1)] = r.GetInt32(0);
}

// period_room_rate: rate = base_rate * season_multiplier
var prrRows = new List<string>();
foreach (var rt in roomTypes)
    foreach (var rp in ratePeriods)
    {
        var rate = Math.Round(rt.BaseRate * rp.Multiplier, 2);
        prrRows.Add($"({roomTypeIds[rt.Code]},{ratePeriodIds[rp.Code]},{rate})");
    }
await BulkInsert("period_room_rate", "room_type_id, rate_period_id, rate", prrRows);

// Build month → rate lookup in memory
var monthToRatePeriodId = new Dictionary<int, int>();
foreach (var rp in ratePeriods)
{
    if (rp.MonthFrom <= rp.MonthTo)
        for (int m = rp.MonthFrom; m <= rp.MonthTo; m++)
            monthToRatePeriodId[m] = ratePeriodIds[rp.Code];
    else // wraps year (Dec-Feb)
    {
        for (int m = rp.MonthFrom; m <= 12; m++) monthToRatePeriodId[m] = ratePeriodIds[rp.Code];
        for (int m = 1; m <= rp.MonthTo; m++)    monthToRatePeriodId[m] = ratePeriodIds[rp.Code];
    }
}

// Build (room_type_id, rate_period_id) → rate lookup
var rateMap = new Dictionary<(int, int), decimal>();
foreach (var rt in roomTypes)
    foreach (var rp in ratePeriods)
        rateMap[(roomTypeIds[rt.Code], ratePeriodIds[rp.Code])] =
            Math.Round(rt.BaseRate * rp.Multiplier, 2);

// ── 6. hotel + hotel_room + hotel_hotel_characteristic ────────────────────────
Console.WriteLine("[6/8] hotel / hotel_room / hotel_hotel_characteristic");

var hotelCities = new (string City, string Country)[]
{
    ("London","GB"),      ("Manchester","GB"),   ("Edinburgh","GB"),
    ("Paris","FR"),       ("Lyon","FR"),         ("Nice","FR"),
    ("Berlin","DE"),      ("Munich","DE"),        ("Hamburg","DE"),
    ("Madrid","ES"),      ("Barcelona","ES"),     ("Seville","ES"),
    ("Rome","IT"),        ("Milan","IT"),         ("Florence","IT"),
    ("Lisbon","PT"),      ("Porto","PT"),         ("Amsterdam","NL"),
    ("Vienna","AT"),      ("Zurich","CH"),        ("Geneva","CH"),
    ("Stockholm","SE"),   ("Oslo","NO"),          ("Copenhagen","DK"),
    ("Warsaw","PL"),      ("Prague","CZ"),        ("Budapest","HU"),
    ("Athens","GR"),      ("Istanbul","TR"),      ("New York","US"),
    ("Los Angeles","US"), ("Miami","US"),         ("Chicago","US"),
    ("Toronto","CA"),     ("Vancouver","CA"),     ("Sydney","AU"),
    ("Melbourne","AU"),   ("Tokyo","JP"),         ("Osaka","JP"),
    ("Singapore","SG"),   ("Bangkok","TH"),       ("Dubai","AE"),
    ("Mumbai","IN"),      ("Cape Town","ZA"),     ("Marrakech","MA"),
    ("Cairo","EG"),       ("Cancun","MX"),        ("Rio de Janeiro","BR"),
    ("Seoul","KR"),       ("Kuala Lumpur","MY"),
};

// Star rating distribution: 3★ most common, 5★ rarest
int[] starWeights = [0, 5, 10, 40, 30, 15]; // index = star, value = weight
int PickStar()
{
    int roll = rng.Next(100);
    int cum = 0;
    for (int s = 1; s <= 5; s++) { cum += starWeights[s]; if (roll < cum) return s; }
    return 3;
}

var hotelRows    = new List<string>();
var roomRows     = new List<string>();
var hotelCharRows = new List<string>();
var charCodes    = characteristics.Select(c => c.Code).ToArray();

// Track room_type per hotel_room for later rate lookups (in-memory)
// hotel_room gets an auto-increment ID; we'll load them after insert
// So store: hotel index → list of (room_number, room_type_code)
var hotelRoomTypes = new List<(int hotelIndex, string roomNumber, string roomTypeCode)>();

string[] streetNames = ["Main St","Park Ave","King Rd","Grand Blvd","Lake Dr",
                        "Ocean Blvd","Hill Rd","Market St","Central Ave","Palace Rd"];

for (int h = 0; h < HOTEL_COUNT; h++)
{
    var (city, ctryCode) = hotelCities[h % hotelCities.Length];
    int chainIndex = rng.Next(chains.Length);
    // 20% of hotels are independent (no chain)
    int? chainId = rng.Next(100) < 20 ? null : chainIds[chains[chainIndex].Code];
    int star     = PickStar();
    int starId   = starIds[star];
    int ctryId   = countryIds[ctryCode];

    string code  = $"HTL{h+1:D4}";
    string name  = chainId == null
        ? $"The {city} Hotel"
        : $"{chains[chainIndex].Name.Split(' ')[0]} {city}";
    string addr  = $"{rng.Next(1, 200)} {streetNames[rng.Next(streetNames.Length)]}";
    string url   = $"https://www.{code.ToLower()}.example.com";

    hotelRows.Add($"({N(chainId)},{ctryId},{starId},{S(code)},{S(name)},{S(addr)},{S("00000")},{S(city)},{S(url)})");

    // Characteristics: 5★ gets all, lower stars get fewer
    int charCount = star switch { 5 => 11, 4 => 8, 3 => 6, 2 => 4, _ => 3 };
    var shuffled = charCodes.OrderBy(_ => rng.Next()).Take(charCount).ToArray();
    // Store char codes for later — we need hotel_id from DB first
    // Mark with h as placeholder; we'll match after insert
    foreach (var cc in shuffled)
        hotelCharRows.Add($"__HOTEL_{h}__,{charIds[cc]}");

    // Rooms: more rooms for higher star hotels
    int roomCount = star switch { 5 => rng.Next(40, 60), 4 => rng.Next(25, 40),
                                   3 => rng.Next(15, 25), 2 => rng.Next(8, 15), _ => rng.Next(5, 10) };

    // Room type distribution per star rating
    string[] typePool = star switch
    {
        5 => ["DOUBLE","DOUBLE","DELUXE","DELUXE","SUITE","SUITE","EXEC","FAMILY"],
        4 => ["SINGLE","DOUBLE","DOUBLE","DELUXE","SUITE","FAMILY"],
        3 => ["SINGLE","SINGLE","DOUBLE","DOUBLE","TWIN","FAMILY"],
        2 => ["SINGLE","SINGLE","DOUBLE","TWIN"],
        _ => ["SINGLE","SINGLE","DOUBLE"],
    };

    for (int r = 0; r < roomCount; r++)
    {
        int floor   = r / 10 + 1;
        string rnum = $"{floor}{(r % 10 + 1):D2}";
        string rtype = typePool[rng.Next(typePool.Length)];
        // Store for later (after we get real hotel IDs from DB)
        hotelRoomTypes.Add((h, rnum, rtype));
    }
}

await BulkInsert("hotel",
    "hotel_chain_id, country_id, star_rating_id, code, name, address, postcode, city, url",
    hotelRows);

// Load hotel IDs in order
var hotelIds = new List<int>();
{
    await using var cmd = new MySqlCommand("SELECT hotel_id FROM hotel ORDER BY hotel_id", conn);
    await using var r = await cmd.ExecuteReaderAsync();
    while (await r.ReadAsync()) hotelIds.Add(r.GetInt32(0));
}

// Now build hotel_room rows with real hotel IDs
foreach (var (hIdx, rnum, rtype) in hotelRoomTypes)
    roomRows.Add($"({hotelIds[hIdx]},{roomTypeIds[rtype]},{S(rnum)},{rnum[0] - '0'})");

await BulkInsert("hotel_room", "hotel_id, room_type_id, room_number, floor", roomRows);

// hotel_hotel_characteristic — replace placeholder with real hotel_id
var hhcRows = hotelCharRows
    .Select(row => {
        var parts   = row.Split(',');
        var hIdx    = int.Parse(parts[0].Replace("__HOTEL_", "").Replace("__", ""));
        var charId  = parts[1];
        return $"({hotelIds[hIdx]},{charId})";
    })
    .Distinct()
    .ToList();
await BulkInsert("hotel_hotel_characteristic", "hotel_id, characteristic_id", hhcRows);

// Load rooms into memory: hotel_id → list of (room_id, room_type_id)
var hotelRooms = new Dictionary<int, List<(int RoomId, int RoomTypeId)>>();
{
    await using var cmd = new MySqlCommand("SELECT room_id, hotel_id, room_type_id FROM hotel_room", conn);
    await using var r = await cmd.ExecuteReaderAsync();
    while (await r.ReadAsync())
    {
        int rid = r.GetInt32(0), hid = r.GetInt32(1), rtid = r.GetInt32(2);
        if (!hotelRooms.ContainsKey(hid)) hotelRooms[hid] = [];
        hotelRooms[hid].Add((rid, rtid));
    }
}

// ── 7. guest ──────────────────────────────────────────────────────────────────
Console.WriteLine("[7/8] guest");

string[] firstNames =
[
    "James","Mary","John","Patricia","Robert","Jennifer","Michael","Linda","William","Barbara",
    "David","Elizabeth","Richard","Susan","Joseph","Jessica","Thomas","Sarah","Charles","Karen",
    "Luca","Sofia","Marco","Giulia","Hans","Anna","Klaus","Maria","Pierre","Marie","Jean","Claire",
    "Miguel","Ana","Carlos","Carmen","Andrei","Ioana","Mihai","Elena","Tomasz","Agnieszka",
    "Dimitri","Eleni","Mehmet","Fatima","Yuki","Kenji","Haruto","Yuna","Wei","Fang","Li","Mei",
    "Ahmed","Layla","Omar","Nour","Raj","Priya","Arjun","Ananya","Lucas","Emma","Noah","Olivia",
    "Ethan","Ava","Mason","Isabella","Liam","Sophia","Oliver","Charlotte","Elijah","Amelia",
];

string[] lastNames =
[
    "Smith","Johnson","Williams","Brown","Jones","Garcia","Miller","Davis","Wilson","Moore",
    "Taylor","Anderson","Thomas","Jackson","White","Harris","Martin","Thompson","Young","Lee",
    "Rossi","Ferrari","Esposito","Romano","Müller","Schmidt","Fischer","Weber","Meyer","Wagner",
    "Dupont","Martin","Bernard","Petit","Dubois","Moreau","Laurent","Simon","Michel","Garcia",
    "Kowalski","Nowak","Wiśniewski","Wójcik","Kowalczyk","Kamiński","Lewandowski","Zieliński",
    "Papadopoulos","Georgiou","Yilmaz","Kaya","Tanaka","Sato","Suzuki","Watanabe","Ito","Yamamoto",
    "Wang","Li","Zhang","Liu","Chen","Yang","Huang","Zhao","Kim","Park","Lee","Choi","Patel",
    "Singh","Kumar","Sharma","Gupta","Ali","Hassan","Ahmed","Mohamed","Silva","Santos","Oliveira",
];

string[] guestCities =
[
    "London","Paris","Berlin","Madrid","Rome","Amsterdam","Vienna","Zurich","Brussels","Stockholm",
    "New York","Los Angeles","Chicago","Houston","Phoenix","Toronto","Vancouver","Sydney","Melbourne",
    "Tokyo","Seoul","Beijing","Shanghai","Singapore","Bangkok","Dubai","Mumbai","Cape Town",
    "Warsaw","Prague","Budapest","Athens","Istanbul","Lisbon","Oslo","Copenhagen","Helsinki",
];

var countryList = countries.Select(c => c.Code).ToArray();

var guestRows = new List<string>();
for (int g = 0; g < GUEST_COUNT; g++)
{
    string fn    = firstNames[rng.Next(firstNames.Length)];
    string ln    = lastNames[rng.Next(lastNames.Length)];
    string name  = $"{fn} {ln}";
    string email = $"{fn.ToLower()}.{ln.ToLower()}{rng.Next(100, 999)}@example.com";
    string city  = guestCities[rng.Next(guestCities.Length)];
    int    ctryId = countryIds[countryList[rng.Next(countryList.Length)]];
    guestRows.Add($"({ctryId},{S(name)},{S(email)},{S(city)})");
}
await BulkInsert("guest", "country_id, name, email, city", guestRows);

var guestIdMin = (int)await ExecScalar("SELECT MIN(guest_id) FROM guest");
var guestIdMax = (int)await ExecScalar("SELECT MAX(guest_id) FROM guest");

// ── 8. booking + room_booking ─────────────────────────────────────────────────
Console.WriteLine("[8/8] booking + room_booking");

var dateStart = new DateTime(2022, 1, 1);
var dateEnd   = new DateTime(2025, 12, 31);
int dateRange = (dateEnd - dateStart).Days;

// Seasonal weight: month → weight (higher = more bookings)
int[] monthWeight = [0, 6, 5, 7, 9, 10, 14, 16, 15, 11, 9, 7, 11]; // Jan-Dec

DateTime RandomCheckin()
{
    // Rejection sampling to simulate seasonal distribution
    while (true)
    {
        var d = dateStart.AddDays(rng.Next(dateRange));
        if (rng.Next(16) < monthWeight[d.Month]) return d;
    }
}

int RandomNights() => rng.Next(100) switch
{
    < 30 => 1,
    < 55 => 2,
    < 75 => 3,
    < 85 => 4,
    < 92 => 5,
    < 96 => rng.Next(6, 8),
    _    => rng.Next(8, 15),
};

string RandomStatus() => rng.Next(100) switch
{
    < 80 => "completed",
    < 90 => "confirmed",
    < 97 => "cancelled",
    _    => "no_show",
};

// 90% single room, 8% two rooms, 2% three rooms
int RandomRoomCount() => rng.Next(100) switch { < 90 => 1, < 98 => 2, _ => 3 };

var bookingRows    = new List<string>();
var roomBookingRows = new List<string>();

// We need booking_id for room_booking FK.
// Strategy: flush bookings in batches, then read back the auto-increment IDs,
// then insert room_bookings for that batch.

int bookingsDone = 0;
while (bookingsDone < BOOKING_COUNT)
{
    int batchSize = Math.Min(BATCH, BOOKING_COUNT - bookingsDone);
    bookingRows.Clear();
    roomBookingRows.Clear();

    for (int b = 0; b < batchSize; b++)
    {
        int      guestId  = guestIdMin + rng.Next(guestIdMax - guestIdMin + 1);
        int      hotelId  = hotelIds[rng.Next(hotelIds.Count)];
        DateTime checkin  = RandomCheckin();
        int      nights   = RandomNights();
        DateTime checkout = checkin.AddDays(nights);
        string   status   = RandomStatus();
        DateTime created  = checkin.AddDays(-rng.Next(1, 180));

        bookingRows.Add($"({guestId},{hotelId},{D(checkin)},{D(checkout)},{S(status)},{DT(created)})");
    }

    await Exec($"INSERT INTO booking (guest_id, hotel_id, date_from, date_to, status, created_at) VALUES {string.Join(',', bookingRows)}");
    long firstId = await ExecScalar("SELECT LAST_INSERT_ID()");

    // Re-derive checkin/nights from the same rng sequence is impossible after the fact,
    // so re-parse from inserted rows to build room_bookings
    // Simpler: re-read the batch back
    await using (var cmd = new MySqlCommand(
        $"SELECT booking_id, hotel_id, date_from, date_to, status FROM booking WHERE booking_id >= {firstId} ORDER BY booking_id", conn))
    await using (var reader = await cmd.ExecuteReaderAsync())
    {
        while (await reader.ReadAsync())
        {
            long    bookingId = reader.GetInt64(0);
            int     hid       = reader.GetInt32(1);
            DateTime dfrom    = reader.GetDateTime(2);
            DateTime dto      = reader.GetDateTime(3);
            string  status    = reader.GetString(4);
            int     nights    = (dto - dfrom).Days;

            if (!hotelRooms.ContainsKey(hid) || hotelRooms[hid].Count == 0) continue;

            // Skip room_bookings for cancelled/no_show sometimes
            if (status == "cancelled" && rng.Next(100) < 60) continue;
            if (status == "no_show"   && rng.Next(100) < 30) continue;

            int roomCount = RandomRoomCount();
            var available = hotelRooms[hid].OrderBy(_ => rng.Next()).Take(roomCount).ToList();

            foreach (var (roomId, roomTypeId) in available)
            {
                int ratePeriodId  = monthToRatePeriodId[dfrom.Month];
                decimal nightly   = rateMap[(roomTypeId, ratePeriodId)];
                decimal total     = Math.Round(nightly * nights, 2);
                roomBookingRows.Add($"({bookingId},{roomId},{D(dfrom)},{D(dto)},{nightly},{total})");
            }
        }
    }

    if (roomBookingRows.Count > 0)
        await Exec($"INSERT INTO room_booking (booking_id, room_id, date_from, date_to, nightly_rate, total_amount) VALUES {string.Join(',', roomBookingRows)}");

    bookingsDone += batchSize;
    if (bookingsDone % 10_000 == 0)
        Console.WriteLine($"  bookings: {bookingsDone:N0} / {BOOKING_COUNT:N0}");
}

Console.WriteLine();
Console.WriteLine("── Row counts ───────────────────────────────");
foreach (var t in new[]{"hotel_chain","country","star_rating","hotel_characteristic",
                         "room_type","rate_period","period_room_rate","hotel",
                         "hotel_room","hotel_hotel_characteristic","guest","booking","room_booking"})
{
    long cnt = await ExecScalar($"SELECT COUNT(*) FROM {t}");
    Console.WriteLine($"  {t,-35} {cnt,10:N0}");
}
Console.WriteLine("Done.");
