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

namespace Baya.Infrastructure.Persistence.Services.Search;

/// <summary>
/// The inline SQL implementation of <see cref="ISearchIndexMaintainer"/>. It shares the request-scoped
/// <see cref="ApplicationDbContext"/> with the calling handler's <c>IUnitOfWork</c>, so it only <b>stages</b>
/// index changes — the handler's single <c>CommitAsync</c> flushes source + projection in one transaction.
/// (<see cref="RebuildAsync"/> is the exception: a standalone job that owns its own batched commits.)
/// <para>
/// The visibility gate is recomputed on every call: a row is searchable only when the nurse is verified,
/// not suspended, accepting bookings, and the variant is active. Each (variant × area) has exactly one live
/// row; a soft-deleted row is resurrected on re-upsert rather than duplicated.
/// </para>
/// </summary>
internal sealed class SearchIndexMaintainer(ApplicationDbContext db, IDateTimeProvider clock) : ISearchIndexMaintainer
{
    public async Task ReindexVariantAsync(NurseServiceVariant variant, CancellationToken cancellationToken)
    {
        var ctx = await LoadNurseContextAsync(variant.NurseId, cancellationToken);
        if (ctx is null)
            return;

        var status = await LoadStatusAsync(variant.NurseId, cancellationToken);
        var bookable = NurseBookable(ctx.IsVerified, ctx.IsAcceptingBookings, status);
        var areas = await LoadActiveAreasAsync(variant.NurseId, cancellationToken);
        var vk = new VariantKey(variant.Id, variant.ServiceCategoryId, variant.Price, variant.PriceUnit, variant.IsActive);

        foreach (var area in areas)
            await UpsertRowAsync(variant, vk, area, variant.NurseId, ctx, bookable, cancellationToken);

        // Reconcile: soft-delete this variant's live rows whose area the nurse no longer covers.
        if (variant.Id != 0)
        {
            var live = await db.Set<NurseSearchIndex>()
                .Where(r => r.VariantId == variant.Id)
                .ToListAsync(cancellationToken);
            var covered = areas.ToHashSet();
            foreach (var row in live)
                if (!covered.Contains(new AreaKey(row.CityId, row.DistrictId)))
                    SoftDelete(row);
        }
    }

    public async Task ReindexNurseAsync(NurseProfile profile, VerificationStatus? verificationStatus, CancellationToken cancellationToken)
    {
        var status = verificationStatus ?? await LoadStatusAsync(profile.Id, cancellationToken);
        var identity = await LoadIdentityAsync(profile.Id, cancellationToken);

        // Bookability + aggregates come from the tracked profile (its just-changed values); gender + name are
        // stable for these triggers so they are read from the database. The avatar is on the tracked profile.
        var ctx = new NurseContext(
            profile.IsVerified, profile.IsAcceptingBookings, identity.Gender,
            profile.AverageRating, profile.TotalReviews, profile.TotalCompletedBookings,
            identity.Name, profile.AvatarUrl);
        var bookable = NurseBookable(profile.IsVerified, profile.IsAcceptingBookings, status);

        var areas = await LoadActiveAreasAsync(profile.Id, cancellationToken);
        var variants = await LoadVariantsAsync(profile.Id, cancellationToken);

        var target = new HashSet<(long VariantId, long CityId, long? DistrictId)>();
        foreach (var variant in variants)
            foreach (var area in areas)
            {
                await UpsertRowAsync(null, variant, area, profile.Id, ctx, bookable, cancellationToken);
                target.Add((variant.Id, area.CityId, area.DistrictId));
            }

        // Prune live rows no longer derivable (variant deleted / area removed).
        var liveRows = await db.Set<NurseSearchIndex>()
            .Where(r => r.NurseId == profile.Id)
            .ToListAsync(cancellationToken);
        foreach (var row in liveRows)
            if (!target.Contains((row.VariantId, row.CityId, row.DistrictId)))
                SoftDelete(row);
    }

