#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Identity;
using Baya.Application.Contracts.Search;
using Baya.Application.Features.Bookings;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Catalog;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;

namespace Baya.Infrastructure.Persistence.Services.Seeding;

/// <summary>
/// Development-only seeder that layers a full <b>lifecycle</b> demo world on top of the
/// <see cref="DemoWorldSeeder"/> personas: booking requests in every status, bookings in every reachable
/// state (upcoming, mid-engagement with EVV, completed inside/past the dispute window, BNPL-settled,
/// cancelled-with-refund, clawed-back), the balanced payment ledger behind each, refunds on every channel,
/// a paid and a draft payout batch, moderated reviews, tickets (including an admin-internal note),
/// notifications, patient care records, a partner center, and a mid-pipeline verification case.
/// <para>
/// Every state is reached through the entities' <b>guarded transition methods</b> and the real money
/// helpers (<see cref="BookingFactory"/>, <see cref="LedgerPosting"/>), never by raw status assignment, so
/// the seeded world satisfies the same invariants real usage produces. Payout batches and coordination
/// tickets are produced by dispatching the <b>real Application commands</b>. Timestamps that carry business
/// meaning are backdated explicitly (they are method arguments/settable by design); <c>CreatedAt</c> audit
/// stamps stay at seed time, which no flow depends on.
/// </para>
/// <para>
/// Idempotent per scenario: each aggregate is guarded on a natural key (the request's
/// customer/nurse/variant/date tuple, a ticket's fixed reference code, the partner permit number…), so
/// re-running on an already-seeded DB is a no-op. Callers must gate this on <c>IsDevelopment()</c> and run
/// it <b>after</b> <see cref="DemoWorldSeeder"/> and the payment-gateway seed.
/// </para>
/// <para>
/// Because several of those natural keys contain a <b>date</b>, the run is anchored to a stable epoch (the
/// first run's instant, read back from the demo payment transactions) rather than to wall-clock now — a
/// date-relative key silently stops matching the day after it was written, which turns "idempotent" into
/// "duplicates the whole world once per day". A wipe + reseed is what moves the demo world forward in time.
/// </para>
/// <para>
/// It is also <b>resumable</b>, which is a stronger property than idempotent and is what the booking
/// scenarios actually need: each scenario spans several aggregates that can only be written in sequence
/// (a generated id feeds the next FK), so it runs inside one transaction and every step re-checks its own
/// row rather than trusting a single scenario-level guard. An interrupted run therefore leaves nothing
/// half-written, and a database already damaged by one is repaired forward on the next boot instead of
/// throwing.
/// </para>
/// </summary>
internal sealed partial class DemoLifecycleSeeder(
    ApplicationDbContext db,
    IAppUserManager userManager,
    ISender sender,
    IPlatformConfig platformConfig,
    IVariantSnapshotSerializer variantSerializer,
    ISearchIndexMaintainer searchIndex,
    IFieldEncryptor encryptor,
    IDateTimeProvider clock,
    ILogger<DemoLifecycleSeeder> logger)
{
    public async Task SeedAsync(CancellationToken cancellationToken)
    {
        var world = await LoadWorldAsync(cancellationToken);
        if (world is null)
        {
            logger.LogWarning("Demo lifecycle seed skipped — the base demo world (personas/variants) is not present.");
            return;
        }

        var gateway = await db.Set<PaymentGateway>()
            .FirstOrDefaultAsync(g => g.Type == PaymentGatewayType.Standard, cancellationToken);
        if (gateway is null)
        {
            logger.LogWarning("Demo lifecycle seed skipped — no standard payment gateway row (run SeedPaymentGatewaysAsync first).");
            return;
        }

        // The whole seed is anchored to a STABLE epoch, not to wall-clock "now".
        //
        // Every scenario's idempotency key includes a date derived from this anchor (`Today(now, offset)`),
        // so anchoring on the current time meant the key moved every calendar day: a run on the next day
        // matched none of yesterday's rows, re-created all thirteen scenarios, and then collided on
        // `GatewayReferenceCode` — which IS date-independent. That is how a supposed no-op re-run ended up
        // throwing a duplicate-key error and leaving a scenario half-written for the next boot to trip over.
        //
        // Re-using the first run's instant makes every key reproduce exactly, so a re-run is a real no-op on
        // any day. The anchor is read back from the demo transactions' audit stamp rather than stored
        // separately — they are the one row set this seeder owns whose natural key (the reference prefix) is
        // itself date-independent. A wipe + reseed is what moves the demo world forward in time.
        // Ordered by Id, not CreatedAt: identity order IS insertion order (so it picks the same first row),
        // it can't tie on a shared millisecond, and — the reason it matters here — the SQLite provider the
        // API tests run on cannot translate a DateTimeOffset ORDER BY at all.
        var seededAt = await db.Set<PaymentTransaction>()
            .Where(t => t.GatewayReferenceCode != null
                        && t.GatewayReferenceCode.StartsWith(DemoLifecycleDefinitions.GatewayRefPrefix))
            .OrderBy(t => t.Id)
            .Select(t => (DateTimeOffset?)t.CreatedAt)
            .FirstOrDefaultAsync(cancellationToken);

        var now = seededAt?.UtcDateTime ?? clock.UtcNow.UtcDateTime;

        var packageVariantId = await EnsurePackageVariantAsync(world, cancellationToken);
        var newRequests = await EnsureRequestScenariosAsync(world, now, cancellationToken);
        var bookings = await EnsureBookingScenariosAsync(world, packageVariantId, gateway.Id, now, cancellationToken);

        var centerId = await EnsurePartnerCenterAsync(world, cancellationToken);
        await EnsureBnplAsync(bookings, now, cancellationToken);

        // Order is load-bearing (the world must be *reachable*, not merely constraint-satisfying):
        // the paid batch runs BEFORE the refunds exist (so the clawback booking was genuinely paid out first),
        // and the draft batch runs AFTER them (so the BNPL-refunded booking is genuinely excluded).
        await EnsurePaidPayoutBatchAsync(bookings, now, cancellationToken);
        await EnsureRefundsAsync(world, bookings, now, cancellationToken);
        await EnsureDraftPayoutBatchAsync(bookings, now, cancellationToken);

        await EnsureReviewsAsync(world, bookings, now, cancellationToken);
        await EnsureInvoicesAsync(bookings, cancellationToken);
        await EnsureTicketsAsync(world, bookings, now, cancellationToken);
        await EnsureCareRecordsAsync(world, bookings, now, cancellationToken);
        await EnsureVerificationPipelineAsync(world, now, cancellationToken);
        await EnsureNotificationsAsync(world, bookings, now, cancellationToken);

        // Re-derive the search projection last so the seeded rating/completed aggregates reach the index.
        var rebuild = await searchIndex.RebuildAsync(cancellationToken);

        if (newRequests == 0 && bookings.CreatedCount == 0)
            logger.LogInformation(
                "Demo lifecycle already seeded — no-op. Search index re-derived: {Nurses} nurses, {Rows} rows.",
                rebuild.NursesProcessed, rebuild.RowsWritten);
        else
            logger.LogInformation(
                "Demo lifecycle seeded: {Requests} request scenario(s), {Bookings} booking scenario(s), partner center #{Center}. " +
                "Search index: {Nurses} nurses, {Rows} rows.",
                newRequests, bookings.CreatedCount, centerId, rebuild.NursesProcessed, rebuild.RowsWritten);
    }

    // ---------------------------------------------------------------------------------------------------
    // World loading — resolve the base-seeder personas into ids. Null when the base world isn't there.
    // ---------------------------------------------------------------------------------------------------

    internal sealed record CustomerWorld(
        int UserId, long ProfileId, IReadOnlyList<long> PatientIds, CustomerAddress PrimaryAddress);

    internal sealed record NurseWorld(
        int UserId, NurseProfile Profile, IReadOnlyList<NurseServiceVariant> Variants, string Gender);

    internal sealed record PersonaWorld(
        IReadOnlyList<CustomerWorld> Customers, IReadOnlyList<NurseWorld> Nurses, int AdminUserId);

    private async Task<PersonaWorld?> LoadWorldAsync(CancellationToken cancellationToken)
    {
        var customers = new List<CustomerWorld>();
        foreach (var persona in DemoWorldDefinitions.Customers)
        {
            var user = await userManager.GetUserByPhoneNumber(persona.Phone);
            if (user is null)
                return null;

            var profile = await db.Set<CustomerProfile>()
                .FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
            if (profile is null)
                return null;

            var patients = await db.Set<Patient>()
                .Where(p => p.CustomerId == profile.Id)
                .OrderBy(p => p.Id)
                .Select(p => p.Id)
                .ToListAsync(cancellationToken);

            var address = await db.Set<CustomerAddress>()
                .FirstOrDefaultAsync(a => a.CustomerId == profile.Id && a.IsPrimary, cancellationToken);
            if (patients.Count == 0 || address is null)
                return null;

            customers.Add(new CustomerWorld(user.Id, profile.Id, patients, address));
        }

        var nurses = new List<NurseWorld>();
        foreach (var persona in DemoWorldDefinitions.Nurses)
        {
            var user = await userManager.GetUserByPhoneNumber(persona.Phone);
            if (user is null)
                return null;

            // Tracked on purpose: the reviews step mutates the aggregates through the guarded setters.
            var profile = await db.Set<NurseProfile>()
                .FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
            if (profile is null)
                return null;

            var variants = await db.Set<NurseServiceVariant>()
                .Include(v => v.Options)
                .Where(v => v.NurseId == profile.Id)
                .OrderBy(v => v.Id)
                .ToListAsync(cancellationToken);

            nurses.Add(new NurseWorld(user.Id, profile, variants, persona.Gender));
        }

        var admin = await userManager.GetUserByPhoneNumber(DemoWorldDefinitions.Admins[0].Phone);
        if (admin is null)
            return null;

        return new PersonaWorld(customers, nurses, admin.Id);
    }

    // ---------------------------------------------------------------------------------------------------
    // The 5-session package variant (the multi-session engagement source).
    // ---------------------------------------------------------------------------------------------------

    private async Task<long> EnsurePackageVariantAsync(PersonaWorld world, CancellationToken cancellationToken)
    {
        var azizi = world.Nurses[0];

        var shiftGroup = await db.Set<ServiceOptionGroup>()
            .Include(g => g.Values)
            .FirstAsync(g => g.ServiceCategoryId == null && g.NameEn == DemoWorldDefinitions.ShiftGroupNameEn, cancellationToken);
        var nightValueId = shiftGroup.Values.First(v => v.NameEn == DemoWorldDefinitions.ShiftNight).Id;

        // Guarded on the duplicate-listing UNIQUE (nurse × category × option-set hash), not the display
        // name: the DbContext save hook normalizes Persian digits/ZWNJ in every stored string, so a Persian
        // literal never round-trips equal to what was persisted.
        var optionSetHash = OptionSetHash.Compute([(shiftGroup.Id, nightValueId)]);
        var existing = await db.Set<NurseServiceVariant>()
            .Where(v => v.NurseId == azizi.Profile.Id
                        && v.ServiceCategoryId == DemoWorldDefinitions.PostSurgery
                        && v.OptionSetHash == optionSetHash)
            .Select(v => v.Id)
            .FirstOrDefaultAsync(cancellationToken);
        if (existing != 0)
            return existing;

        var variant = new NurseServiceVariant
        {
            NurseId = azizi.Profile.Id,
            ServiceCategoryId = DemoWorldDefinitions.PostSurgery,
            Price = DemoLifecycleDefinitions.PackageVariantPricePerSession,
            PriceUnit = PriceUnits.PerSession,
            SessionCount = DemoLifecycleDefinitions.PackageVariantSessionCount,
            DisplayName = DemoLifecycleDefinitions.PackageVariantDisplayName,
            OptionSetHash = optionSetHash,
            IsActive = true,
            Options = new List<NurseServiceVariantOption>
            {
                new() { OptionGroupId = shiftGroup.Id, OptionValueId = nightValueId }
            }
        };

        db.Set<NurseServiceVariant>().Add(variant);
        await db.SaveChangesAsync(cancellationToken);
        return variant.Id;
    }

    // ---------------------------------------------------------------------------------------------------
    // Request-only scenarios: pending / accepted-awaiting-payment / rejected / expired / cancelled.
    // ---------------------------------------------------------------------------------------------------

    private async Task<int> EnsureRequestScenariosAsync(PersonaWorld world, DateTime now, CancellationToken cancellationToken)
    {
        var mohammadi = world.Customers[0];
        var hosseini = world.Customers[1];
        var azizi = world.Nurses[0];
        var karimi = world.Nurses[1];

        var created = 0;

        // Pending: the nurse inbox's live countdown + the customer's C5 tracker. Deadline must be in the
        // future or the 1-minute booking_request_expiry job sweeps it before anyone can look at it.
        created += await EnsureRequestAsync(
            mohammadi, azizi, azizi.Variants[1].Id, mohammadi.PatientIds[0],
            requestedDate: Today(now, 5), timeStart: new TimeOnly(9, 0), timeEnd: new TimeOnly(13, 0),
            notes: "پدرم به کمک برای جابه‌جایی و مصرف دارو نیاز دارد.",
            deadline: now.AddHours(24), configure: null, cancellationToken);

        // Accepted, awaiting payment: the checkout entry point. The real handler computes a 30-minute
        // window; the demo freezes 24h so the scenario survives longer than half an hour of testing.
        created += await EnsureRequestAsync(
            mohammadi, azizi, azizi.Variants[0].Id, mohammadi.PatientIds[0],
            requestedDate: Today(now, 7), timeStart: new TimeOnly(8, 0), timeEnd: new TimeOnly(8, 0),
            notes: "مراقبت شبانه‌روزی برای دوران نقاهت.",
            deadline: now.AddHours(24),
            configure: r => r.Accept(paymentDeadlineAt: now.AddHours(24)), cancellationToken);

        // Rejected with a schedule-conflict reason (the C5 re-request off-ramp keys off this wording).
        created += await EnsureRequestAsync(
            mohammadi, azizi, azizi.Variants[2].Id, mohammadi.PatientIds[1],
            requestedDate: Today(now, 1), timeStart: new TimeOnly(10, 0), timeEnd: new TimeOnly(18, 0),
            notes: "مراقبت روزانه بعد از عمل زانو.",
            deadline: now.AddHours(24),
            configure: r => r.Reject("زمان درخواستی با برنامه من تداخل دارد."), cancellationToken);

        // Expired: no nurse response before the (backdated) deadline.
        created += await EnsureRequestAsync(
            hosseini, karimi, karimi.Variants[0].Id, hosseini.PatientIds[0],
            requestedDate: Today(now, 1), timeStart: new TimeOnly(9, 0), timeEnd: new TimeOnly(17, 0),
            notes: "مراقبت روزانه در منزل.",
            deadline: now.AddDays(-2),
            configure: r => r.ExpireNoResponse(), cancellationToken);

        // Cancelled by the customer before any response.
        created += await EnsureRequestAsync(
            hosseini, karimi, karimi.Variants[1].Id, hosseini.PatientIds[0],
            requestedDate: Today(now, 4), timeStart: new TimeOnly(8, 0), timeEnd: new TimeOnly(8, 0),
            notes: "لطفاً نادیده بگیرید — برنامه تغییر کرد.",
            deadline: now.AddHours(24),
            configure: r => r.CancelByCustomer(), cancellationToken);

        return created;
    }

    private async Task<int> EnsureRequestAsync(
        CustomerWorld customer,
        NurseWorld nurse,
        long variantId,
        long patientId,
        DateOnly requestedDate,
        TimeOnly timeStart,
        TimeOnly timeEnd,
        string notes,
        DateTime deadline,
        Action<BookingRequest>? configure,
        CancellationToken cancellationToken)
    {
        if (await RequestExistsAsync(customer.ProfileId, nurse.Profile.Id, variantId, requestedDate, cancellationToken))
            return 0;

        var request = new BookingRequest
        {
            CustomerId = customer.ProfileId,
            NurseId = nurse.Profile.Id,
            PatientId = patientId,
            VariantId = variantId,
            CustomerAddressId = customer.PrimaryAddress.Id,
            RequiredCaregiverGender = nurse.Gender,
            RequestedDate = requestedDate,
            RequestedTimeStart = timeStart,
            RequestedTimeEnd = timeEnd,
            CustomerNotes = notes,
            NurseResponseDeadlineAt = deadline
        };
        configure?.Invoke(request);

        db.Set<BookingRequest>().Add(request);
        await db.SaveChangesAsync(cancellationToken);
        return 1;
    }

    private Task<bool> RequestExistsAsync(
        long customerId, long nurseId, long variantId, DateOnly requestedDate, CancellationToken cancellationToken)
        => db.Set<BookingRequest>().AnyAsync(
            r => r.CustomerId == customerId && r.NurseId == nurseId
                 && r.VariantId == variantId && r.RequestedDate == requestedDate,
            cancellationToken);

    // ---------------------------------------------------------------------------------------------------
    // Booking scenarios — request → (real factory) booking → sessions/EVV → payment txn → balanced ledger.
    // ---------------------------------------------------------------------------------------------------

    /// <summary>The seeded (or found) bookings by scenario key, plus how many were newly created.</summary>
    internal sealed class BookingWorld
    {
        public Dictionary<string, BookingEntity> ByKey { get; } = [];
        public Dictionary<string, PaymentTransaction> TxnByKey { get; } = [];
        public int CreatedCount { get; set; }
    }

    private async Task<BookingWorld> EnsureBookingScenariosAsync(
        PersonaWorld world, long packageVariantId, long gatewayId, DateTime now, CancellationToken cancellationToken)
    {
        var result = new BookingWorld();
        var sequence = 0;

        foreach (var scenario in DemoLifecycleDefinitions.Bookings)
        {
            sequence++;
            var customer = world.Customers[scenario.Customer];
            var nurse = world.Nurses[scenario.Nurse];
            var variant = scenario.VariantIndex >= 0
                ? nurse.Variants[scenario.VariantIndex]
                : await db.Set<NurseServiceVariant>().Include(v => v.Options)
                    .FirstAsync(v => v.Id == packageVariantId, cancellationToken);

            var requestedDate = Today(now, scenario.ScheduledInDays);
            var confirmedAt = now.AddDays(-scenario.ConfirmedDaysAgo);

            // One transaction per scenario. The saves below exist only to hand a generated id to the next
            // step (request.Id → booking FK, session ids → EVV, txn.Id → ledger source ref), so they must
            // never be independently observable: committing them separately is what let an interrupted run
            // (a `dotnet watch` restart, a Ctrl-C, a transient DB error) leave a request with no booking, or
            // a booking with no payment. Everything below is therefore all-or-nothing.
            await using var scenarioTx = await db.Database.BeginTransactionAsync(cancellationToken);

            // Resume is per STEP, not per scenario. The old probe keyed the whole scenario on the request —
            // the row written FIRST — then assumed the rest existed, so a half-written scenario made the
            // next boot throw ("Sequence contains no elements") instead of finishing the job. Each step now
            // asks whether its own row is there, so an interrupted seed is completed rather than skipped
            // (which would leave an unreachable world) or fatal.
            var request = await db.Set<BookingRequest>()
                .FirstOrDefaultAsync(
                    r => r.CustomerId == customer.ProfileId && r.NurseId == nurse.Profile.Id
                         && r.VariantId == variant.Id && r.RequestedDate == requestedDate,
                    cancellationToken);
            var wroteAnything = false;

            if (request is null)
            {
                request = new BookingRequest
                {
                    CustomerId = customer.ProfileId,
                    NurseId = nurse.Profile.Id,
                    PatientId = customer.PatientIds[0],
                    VariantId = variant.Id,
                    CustomerAddressId = customer.PrimaryAddress.Id,
                    RequiredCaregiverGender = nurse.Gender,
                    RequestedDate = requestedDate,
                    RequestedTimeStart = new TimeOnly(9, 0),
                    RequestedTimeEnd = new TimeOnly(13, 0),
                    CustomerNotes = "هماهنگی از طریق پیام انجام شد.",
                    NurseResponseDeadlineAt = confirmedAt
                };
                request.Accept(paymentDeadlineAt: confirmedAt);
                db.Set<BookingRequest>().Add(request);
                await db.SaveChangesAsync(cancellationToken); // assigns request.Id for the 1:1 booking FK
                wroteAnything = true;
            }

            var booking = await db.Set<BookingEntity>().Include(b => b.Sessions)
                .FirstOrDefaultAsync(b => b.BookingRequestId == request.Id, cancellationToken);

            if (booking is null)
            {
                booking = BookingFactory.Create(
                    await BuildConversionSourceAsync(request, customer, nurse, variant, cancellationToken),
                    rate: await GetFeeRateAsync(cancellationToken),
                    now: confirmedAt,
                    pspFeeAmount: null,
                    variantSerializer);

                // Transition() fails fast on an illegal edge, and a resumed request may already be
                // converted — re-driving it would turn a repairable state into a crash.
                if (BookingRequestTransitions.CanTransition(request.Status, BookingRequestStatus.Converted))
                    request.MarkConverted();

                ApplyLifecycleState(booking, scenario, now);
                db.Set<BookingEntity>().Add(booking);
                await db.SaveChangesAsync(cancellationToken); // assigns booking/session ids for EVV + ledger refs

                SeedVisitVerifications(booking, scenario, customer.PrimaryAddress, now);

                if (scenario.WithCareInstructions)
                    db.Set<BookingCareInstruction>().Add(BuildCareInstructions(booking.Id));

                await db.SaveChangesAsync(cancellationToken);
                wroteAnything = true;
            }

            var txn = await db.Set<PaymentTransaction>()
                .FirstOrDefaultAsync(
                    t => t.BookingId == booking.Id && t.Status == PaymentTransactionStatus.Succeeded,
                    cancellationToken);

            if (txn is null)
            {
                // The txn commits first so the ledger legs can reference its real id (append-only source refs).
                txn = BuildSucceededTransaction(booking, request, customer.ProfileId, gatewayId, scenario, sequence);
                db.Set<PaymentTransaction>().Add(txn);
                await db.SaveChangesAsync(cancellationToken);
                wroteAnything = true;
            }

            // Card bookings post the balanced capture group here; the BNPL booking posts BnplSettle in the
            // money step (its group references the bnpl_transactions row, which doesn't exist yet).
            if (scenario.Key != "bnpl_settled")
            {
                var capturePosted = await db.Set<LedgerEntry>().AnyAsync(
                    e => e.SourceRefType == LedgerSourceRefType.PaymentTransaction && e.SourceRefId == txn.Id,
                    cancellationToken);

                if (!capturePosted)
                {
                    db.Set<LedgerEntry>().AddRange(LedgerPosting.CardCapture(
                        booking.Id, booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr,
                        booking.NursePayoutAmount, txn.Id, createdAt: confirmedAt));
                    await db.SaveChangesAsync(cancellationToken);
                    wroteAnything = true;
                }
            }

            await scenarioTx.CommitAsync(cancellationToken);

            result.ByKey[scenario.Key] = booking;
            result.TxnByKey[scenario.Key] = txn;
            if (wroteAnything) result.CreatedCount++;
        }

        // One deliberately-failed extra payment attempt so the wallet's payment history has a failed row.
        await EnsureFailedAttemptAsync(world, gatewayId, cancellationToken);

        return result;
    }

    /// <summary>Walks the booking + its sessions along the real transition edges for the scenario.</summary>
    private static void ApplyLifecycleState(BookingEntity booking, BookingScenario scenario, DateTime now)
    {
        if (scenario.Cancelled)
        {
            // ≥ 24h before start → the standard_24h tier, 100% refundable (only un-started sessions).
            booking.TransitionTo(BookingStatus.Cancelled, now.AddDays(-1), CancellationActor.Customer, "برنامه سفر تغییر کرد.");
            booking.RecordCancellationSnapshot(CancellationPolicyCode.Standard24h, 100m, booking.GrossPriceIrr);
            foreach (var session in booking.Sessions)
                session.TransitionTo(BookingSessionStatus.Cancelled);
            return;
        }

        if (scenario.CompletedDaysAgo is { } daysAgo)
        {
            var completedAt = now.AddDays(-daysAgo);
            booking.TransitionTo(BookingStatus.InProgress, completedAt.AddHours(-4));
            booking.TransitionTo(BookingStatus.Completed, completedAt);
            booking.SetDisputeWindow(completedAt.AddHours(72));

            foreach (var session in booking.Sessions)
            {
                session.ScheduledDate = DateOnly.FromDateTime(completedAt);
                session.TransitionTo(BookingSessionStatus.InProgress);
                session.TransitionTo(BookingSessionStatus.Completed);
                session.SetPayoutEligible(completedAt.AddHours(72));
            }
            return;
        }

        if (scenario.Key == "in_progress")
        {
            // The 5-session package mid-engagement: 2 done, session 3 running right now, 4–5 still ahead.
            booking.TransitionTo(BookingStatus.InProgress, now.AddDays(-2));

            var sessions = booking.Sessions.OrderBy(s => s.SessionIndex).ToList();
            for (var i = 0; i < sessions.Count; i++)
            {
                sessions[i].ScheduledDate = Today(now, i - 2);
                switch (i)
                {
                    case < 2:
                        sessions[i].TransitionTo(BookingSessionStatus.InProgress);
                        sessions[i].TransitionTo(BookingSessionStatus.Completed);
                        sessions[i].SetPayoutEligible(now.AddDays(i - 2).AddHours(72));
                        break;
                    case 2:
                        sessions[i].TransitionTo(BookingSessionStatus.InProgress);
                        break;
                }
            }
        }
        // "upcoming" stays exactly as the factory built it: confirmed, all sessions scheduled.
    }

    /// <summary>EVV rows for every non-scheduled session. The draft-batch booking's check-in is deliberately
    /// out of tolerance so the admin EVV queue has a flagged row to review.</summary>
    private void SeedVisitVerifications(
        BookingEntity booking, BookingScenario scenario, CustomerAddress address, DateTime now)
    {
        var outOfRange = scenario.Key == "completed_eligible";

        foreach (var session in booking.Sessions)
        {
            if (session.Status is not (BookingSessionStatus.InProgress or BookingSessionStatus.Completed))
                continue;

            var visitDay = session.ScheduledDate.ToDateTime(session.ScheduledTimeStart, DateTimeKind.Utc);
            var evv = new VisitVerification
            {
                BookingSessionId = session.Id,
                CheckInAt = session.Status == BookingSessionStatus.InProgress ? now.AddHours(-1) : visitDay,
                CheckInLat = address.Latitude + (outOfRange ? 0.0110m : 0.0003m),
                CheckInLng = address.Longitude,
                CheckInAddressMatch = !outOfRange,
                CheckInDistanceMeters = outOfRange ? 1220m : 38m
            };
            evv.MarkCheckedIn();

            if (session.Status == BookingSessionStatus.Completed)
            {
                evv.CheckOutAt = visitDay.AddHours(4);
                evv.CheckOutLat = evv.CheckInLat;
                evv.CheckOutLng = evv.CheckInLng;
                evv.MarkCompleted();
            }

            db.Set<VisitVerification>().Add(evv);
        }
    }

    private BookingCareInstruction BuildCareInstructions(long bookingId) => new()
    {
        BookingId = bookingId,
        CurrentConditions = "فشار خون بالا، دیابت نوع دو تحت کنترل.",
        Medications = "متفورمین ۵۰۰ (صبح و شب)، لوزارتان ۲۵ (صبح).",
        Allergies = "پنی‌سیلین.",
        SpecialInstructions = "اندازه‌گیری قند خون پیش از هر وعده و ثبت آن.",
        EmergencyContactName = "بهرام محمدی",
        EmergencyContactPhone = "09121110010"
    };

    private static PaymentTransaction BuildSucceededTransaction(
        BookingEntity booking,
        BookingRequest request,
        long customerId,
        long gatewayId,
        BookingScenario scenario,
        int sequence)
    {
        var txn = new PaymentTransaction
        {
            BookingRequestId = request.Id,
            CustomerId = customerId,
            GatewayId = gatewayId,
            Amount = booking.GrossPriceIrr,
            GatewayTransactionId = $"demo-txn-{sequence:D4}",
            GatewayReferenceCode = $"{DemoLifecycleDefinitions.GatewayRefPrefix}{sequence:D4}",
            IsInstallment = scenario.Key == "bnpl_settled"
        };
        txn.MarkSucceeded(booking.Id, "100", "{\"source\":\"demo-lifecycle-seed\"}");
        return txn;
    }

    private async Task<BookingConversionSource> BuildConversionSourceAsync(
        BookingRequest request,
        CustomerWorld customer,
        NurseWorld nurse,
        NurseServiceVariant variant,
        CancellationToken cancellationToken)
    {
        var patientName = await db.Set<Patient>()
            .Where(p => p.Id == request.PatientId)
            .Select(p => p.DisplayName)
            .FirstAsync(cancellationToken);

        return new BookingConversionSource(
            request.Id,
            request.Status,
            customer.ProfileId,
            customer.UserId,
            nurse.Profile.Id,
            nurse.UserId,
            request.PatientId,
            patientName,
            variant.Id,
            customer.PrimaryAddress.Id,
            request.RequestedDate,
            request.RequestedTimeStart,
            request.RequestedTimeEnd,
            await BuildVariantSnapshotAsync(variant, cancellationToken),
            await BuildAddressSnapshotAsync(customer.PrimaryAddress, cancellationToken));
    }

    private async Task<VariantSnapshot> BuildVariantSnapshotAsync(
        NurseServiceVariant variant, CancellationToken cancellationToken)
    {
        var category = await db.Set<ServiceCategory>()
            .FirstAsync(c => c.Id == variant.ServiceCategoryId, cancellationToken);

        var options = new List<VariantOptionDto>();
        foreach (var option in variant.Options)
        {
            var group = await db.Set<ServiceOptionGroup>().FirstAsync(g => g.Id == option.OptionGroupId, cancellationToken);
            var value = await db.Set<ServiceOptionValue>().FirstAsync(v => v.Id == option.OptionValueId, cancellationToken);
            options.Add(new VariantOptionDto(group.Id, group.NameFa, group.NameEn, value.Id, value.NameFa, value.NameEn));
        }

        return new VariantSnapshot(
            variant.Id, variant.ServiceCategoryId, category.NameFa, category.NameEn,
            variant.Price, variant.PriceUnit, variant.SessionCount, variant.DisplayName, options);
    }

    private async Task<AddressSnapshot> BuildAddressSnapshotAsync(
        CustomerAddress address, CancellationToken cancellationToken)
    {
        var city = await db.Set<City>().FirstAsync(c => c.Id == address.CityId, cancellationToken);
        var district = address.DistrictId is { } districtId
            ? await db.Set<District>().FirstAsync(d => d.Id == districtId, cancellationToken)
            : null;

        return new AddressSnapshot(
            address.Id, address.Title, city.Id, city.NameFa, city.NameEn,
            district?.Id, district?.NameFa, district?.NameEn,
            address.AddressLine, address.PostalCode, address.RecipientName, address.RecipientPhone,
            address.Latitude, address.Longitude);
    }

    /// <summary>A failed card attempt against the accepted-awaiting-payment request — the wallet history's
    /// failed row. Guarded on its fixed gateway reference.</summary>
    private async Task EnsureFailedAttemptAsync(PersonaWorld world, long gatewayId, CancellationToken cancellationToken)
    {
        const string reference = DemoLifecycleDefinitions.GatewayRefPrefix + "fail";
        if (await db.Set<PaymentTransaction>().AnyAsync(t => t.GatewayReferenceCode == reference, cancellationToken))
            return;

        var mohammadi = world.Customers[0];
        var accepted = await db.Set<BookingRequest>()
            .Where(r => r.CustomerId == mohammadi.ProfileId && r.Status == BookingRequestStatus.AcceptedAwaitingPayment)
            .Select(r => new { r.Id, r.Variant.Price })
            .FirstOrDefaultAsync(cancellationToken);
        if (accepted is null)
            return;

        var txn = new PaymentTransaction
        {
            BookingRequestId = accepted.Id,
            CustomerId = mohammadi.ProfileId,
            GatewayId = gatewayId,
            Amount = accepted.Price,
            GatewayTransactionId = "demo-txn-fail",
            GatewayReferenceCode = reference
        };
        txn.MarkFailed("51", "{\"error\":\"insufficient_funds\",\"source\":\"demo-lifecycle-seed\"}");
        db.Set<PaymentTransaction>().Add(txn);
        await db.SaveChangesAsync(cancellationToken);
    }

    private decimal? _feeRate;

    /// <summary>The snapshotted commission rate — read through the same typed config the real handlers use.</summary>
    private async ValueTask<decimal> GetFeeRateAsync(CancellationToken cancellationToken)
        => _feeRate ??= await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);

    private static DateOnly Today(DateTime now, int offsetDays) => DateOnly.FromDateTime(now.AddDays(offsetDays));
}
