#nullable enable
using System.Globalization;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Application.Models.Search;
using Baya.Domain.Entities.Search;
using Microsoft.EntityFrameworkCore;

namespace Baya.Infrastructure.Persistence.Services.Search;

/// <summary>
/// The MVP <see cref="INurseSearch"/> backend — the real, production search over the maintained
/// <c>nurse_search_index</c>. It reads <b>only</b> <c>is_searchable = 1</c> rows (an unverified, suspended,
/// paused, or deactivated nurse/variant never surfaces), applies the category/city/district/gender/price
/// filters and the rating sort, and paginates. Served from the covering search index; a later
/// <c>ElasticNurseSearch</c> replaces this class behind the same interface with no caller changes.
/// </summary>
internal sealed class SqlNurseSearch(ApplicationDbContext db) : INurseSearch
{
    public async Task<PagedResult<NurseSearchResultDto>> SearchAsync(NurseSearchCriteria criteria, CancellationToken cancellationToken)
    {
        var query = db.Set<NurseSearchIndex>()
            .AsNoTracking()
            .Where(r => r.IsSearchable
                        && r.ServiceCategoryId == criteria.ServiceCategoryId
                        && r.CityId == criteria.CityId);

        // NULL-district = "whole city". A district search matches that district's rows PLUS the whole-city
        // (NULL) rows; a city-only search (no district) matches every row in the city, NULL or not.
        if (criteria.DistrictId is { } districtId)
            query = query.Where(r => r.DistrictId == districtId || r.DistrictId == null);

        if (!string.IsNullOrWhiteSpace(criteria.NurseGender))
            query = query.Where(r => r.NurseGender == criteria.NurseGender);

        if (criteria.MinPrice is { } min)
            query = query.Where(r => r.Price >= min);

        if (criteria.MaxPrice is { } max)
            query = query.Where(r => r.Price <= max);

        if (!string.IsNullOrWhiteSpace(criteria.PriceUnit))
            query = query.Where(r => r.PriceUnit == criteria.PriceUnit);

        var total = await query.CountAsync(cancellationToken);

        var rows = await query
            // Rating sort is the only MVP sort; the tiebreak on reviews then nurse_id keeps paging deterministic.
            .OrderByDescending(r => r.AverageRating)
            .ThenByDescending(r => r.TotalReviews)
            .ThenBy(r => r.NurseId)
            .ThenBy(r => r.VariantId)
            .Skip((criteria.Page - 1) * criteria.PageSize)
            .Take(criteria.PageSize)
            .Select(r => new Row(
                r.VariantId, r.NurseId, r.ServiceCategoryId, r.Price, r.PriceUnit, r.NurseGender,
                r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId,
                r.NurseName, r.AvatarUrl))
            .ToListAsync(cancellationToken);

        // Format price to a digit string in memory (no long.ToString translation required in SQL).
        // DistanceKm is null: the covering index carries no coordinate, so distance is not derivable here.
        var items = rows.Select(r => new NurseSearchResultDto(
            r.VariantId, r.NurseId, r.ServiceCategoryId,
            r.Price.ToString(CultureInfo.InvariantCulture), r.PriceUnit, r.NurseGender,
            r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId,
            r.NurseName, r.AvatarUrl, null)).ToList();

        return new PagedResult<NurseSearchResultDto>(items, total, criteria.Page, criteria.PageSize);
    }

    private sealed record Row(
        long VariantId, long NurseId, long ServiceCategoryId, long Price, string PriceUnit, string NurseGender,
        decimal AverageRating, int TotalReviews, int TotalCompletedBookings, long CityId, long? DistrictId,
        string NurseName, string AvatarUrl);
}