    public async Task FanOutServiceAreaAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken)
    {
        var ctx = await LoadNurseContextAsync(nurseId, cancellationToken);
        if (ctx is null)
            return;

        var status = await LoadStatusAsync(nurseId, cancellationToken);
        var bookable = NurseBookable(ctx.IsVerified, ctx.IsAcceptingBookings, status);
        var variants = await LoadVariantsAsync(nurseId, cancellationToken);
        var area = new AreaKey(cityId, districtId);

        foreach (var variant in variants)
            await UpsertRowAsync(null, variant, area, nurseId, ctx, bookable, cancellationToken);
    }

    public async Task RemoveServiceAreaRowsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken)
    {
        var rows = await db.Set<NurseSearchIndex>()
            .Where(r => r.NurseId == nurseId && r.CityId == cityId && r.DistrictId == districtId)
            .ToListAsync(cancellationToken);
        foreach (var row in rows)
            SoftDelete(row);
    }

    public async Task<SearchIndexRebuildResult> RebuildAsync(CancellationToken cancellationToken)
    {
        // Idempotent full rebuild: drop the whole projection, then re-derive from source in nurse-batches.
        await db.Set<NurseSearchIndex>().IgnoreQueryFilters().ExecuteDeleteAsync(cancellationToken);

        const int batchSize = 200;
        var pageIndex = 0;
        var nursesProcessed = 0;
        var rowsWritten = 0;

        while (true)
        {
            var nurseIds = await db.Set<NurseProfile>()
                .OrderBy(p => p.Id)
                .Skip(pageIndex * batchSize)
                .Take(batchSize)
                .Select(p => p.Id)
                .ToListAsync(cancellationToken);
            if (nurseIds.Count == 0)
                break;

            foreach (var nurseId in nurseIds)
            {
                rowsWritten += await BuildFreshRowsForNurseAsync(nurseId, cancellationToken);
                nursesProcessed++;
            }

            await db.SaveChangesAsync(cancellationToken);
            pageIndex++;
        }

        return new SearchIndexRebuildResult(nursesProcessed, rowsWritten);
    }

    private async Task<int> BuildFreshRowsForNurseAsync(long nurseId, CancellationToken cancellationToken)
    {
        var ctx = await LoadNurseContextAsync(nurseId, cancellationToken);
        if (ctx is null)
            return 0;

        var status = await LoadStatusAsync(nurseId, cancellationToken);
        var bookable = NurseBookable(ctx.IsVerified, ctx.IsAcceptingBookings, status);
        var areas = await LoadActiveAreasAsync(nurseId, cancellationToken);
        var variants = await LoadVariantsAsync(nurseId, cancellationToken);

        var count = 0;
        foreach (var variant in variants)
            foreach (var area in areas)
            {
                await db.Set<NurseSearchIndex>().AddAsync(
                    NewRow(null, variant, area, nurseId, ctx, bookable && variant.IsActive), cancellationToken);
                count++;
            }

        return count;
    }

    private async Task UpsertRowAsync(
        NurseServiceVariant? variantEntity,
        VariantKey variant,
        AreaKey area,
        long nurseId,
        NurseContext ctx,
        bool nurseBookable,
        CancellationToken cancellationToken)
    {
        var isSearchable = nurseBookable && variant.IsActive;

        // A new variant (id 0) can have no existing rows; otherwise look past the soft-delete filter so a
        // previously-removed (variant × area) row is resurrected rather than duplicated.
        var existing = variant.Id == 0
            ? null
            : await db.Set<NurseSearchIndex>()
                .IgnoreQueryFilters()
                .FirstOrDefaultAsync(
                    r => r.VariantId == variant.Id && r.CityId == area.CityId && r.DistrictId == area.DistrictId,
                    cancellationToken);

        if (existing is null)
        {
            await db.Set<NurseSearchIndex>().AddAsync(
                NewRow(variantEntity, variant, area, nurseId, ctx, isSearchable), cancellationToken);
            return;
        }

        existing.NurseId = nurseId;
        existing.ServiceCategoryId = variant.ServiceCategoryId;
        existing.Price = variant.Price;
        existing.PriceUnit = variant.PriceUnit;
        existing.NurseGender = ctx.Gender;
        existing.NurseName = ctx.NurseName;
        existing.AvatarUrl = ctx.AvatarUrl;
        existing.AverageRating = ctx.AverageRating;
        existing.TotalReviews = ctx.TotalReviews;
        existing.TotalCompletedBookings = ctx.TotalCompletedBookings;
        existing.IsSearchable = isSearchable;
        existing.UpdatedAt = clock.UtcNow;
        existing.DeletedAt = null;
    }

    private NurseSearchIndex NewRow(
        NurseServiceVariant? variantEntity, VariantKey variant, AreaKey area, long nurseId, NurseContext ctx, bool isSearchable)
    {
        var row = new NurseSearchIndex
        {
            VariantId = variant.Id,
            NurseId = nurseId,
            ServiceCategoryId = variant.ServiceCategoryId,
            Price = variant.Price,
            PriceUnit = variant.PriceUnit,
            CityId = area.CityId,
            DistrictId = area.DistrictId,
            NurseGender = ctx.Gender,
            NurseName = ctx.NurseName,
            AvatarUrl = ctx.AvatarUrl,
            AverageRating = ctx.AverageRating,
            TotalReviews = ctx.TotalReviews,
            TotalCompletedBookings = ctx.TotalCompletedBookings,
            IsSearchable = isSearchable,
            UpdatedAt = clock.UtcNow
        };

        // For a freshly-created variant the id is not assigned yet — attach the tracked principal so EF sets
        // the generated variant_id in the same graph insert.
        if (variant.Id == 0 && variantEntity is not null)
            row.Variant = variantEntity;

        return row;
    }

    private void SoftDelete(NurseSearchIndex row)
    {
        row.DeletedAt = clock.UtcNow;
        row.IsSearchable = false;
        row.UpdatedAt = clock.UtcNow;
    }

    private static bool NurseBookable(bool isVerified, bool isAccepting, VerificationStatus? status)
        => isVerified && isAccepting && status != VerificationStatus.Suspended;

    private Task<NurseContext?> LoadNurseContextAsync(long nurseId, CancellationToken cancellationToken)
        => db.Set<NurseProfile>()
            .Where(p => p.Id == nurseId)
            .Select(p => new NurseContext(
                p.IsVerified, p.IsAcceptingBookings, p.User.Gender,
                p.AverageRating, p.TotalReviews, p.TotalCompletedBookings,
                ((p.User.Name ?? "") + " " + (p.User.FamilyName ?? "")).Trim(), p.AvatarUrl))
            .FirstOrDefaultAsync(cancellationToken);

    private async Task<NurseIdentity> LoadIdentityAsync(long nurseId, CancellationToken cancellationToken)
    {
        var identity = await db.Set<NurseProfile>()
            .Where(p => p.Id == nurseId)
            .Select(p => new NurseIdentity(
                p.User.Gender ?? string.Empty,
                ((p.User.Name ?? "") + " " + (p.User.FamilyName ?? "")).Trim()))
            .FirstOrDefaultAsync(cancellationToken);
        // NurseIdentity is a struct — FirstOrDefaultAsync yields default (null fields) when no row exists.
        return new NurseIdentity(identity.Gender ?? string.Empty, identity.Name ?? string.Empty);
    }

    private Task<VerificationStatus?> LoadStatusAsync(long nurseId, CancellationToken cancellationToken)
        => db.Set<NurseVerification>()
            .Where(v => v.NurseId == nurseId)
            .Select(v => (VerificationStatus?)v.Status)
            .FirstOrDefaultAsync(cancellationToken);

    private Task<List<AreaKey>> LoadActiveAreasAsync(long nurseId, CancellationToken cancellationToken)
        => db.Set<NurseServiceArea>()
            .Where(a => a.NurseId == nurseId && a.IsActive)
            .Select(a => new AreaKey(a.CityId, a.DistrictId))
            .ToListAsync(cancellationToken);

    private Task<List<VariantKey>> LoadVariantsAsync(long nurseId, CancellationToken cancellationToken)
        => db.Set<NurseServiceVariant>()
            .Where(v => v.NurseId == nurseId)
            .Select(v => new VariantKey(v.Id, v.ServiceCategoryId, v.Price, v.PriceUnit, v.IsActive))
            .ToListAsync(cancellationToken);

    private readonly record struct AreaKey(long CityId, long? DistrictId);

    private readonly record struct VariantKey(long Id, long ServiceCategoryId, long Price, string PriceUnit, bool IsActive);

    private sealed record NurseContext(
        bool IsVerified, bool IsAcceptingBookings, string Gender,
        decimal AverageRating, int TotalReviews, int TotalCompletedBookings,
        string NurseName, string AvatarUrl);

    private readonly record struct NurseIdentity(string Gender, string Name);
}
