#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Identity;
using Baya.Application.Contracts.Search;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Baya.Infrastructure.Persistence.Services.Seeding;

/// <summary>
/// Development-only seeder that turns a freshly-migrated database (reference/lookup rows + one admin + one
/// gateway) into a <b>populated demo marketplace</b>: verified nurses with priced variants and Tehran
/// coverage, an unverified nurse, and demo customers with patients and addresses. Without it every real-path
/// discovery/search/booking screen renders empty because the reference seed contains no transactional data.
/// <para>
/// It seeds the <i>causes</i> (verification flip, active variants, covered areas) and lets the real
/// <see cref="ISearchIndexMaintainer"/> compute the <c>nurse_search_index</c> — never hand-inserting index
/// rows — so the seeded world is identical to one built by real usage and the <c>is_searchable</c> invariant
/// stays truthful. Every insert is guarded on a stable natural key (the persona's phone number, the option
/// group name), so re-running on an already-seeded DB is a no-op.
/// </para>
/// </summary>
internal sealed class DemoWorldSeeder(
    ApplicationDbContext db,
    IAppUserManager userManager,
    ISearchIndexMaintainer searchIndex,
    IFieldEncryptor encryptor,
    IDateTimeProvider clock,
    ILogger<DemoWorldSeeder> logger)
{
    public async Task SeedAsync(CancellationToken cancellationToken)
    {
        var (shiftGroupId, shiftValueIdByCode) = await EnsureShiftTypeGroupAsync(cancellationToken);

        var seededNurses = 0;
        foreach (var persona in DemoWorldDefinitions.Nurses)
            if (await EnsureNurseAsync(persona, shiftGroupId, shiftValueIdByCode, cancellationToken))
                seededNurses++;

        var seededCustomers = 0;
        foreach (var persona in DemoWorldDefinitions.Customers)
            if (await EnsureCustomerAsync(persona, cancellationToken))
                seededCustomers++;

        var seededAdmins = 0;
        foreach (var persona in DemoWorldDefinitions.Admins)
            if (await EnsureAdminAsync(persona, cancellationToken))
                seededAdmins++;

        // Re-derive the whole search projection from source. Idempotent (drops + rebuilds), and the single
        // place is_searchable is computed — verified+accepting nurses with an active variant surface; the
        // unverified nurse does not.
        var rebuild = await searchIndex.RebuildAsync(cancellationToken);

        if (seededNurses == 0 && seededCustomers == 0 && seededAdmins == 0)
            logger.LogInformation(
                "Demo world already seeded — no-op. Search index re-derived: {Nurses} nurses, {Rows} rows.",
                rebuild.NursesProcessed, rebuild.RowsWritten);
        else
            logger.LogInformation(
                "Demo world seeded: {Nurses} nurse(s), {Customers} customer(s), {Admins} admin(s). Search index: {IndexNurses} nurses, {Rows} searchable-eligible rows.",
                seededNurses, seededCustomers, seededAdmins, rebuild.NursesProcessed, rebuild.RowsWritten);
    }

    private async Task<(long GroupId, IReadOnlyDictionary<string, long> ValueIdByCode)> EnsureShiftTypeGroupAsync(
        CancellationToken cancellationToken)
    {
        var group = await db.Set<ServiceOptionGroup>()
            .Include(g => g.Values)
            .FirstOrDefaultAsync(
                g => g.ServiceCategoryId == null && g.NameEn == DemoWorldDefinitions.ShiftGroupNameEn,
                cancellationToken);

        if (group is null)
        {
            group = new ServiceOptionGroup
            {
                ServiceCategoryId = null, // cross-category: applies to every category
                NameFa = DemoWorldDefinitions.ShiftGroupNameFa,
                NameEn = DemoWorldDefinitions.ShiftGroupNameEn,
                IsRequired = true,
                SortOrder = 1,
                IsActive = true,
                Values = DemoWorldDefinitions.ShiftValues
                    .Select((v, i) => new ServiceOptionValue
                    {
                        NameFa = v.NameFa,
                        NameEn = v.NameEn,
                        SortOrder = i + 1,
                        IsActive = true
                    })
                    .ToList()
            };

            db.Set<ServiceOptionGroup>().Add(group);
            await db.SaveChangesAsync(cancellationToken);
        }

        var valueIdByCode = group.Values.ToDictionary(v => v.NameEn, v => v.Id);
        return (group.Id, valueIdByCode);
    }

    /// <returns><c>true</c> if the persona was newly created; <c>false</c> if it already existed.</returns>
    private async Task<bool> EnsureNurseAsync(
        NursePersona persona,
        long shiftGroupId,
        IReadOnlyDictionary<string, long> shiftValueIdByCode,
        CancellationToken cancellationToken)
    {
        if (await userManager.GetUserByPhoneNumber(persona.Phone) is not null)
            return false;

        var user = await CreateUserAsync(persona.UserName, persona.Phone, persona.Name, persona.FamilyName,
            persona.Gender, persona.NationalId, RoleNames.Nurse, cancellationToken);

        var now = clock.UtcNow;

        var profile = new NurseProfile
        {
            UserId = user.Id,
            Bio = persona.Bio,
            YearsOfExperience = persona.YearsOfExperience,
            EducationLevel = "کارشناسی",
            EducationField = "پرستاری",
            SpecializationsJson = "[]"
        };
        profile.SetAcceptingBookings(true);
        db.Set<NurseProfile>().Add(profile);
        await db.SaveChangesAsync(cancellationToken); // assigns profile.Id used by the FK-only child rows

        // Verification record: the single source of verification truth. A verified nurse is approved; the
        // profile's guarded is_verified flag is flipped through its sanctioned write path (mirroring b6's
        // finalize), which is what the search maintainer and trust badge read.
        var verification = new NurseVerification
        {
            NurseId = profile.Id,
            Status = persona.IsVerified ? VerificationStatus.Approved : VerificationStatus.Pending,
            SubmittedAt = now,
            ApprovedAt = persona.IsVerified ? now : null
        };
        db.Set<NurseVerification>().Add(verification);

        if (persona.IsVerified)
            profile.MarkVerified();

        foreach (var credential in persona.Credentials)
            db.Set<NurseCredential>().Add(new NurseCredential
            {
                NurseId = profile.Id,
                CredentialType = credential.Type,
                CredentialNumber = credential.Number, // encrypted at rest by the value converter
                HolderNameSnapshot = $"{persona.Name} {persona.FamilyName}",
                IssuingAuthority = credential.IssuingAuthority,
                IssuedAt = DateOnly.FromDateTime(now.UtcDateTime).AddYears(-1),
                ExpiresAt = DateOnly.FromDateTime(now.UtcDateTime).AddYears(2),
                VerificationMethod = VerificationMethods.Manual
            });

        if (persona.Bank is { } bank)
        {
            var iban = bank.Iban;
            db.Set<NurseBankAccount>().Add(new NurseBankAccount
            {
                NurseId = profile.Id,
                BankName = bank.BankName,
                AccountHolderName = bank.AccountHolderName, // encrypted at rest by the value converter
                Iban = iban,                                // encrypted at rest by the value converter
                IbanHash = encryptor.Hash(iban),            // deterministic lookup/uniqueness column
                IsPrimary = true,
                IsVerified = bank.MatchedNationalId,
                VerifiedAt = bank.MatchedNationalId ? now : null,
                MatchedNationalId = bank.MatchedNationalId,
                AccountHolderFromBank = bank.MatchedNationalId ? bank.AccountHolderName : null!,
                OwnershipVendorRef = bank.MatchedNationalId ? $"demo-sheba-{profile.Id}" : null!
            });
        }

        foreach (var variantDef in persona.Variants)
        {
            var shiftValueId = shiftValueIdByCode[variantDef.ShiftValueEn];
            var optionPairs = new[] { (shiftGroupId, shiftValueId) };

            db.Set<NurseServiceVariant>().Add(new NurseServiceVariant
            {
                NurseId = profile.Id,
                ServiceCategoryId = variantDef.CategoryId,
                Price = variantDef.Price,
                PriceUnit = variantDef.PriceUnit,
                SessionCount = variantDef.SessionCount,
                DisplayName = variantDef.DisplayName,
                OptionSetHash = OptionSetHash.Compute(optionPairs),
                IsActive = true,
                Options = new List<NurseServiceVariantOption>
                {
                    new() { OptionGroupId = shiftGroupId, OptionValueId = shiftValueId }
                }
            });
        }

        foreach (var area in persona.Areas)
            db.Set<NurseServiceArea>().Add(new NurseServiceArea
            {
                NurseId = profile.Id,
                CityId = area.CityId,
                DistrictId = area.DistrictId,
                IsActive = true
            });

        await db.SaveChangesAsync(cancellationToken);
        return true;
    }

    private async Task<bool> EnsureCustomerAsync(CustomerPersona persona, CancellationToken cancellationToken)
    {
        if (await userManager.GetUserByPhoneNumber(persona.Phone) is not null)
            return false;

        var user = await CreateUserAsync(persona.UserName, persona.Phone, persona.Name, persona.FamilyName,
            persona.Gender, nationalId: null, RoleNames.Customer, cancellationToken);

        var profile = new CustomerProfile
        {
            UserId = user.Id,
            DefaultEmergencyContactName = persona.EmergencyContactName, // encrypted at rest
            DefaultEmergencyContactPhone = persona.EmergencyContactPhone // encrypted at rest
        };
        db.Set<CustomerProfile>().Add(profile);
        await db.SaveChangesAsync(cancellationToken); // assigns profile.Id used by the FK-only child rows

        foreach (var patient in persona.Patients)
            db.Set<Patient>().Add(new Patient
            {
                CustomerId = profile.Id,
                DisplayName = patient.DisplayName,
                FirstName = patient.FirstName,
                LastName = patient.LastName,
                Gender = patient.Gender,
                BirthDate = patient.BirthDate,
                BloodType = patient.BloodType,
                InitialMedicalNotes = patient.InitialMedicalNotes, // encrypted at rest
                IsActive = true
            });

        foreach (var address in persona.Addresses)
            db.Set<CustomerAddress>().Add(new CustomerAddress
            {
                CustomerId = profile.Id,
                CityId = DemoWorldDefinitions.TehranCityId,
                DistrictId = address.DistrictId,
                Title = address.Title,
                AddressLine = address.AddressLine, // encrypted at rest
                PostalCode = address.PostalCode,   // encrypted at rest
                Latitude = address.Latitude,
                Longitude = address.Longitude,
                IsPrimary = address.IsPrimary,
                RecipientName = address.RecipientName,   // encrypted at rest
                RecipientPhone = address.RecipientPhone  // encrypted at rest
            });

        await db.SaveChangesAsync(cancellationToken);
        return true;
    }

    /// <returns><c>true</c> if the admin was newly created; <c>false</c> if it already existed.</returns>
    private async Task<bool> EnsureAdminAsync(AdminPersona persona, CancellationToken cancellationToken)
    {
        if (await userManager.GetUserByPhoneNumber(persona.Phone) is not null)
            return false;

        // An admin is just a phone user + a server-granted admin role — no NurseProfile/CustomerProfile. This
        // is the only way to reach the /admin console through the phone-OTP login (admin sub-roles are never
        // self-selectable via me/select_role).
        await CreateUserAsync(persona.UserName, persona.Phone, persona.Name, persona.FamilyName,
            persona.Gender, nationalId: null, persona.RoleName, cancellationToken);
        return true;
    }

    private async Task<User> CreateUserAsync(
        string userName, string phone, string name, string familyName, string gender,
        string? nationalId, string roleName, CancellationToken cancellationToken)
    {
        var user = new User
        {
            UserName = userName,
            PhoneNumber = phone,          // encrypted at rest; PhoneHash synced on SaveChanges
            PhoneNumberConfirmed = true,  // so the demo account logs in immediately via the OTP flow
            PhoneVerifiedAt = clock.UtcNow,
            Name = name,
            FamilyName = familyName,
            Gender = gender,
            NationalId = nationalId,      // encrypted at rest (null for the unverified/customer personas)
            IsActive = true
        };

        var createResult = await userManager.CreateUser(user);
        if (!createResult.Succeeded)
            throw new InvalidOperationException(
                $"Demo seed failed to create user '{userName}': {string.Join("; ", createResult.Errors.Select(e => e.Description))}");

        var role = await db.Set<Role>().FirstOrDefaultAsync(r => r.Name == roleName, cancellationToken)
            ?? throw new InvalidOperationException($"Role '{roleName}' is not seeded — cannot assign it to the demo user.");
        await userManager.AddUserToRoleAsync(user, role);

        return user;
    }
}
