using System.Text.Json;
using Meezi.API.Models.Public;
using Meezi.API.Security;
using Meezi.Core.Discover;
using Meezi.Core.Entities;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Data;
using Meezi.Core.Enums;
using Meezi.Infrastructure.Discover;
using Microsoft.EntityFrameworkCore;

namespace Meezi.API.Services;

public interface IReviewService
{
    Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
        DiscoverFilterParams filters,
        CancellationToken cancellationToken = default);

    Task<IReadOnlyList<CafeReviewDto>> GetReviewsAsync(
        string cafeId,
        int page,
        int pageSize,
        bool publicOnly = true,
        CancellationToken cancellationToken = default);
    Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewAsync(
        string cafeId,
        CreateCafeReviewRequest request,
        CancellationToken cancellationToken = default);
    Task<CafeReviewDto?> ReplyReviewAsync(string cafeId, string reviewId, string reply, CancellationToken cancellationToken = default);
    Task<CafeReviewDto?> SetHiddenAsync(string cafeId, string reviewId, bool isHidden, CancellationToken cancellationToken = default);
    Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewWithPhotosAsync(
        string cafeId,
        CreateCafeReviewRequest request,
        IReadOnlyList<IFormFile> photos,
        CancellationToken cancellationToken = default);
    Task<(double Average, int Count)> GetRatingSummaryAsync(string cafeId, CancellationToken cancellationToken = default);
}

public class ReviewService : IReviewService
{
    private const int MaxReviewPhotos = 3;

    private readonly AppDbContext _db;
    private readonly IAbuseProtectionService _abuse;
    private readonly IHttpContextAccessor _http;
    private readonly IMediaStorageService _media;

    public ReviewService(
        AppDbContext db,
        IAbuseProtectionService abuse,
        IHttpContextAccessor http,
        IMediaStorageService media)
    {
        _db = db;
        _abuse = abuse;
        _http = http;
        _media = media;
    }

    public async Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
        DiscoverFilterParams filters,
        CancellationToken cancellationToken = default)
    {
        var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null);

        if (!string.IsNullOrWhiteSpace(filters.City))
            query = query.Where(c => c.City != null && c.City.Contains(filters.City));

        if (!string.IsNullOrWhiteSpace(filters.Q))
        {
            var q = filters.Q.Trim();
            var qNorm = PersianSearchNormalizer.Normalize(q);
            var pattern = $"%{q}%";
            var patternNorm = qNorm.Length > 0 && !string.Equals(qNorm, q, StringComparison.Ordinal)
                ? $"%{qNorm}%"
                : null;

            query = query.Where(c =>
                EF.Functions.ILike(c.Name, pattern)
                || EF.Functions.ILike(c.Slug, pattern)
                || (c.Description != null && EF.Functions.ILike(c.Description, pattern))
                || (c.Address != null && EF.Functions.ILike(c.Address, pattern))
                || (c.City != null && EF.Functions.ILike(c.City, pattern))
                || (c.NameAr != null && EF.Functions.ILike(c.NameAr, pattern))
                || (patternNorm != null && (
                    EF.Functions.ILike(c.Name, patternNorm)
                    || (c.Description != null && EF.Functions.ILike(c.Description, patternNorm))
                    || (c.Address != null && EF.Functions.ILike(c.Address, patternNorm))))
                || _db.MenuItems.Any(m =>
                    m.CafeId == c.Id
                    && m.DeletedAt == null
                    && (EF.Functions.ILike(m.Name, pattern)
                        || (patternNorm != null && EF.Functions.ILike(m.Name, patternNorm)))));
        }

        var cafes = await query.ToListAsync(cancellationToken);
        var cafeIds = cafes.Select(c => c.Id).ToList();

        var ratings = await _db.CafeReviews
            .Where(r => cafeIds.Contains(r.CafeId) && !r.IsHidden)
            .GroupBy(r => r.CafeId)
            .Select(g => new { CafeId = g.Key, Avg = g.Average(x => x.Rating), Count = g.Count() })
            .ToListAsync(cancellationToken);

        var ratingMap = ratings.ToDictionary(x => x.CafeId, x => (x.Avg, x.Count));

        // Determine whether this is a free-text NLP search or a pure chip-filter search
        bool hasTextQuery = !string.IsNullOrWhiteSpace(filters.Q);

        var result = cafes
            .Select(c =>
            {
                ratingMap.TryGetValue(c.Id, out var r);
                var count = r.Count;
                var avg = count > 0 ? r.Avg : 0.0;
                var profile = CafeDiscoverProfileSerializer.Deserialize(c.DiscoverProfileJson);
                var hours   = DeserializeHours(c.WorkingHoursJson);
                var gallery = DeserializeGallery(c.GalleryJson);
                var badges  = MapBadges(c);

                // openNow filter — skip cafes that are provably closed
                if (filters.OpenNow && hours is not null && !hours.IsOpenNow())
                    return default;

                double score;
                if (hasTextQuery)
                {
                    // Soft scoring: partial matches surface instead of being hidden
                    score = DiscoverProfileMatcher.Score(profile, filters);
                    if (filters.RequireProfile
                        && !DiscoverProfileMatcher.HasMeaningfulProfile(profile)
                        && score < DiscoverProfileMatcher.MinScoreThreshold)
                        return default;
                    if (score < DiscoverProfileMatcher.MinScoreThreshold)
                        return default;
                }
                else
                {
                    // Hard AND match for chip-only searches (backward compatible)
                    if (!DiscoverProfileMatcher.Matches(profile, filters))
                        return default;
                    score = DiscoverProfileMatcher.Score(profile, filters);
                }

                bool isOpenNow = hours?.IsOpenNow() ?? false;

                var dto = new CafeDiscoverDto(
                    c.Id,
                    c.Name,
                    c.Slug,
                    c.City,
                    c.Address,
                    c.LogoUrl,
                    c.CoverImageUrl,
                    c.IsVerified,
                    Math.Round(avg, 1),
                    count,
                    CafeDiscoverProfileMapping.ToDto(profile),
                    badges,
                    gallery,
                    isOpenNow,
                    c.InstagramHandle,
                    c.WebsiteUrl,
                    score);
                return (dto, score, (object?)dto);
            })
            .Where(x => x.Item3 is not null)
            .Select(x => x.dto)
            .ToList();

        if (filters.MinRating.HasValue)
            result = result.Where(c => c.AverageRating >= filters.MinRating.Value).ToList();

        result = (filters.Sort?.ToLowerInvariant()) switch
        {
            "rating"   => result.OrderByDescending(c => c.AverageRating).ThenByDescending(c => c.ReviewCount).ToList(),
            "reviews"  => result.OrderByDescending(c => c.ReviewCount).ToList(),
            "score"    => result.OrderByDescending(c => c.RelevanceScore).ThenByDescending(c => c.AverageRating).ToList(),
            _          => hasTextQuery
                            ? result.OrderByDescending(c => c.RelevanceScore).ThenByDescending(c => c.AverageRating).ToList()
                            : result.OrderBy(c => c.Name).ToList()
        };

        return result;
    }

    public async Task<IReadOnlyList<CafeReviewDto>> GetReviewsAsync(
        string cafeId,
        int page,
        int pageSize,
        bool publicOnly = true,
        CancellationToken cancellationToken = default)
    {
        page = Math.Max(1, page);
        pageSize = Math.Clamp(pageSize, 1, 50);

        var reviews = await _db.CafeReviews
            .Include(r => r.Photos)
            .Where(r => r.CafeId == cafeId && (!publicOnly || !r.IsHidden))
            .OrderByDescending(r => r.CreatedAt)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync(cancellationToken);

        return reviews.Select(r => ToDto(r, publicView: true)).ToList();
    }

    public async Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewAsync(
        string cafeId,
        CreateCafeReviewRequest request,
        CancellationToken cancellationToken = default)
    {
        var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId && c.IsVerified, cancellationToken);
        if (cafe is null) return (null, "NOT_FOUND", "Cafe not found.");

        var ctx = _http.HttpContext;
        if (ctx is not null)
        {
            var availability = PublicCafeGuard.EnsureAcceptingPublicTraffic(cafe);
            if (!availability.Ok) return (null, availability.ErrorCode, availability.Message);

            var ip = ClientIpResolver.GetClientIp(ctx);
            var writeCheck = await _abuse.CheckPublicWriteByIpAsync(ip, cancellationToken);
            if (!writeCheck.Allowed) return (null, writeCheck.ErrorCode, writeCheck.Message);

            var captcha = await _abuse.VerifyCaptchaAsync(request.CaptchaToken, cancellationToken);
            if (!captcha.Ok) return (null, captcha.ErrorCode, captcha.Message);
        }

        var entity = new CafeReview
        {
            CafeId = cafeId,
            AuthorName = request.AuthorName.Trim(),
            AuthorPhone = request.AuthorPhone?.Trim(),
            Rating = request.Rating,
            Comment = request.Comment?.Trim()
        };

        _db.CafeReviews.Add(entity);
        await _db.SaveChangesAsync(cancellationToken);
        return (ToDto(entity, publicView: true), null, null);
    }

    public async Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewWithPhotosAsync(
        string cafeId,
        CreateCafeReviewRequest request,
        IReadOnlyList<IFormFile> photos,
        CancellationToken cancellationToken = default)
    {
        var baseResult = await CreateReviewAsync(cafeId, request, cancellationToken);
        if (baseResult.Data is null)
            return baseResult;

        var files = photos?.Where(f => f.Length > 0).Take(MaxReviewPhotos).ToList() ?? [];
        if (files.Count == 0)
            return baseResult;

        var review = await _db.CafeReviews
            .Include(r => r.Photos)
            .FirstAsync(r => r.Id == baseResult.Data.Id, cancellationToken);

        var sort = 0;
        foreach (var file in files)
        {
            var url = await _media.SaveReviewPhotoAsync(cafeId, file, cancellationToken);
            if (url is null) continue;
            review.Photos.Add(new CafeReviewPhoto
            {
                ReviewId = review.Id,
                Url = url,
                SortOrder = sort++
            });
        }

        await _db.SaveChangesAsync(cancellationToken);
        return (ToDto(review, publicView: true), null, null);
    }

    public async Task<CafeReviewDto?> ReplyReviewAsync(
        string cafeId,
        string reviewId,
        string reply,
        CancellationToken cancellationToken = default)
    {
        var entity = await _db.CafeReviews
            .FirstOrDefaultAsync(r => r.Id == reviewId && r.CafeId == cafeId, cancellationToken);
        if (entity is null) return null;

        entity.OwnerReply = reply.Trim();
        entity.OwnerRepliedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync(cancellationToken);
        return ToDto(entity, publicView: false);
    }

    public async Task<CafeReviewDto?> SetHiddenAsync(
        string cafeId,
        string reviewId,
        bool isHidden,
        CancellationToken cancellationToken = default)
    {
        var entity = await _db.CafeReviews
            .Include(r => r.Photos)
            .FirstOrDefaultAsync(r => r.Id == reviewId && r.CafeId == cafeId, cancellationToken);
        if (entity is null) return null;

        entity.IsHidden = isHidden;
        await _db.SaveChangesAsync(cancellationToken);
        return ToDto(entity, publicView: false);
    }

    public async Task<(double Average, int Count)> GetRatingSummaryAsync(string cafeId, CancellationToken cancellationToken = default)
    {
        var reviews = await _db.CafeReviews
            .Where(r => r.CafeId == cafeId && !r.IsHidden)
            .ToListAsync(cancellationToken);
        if (reviews.Count == 0) return (0, 0);
        return (Math.Round(reviews.Average(r => r.Rating), 1), reviews.Count);
    }

    private static IReadOnlyList<CafeBadgePublicDto> MapBadges(Cafe c) =>
        DiscoverBadgeMapping.ToDtos(c)
            .Select(b => new CafeBadgePublicDto(b.Key, b.Label, b.Icon))
            .ToList();

    private static readonly JsonSerializerOptions _jsonOpts = new()
    {
        PropertyNameCaseInsensitive = true,
        PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
    };

    private static WorkingHoursSchedule? DeserializeHours(string? json)
    {
        if (string.IsNullOrWhiteSpace(json)) return null;
        try { return JsonSerializer.Deserialize<WorkingHoursSchedule>(json, _jsonOpts); }
        catch { return null; }
    }

    private static IReadOnlyList<string> DeserializeGallery(string? json)
    {
        if (string.IsNullOrWhiteSpace(json)) return [];
        try
        {
            return JsonSerializer.Deserialize<List<string>>(json, _jsonOpts) ?? [];
        }
        catch { return []; }
    }

    private static CafeReviewDto ToDto(CafeReview r, bool publicView) => new(
        r.Id,
        r.AuthorName,
        r.Rating,
        r.Comment,
        r.OwnerReply,
        r.CreatedAt,
        r.Photos.OrderBy(p => p.SortOrder).Select(p => p.Url).ToList(),
        publicView ? false : r.IsHidden);
}
