using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Finance.Entities;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Domain.Orders.Enums;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;

namespace TakeoutSaaS.Infrastructure.App.Repositories;

/// <summary>
/// 经营报表 EF Core 仓储实现。
/// </summary>
public sealed class EfFinanceBusinessReportRepository(
    TakeoutAppDbContext context,
    ITenantContextAccessor tenantContextAccessor) : IFinanceBusinessReportRepository
{
    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
    private static readonly FinanceCostCategory[] CostCategoryOrder =
    [
        FinanceCostCategory.FoodMaterial,
        FinanceCostCategory.Labor,
        FinanceCostCategory.FixedExpense,
        FinanceCostCategory.PackagingConsumable
    ];
    private static readonly DeliveryType[] IncomeChannelOrder =
    [
        DeliveryType.Delivery,
        DeliveryType.Pickup,
        DeliveryType.DineIn
    ];
    private static readonly IReadOnlyDictionary<FinanceCostCategory, (FinanceCostCalcMode Mode, decimal Ratio, decimal Fixed)> DefaultCostProfileMap =
        new Dictionary<FinanceCostCategory, (FinanceCostCalcMode, decimal, decimal)>
        {
            [FinanceCostCategory.FoodMaterial] = (FinanceCostCalcMode.Ratio, 0.36m, 0m),
            [FinanceCostCategory.Labor] = (FinanceCostCalcMode.Ratio, 0.19m, 0m),
            [FinanceCostCategory.FixedExpense] = (FinanceCostCalcMode.FixedDaily, 0m, 190m),
            [FinanceCostCategory.PackagingConsumable] = (FinanceCostCalcMode.Ratio, 0.04m, 0m)
        };

    /// <inheritdoc />
    public async Task EnsureDefaultCostProfilesAsync(long tenantId, long storeId, CancellationToken cancellationToken = default)
    {
        if (tenantId <= 0 || storeId <= 0)
        {
            return;
        }

        var existing = await context.FinanceCostProfiles
            .AsNoTracking()
            .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null)
            .Select(item => item.Category)
            .Distinct()
            .ToListAsync(cancellationToken);
        var missing = CostCategoryOrder.Where(item => !existing.Contains(item)).ToList();
        if (missing.Count == 0)
        {
            return;
        }

        var effectiveFrom = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        var entities = missing.Select((category, index) =>
        {
            var profile = DefaultCostProfileMap[category];
            return new FinanceCostProfile
            {
                TenantId = tenantId,
                StoreId = storeId,
                Category = category,
                CalcMode = profile.Mode,
                Ratio = profile.Ratio,
                FixedDailyAmount = profile.Fixed,
                EffectiveFrom = effectiveFrom,
                IsEnabled = true,
                SortOrder = (index + 1) * 10
            };
        }).ToList();

        await context.FinanceCostProfiles.AddRangeAsync(entities, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task QueueSnapshotsForPageAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, CancellationToken cancellationToken = default)
    {
        if (tenantId <= 0 || storeId <= 0)
        {
            return;
        }

        var now = DateTime.UtcNow;
        var periods = BuildPagedPeriods(periodType, page, pageSize, now);
        var starts = periods.Select(item => item.StartAt).ToHashSet();
        var existing = await context.FinanceBusinessReportSnapshots
            .Where(item =>
                item.TenantId == tenantId &&
                item.StoreId == storeId &&
                item.PeriodType == periodType &&
                item.DeletedAt == null &&
                starts.Contains(item.PeriodStartAt))
            .OrderByDescending(item => item.Id)
            .ToListAsync(cancellationToken);
        var map = existing.GroupBy(item => item.PeriodStartAt).ToDictionary(group => group.Key, group => group.First());

        var changed = false;
        foreach (var period in periods)
        {
            if (!map.TryGetValue(period.StartAt, out var snapshot))
            {
                await context.FinanceBusinessReportSnapshots.AddAsync(new FinanceBusinessReportSnapshot
                {
                    TenantId = tenantId,
                    StoreId = storeId,
                    PeriodType = periodType,
                    PeriodStartAt = period.StartAt,
                    PeriodEndAt = period.EndAt,
                    Status = FinanceBusinessReportStatus.Queued
                }, cancellationToken);
                changed = true;
                continue;
            }

            if (snapshot.PeriodEndAt != period.EndAt)
            {
                snapshot.PeriodEndAt = period.EndAt;
                changed = true;
            }

            if (snapshot.Status == FinanceBusinessReportStatus.Failed && snapshot.RetryCount < 5)
            {
                snapshot.Status = FinanceBusinessReportStatus.Queued;
                snapshot.LastError = null;
                changed = true;
            }

            if (now >= period.StartAt
                && now < period.EndAt
                && snapshot.Status == FinanceBusinessReportStatus.Succeeded
                && (!snapshot.FinishedAt.HasValue || snapshot.FinishedAt.Value.AddMinutes(30) <= now))
            {
                snapshot.Status = FinanceBusinessReportStatus.Queued;
                snapshot.StartedAt = null;
                snapshot.FinishedAt = null;
                snapshot.LastError = null;
                changed = true;
            }
        }

        if (changed)
        {
            await context.SaveChangesAsync(cancellationToken);
        }
    }

    /// <inheritdoc />
    public async Task<FinanceBusinessReportPageSnapshot> SearchPageAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, CancellationToken cancellationToken = default)
    {
        var normalizedPage = Math.Max(1, page);
        var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
        var query = context.FinanceBusinessReportSnapshots
            .AsNoTracking()
            .Where(item =>
                item.TenantId == tenantId &&
                item.StoreId == storeId &&
                item.PeriodType == periodType &&
                item.DeletedAt == null);

        var totalCount = await query.CountAsync(cancellationToken);
        if (totalCount == 0)
        {
            return new FinanceBusinessReportPageSnapshot();
        }

        var items = await query
            .OrderByDescending(item => item.PeriodStartAt)
            .ThenByDescending(item => item.Id)
            .Skip((normalizedPage - 1) * normalizedPageSize)
            .Take(normalizedPageSize)
            .Select(item => new FinanceBusinessReportListItemSnapshot
            {
                ReportId = item.Id,
                PeriodType = item.PeriodType,
                PeriodStartAt = item.PeriodStartAt,
                PeriodEndAt = item.PeriodEndAt,
                Status = item.Status,
                RevenueAmount = item.RevenueAmount,
                OrderCount = item.OrderCount,
                AverageOrderValue = item.AverageOrderValue,
                RefundRate = item.RefundRate,
                CostTotalAmount = item.CostTotalAmount,
                NetProfitAmount = item.NetProfitAmount,
                ProfitRate = item.ProfitRate
            })
            .ToListAsync(cancellationToken);

        return new FinanceBusinessReportPageSnapshot
        {
            Items = items,
            TotalCount = totalCount
        };
    }

    /// <inheritdoc />
    public async Task<FinanceBusinessReportDetailSnapshot?> GetDetailAsync(long tenantId, long storeId, long reportId, bool allowRealtimeBuild, CancellationToken cancellationToken = default)
    {
        var snapshot = await context.FinanceBusinessReportSnapshots
            .AsNoTracking()
            .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == reportId && item.DeletedAt == null, cancellationToken);
        if (snapshot is null)
        {
            return null;
        }

        if (allowRealtimeBuild && snapshot.Status != FinanceBusinessReportStatus.Succeeded)
        {
            await GenerateSnapshotAsync(reportId, cancellationToken);
            snapshot = await context.FinanceBusinessReportSnapshots
                .AsNoTracking()
                .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == reportId && item.DeletedAt == null, cancellationToken);
            if (snapshot is null)
            {
                return null;
            }
        }

        return new FinanceBusinessReportDetailSnapshot
        {
            ReportId = snapshot.Id,
            StoreId = snapshot.StoreId,
            PeriodType = snapshot.PeriodType,
            PeriodStartAt = snapshot.PeriodStartAt,
            PeriodEndAt = snapshot.PeriodEndAt,
            Status = snapshot.Status,
            RevenueAmount = snapshot.RevenueAmount,
            OrderCount = snapshot.OrderCount,
            AverageOrderValue = snapshot.AverageOrderValue,
            RefundRate = snapshot.RefundRate,
            CostTotalAmount = snapshot.CostTotalAmount,
            NetProfitAmount = snapshot.NetProfitAmount,
            ProfitRate = snapshot.ProfitRate,
            Kpis = Deserialize<FinanceBusinessReportKpiSnapshot>(snapshot.KpiComparisonJson),
            IncomeBreakdowns = Deserialize<FinanceBusinessReportBreakdownSnapshot>(snapshot.IncomeBreakdownJson),
            CostBreakdowns = Deserialize<FinanceBusinessReportBreakdownSnapshot>(snapshot.CostBreakdownJson)
        };
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<FinanceBusinessReportDetailSnapshot>> ListBatchDetailsAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, bool allowRealtimeBuild, CancellationToken cancellationToken = default)
    {
        var normalizedPage = Math.Max(1, page);
        var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
        var reportIds = await context.FinanceBusinessReportSnapshots
            .AsNoTracking()
            .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.PeriodType == periodType && item.DeletedAt == null)
            .OrderByDescending(item => item.PeriodStartAt)
            .ThenByDescending(item => item.Id)
            .Skip((normalizedPage - 1) * normalizedPageSize)
            .Take(normalizedPageSize)
            .Select(item => item.Id)
            .ToListAsync(cancellationToken);

        var list = new List<FinanceBusinessReportDetailSnapshot>(reportIds.Count);
        foreach (var reportId in reportIds)
        {
            var detail = await GetDetailAsync(tenantId, storeId, reportId, allowRealtimeBuild, cancellationToken);
            if (detail is not null)
            {
                list.Add(detail);
            }
        }

        return list;
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<FinanceBusinessReportPendingSnapshot>> GetPendingSnapshotsAsync(int take, CancellationToken cancellationToken = default)
    {
        var normalizedTake = Math.Clamp(take, 1, 200);
        if ((tenantContextAccessor.Current?.TenantId ?? 0) != 0)
        {
            return await QueryPendingAsync(normalizedTake, cancellationToken);
        }

        var tenantIds = await context.Tenants.AsNoTracking().Where(item => item.DeletedAt == null && item.Id > 0).Select(item => item.Id).ToListAsync(cancellationToken);
        var pending = new List<(long SnapshotId, long TenantId, DateTime CreatedAt)>();
        foreach (var tenantId in tenantIds)
        {
            using (tenantContextAccessor.EnterTenantScope(tenantId, "finance-report"))
            {
                var rows = await context.FinanceBusinessReportSnapshots
                    .AsNoTracking()
                    .Where(item =>
                        item.DeletedAt == null &&
                        (item.Status == FinanceBusinessReportStatus.Queued || (item.Status == FinanceBusinessReportStatus.Failed && item.RetryCount < 3)))
                    .OrderBy(item => item.CreatedAt)
                    .ThenBy(item => item.Id)
                    .Take(normalizedTake)
                    .Select(item => new { item.Id, item.TenantId, item.CreatedAt })
                    .ToListAsync(cancellationToken);
                pending.AddRange(rows.Select(item => (item.Id, item.TenantId, item.CreatedAt)));
            }
        }

        return pending.OrderBy(item => item.CreatedAt).ThenBy(item => item.SnapshotId).Take(normalizedTake).Select(item => new FinanceBusinessReportPendingSnapshot { SnapshotId = item.SnapshotId, TenantId = item.TenantId }).ToList();
    }

    /// <inheritdoc />
    public async Task GenerateSnapshotAsync(long snapshotId, CancellationToken cancellationToken = default)
    {
        var snapshot = await context.FinanceBusinessReportSnapshots.FirstOrDefaultAsync(item => item.Id == snapshotId && item.DeletedAt == null, cancellationToken);
        if (snapshot is null)
        {
            return;
        }

        if (snapshot.Status == FinanceBusinessReportStatus.Running
            && snapshot.StartedAt.HasValue
            && snapshot.StartedAt.Value.AddMinutes(10) > DateTime.UtcNow)
        {
            return;
        }

        snapshot.Status = FinanceBusinessReportStatus.Running;
        snapshot.StartedAt = DateTime.UtcNow;
        snapshot.LastError = null;
        await context.SaveChangesAsync(cancellationToken);

        try
        {
            await EnsureDefaultCostProfilesAsync(snapshot.TenantId, snapshot.StoreId, cancellationToken);
            var report = await BuildComputedSnapshotAsync(snapshot.TenantId, snapshot.StoreId, snapshot.PeriodType, snapshot.PeriodStartAt, snapshot.PeriodEndAt, cancellationToken);
            snapshot.RevenueAmount = report.RevenueAmount;
            snapshot.OrderCount = report.OrderCount;
            snapshot.AverageOrderValue = report.AverageOrderValue;
            snapshot.RefundRate = report.RefundRate;
            snapshot.CostTotalAmount = report.CostTotalAmount;
            snapshot.NetProfitAmount = report.NetProfitAmount;
            snapshot.ProfitRate = report.ProfitRate;
            snapshot.KpiComparisonJson = JsonSerializer.Serialize(report.Kpis, JsonOptions);
            snapshot.IncomeBreakdownJson = JsonSerializer.Serialize(report.IncomeBreakdowns, JsonOptions);
            snapshot.CostBreakdownJson = JsonSerializer.Serialize(report.CostBreakdowns, JsonOptions);
            snapshot.Status = FinanceBusinessReportStatus.Succeeded;
            snapshot.FinishedAt = DateTime.UtcNow;
            snapshot.LastError = null;
        }
        catch (Exception ex)
        {
            snapshot.Status = FinanceBusinessReportStatus.Failed;
            snapshot.FinishedAt = DateTime.UtcNow;
            snapshot.RetryCount += 1;
            snapshot.LastError = ex.Message[..Math.Min(1024, ex.Message.Length)];
        }

        await context.SaveChangesAsync(cancellationToken);
    }

    private async Task<IReadOnlyList<FinanceBusinessReportPendingSnapshot>> QueryPendingAsync(int take, CancellationToken cancellationToken)
    {
        return await context.FinanceBusinessReportSnapshots
            .AsNoTracking()
            .Where(item =>
                item.DeletedAt == null &&
                (item.Status == FinanceBusinessReportStatus.Queued
                    || (item.Status == FinanceBusinessReportStatus.Failed && item.RetryCount < 3)))
            .OrderBy(item => item.CreatedAt)
            .ThenBy(item => item.Id)
            .Take(take)
            .Select(item => new FinanceBusinessReportPendingSnapshot
            {
                SnapshotId = item.Id,
                TenantId = item.TenantId
            })
            .ToListAsync(cancellationToken);
    }

    private async Task<ComputedReportSnapshot> BuildComputedSnapshotAsync(
        long tenantId,
        long storeId,
        FinanceBusinessReportPeriodType periodType,
        DateTime startAt,
        DateTime endAt,
        CancellationToken cancellationToken)
    {
        var current = await BuildRawMetricsAsync(tenantId, storeId, startAt, endAt, cancellationToken);
        var previous = ResolvePreviousPeriod(periodType, startAt, endAt);
        var yearAgo = (startAt.AddYears(-1), endAt.AddYears(-1));
        var mom = await BuildRawMetricsAsync(tenantId, storeId, previous.StartAt, previous.EndAt, cancellationToken);
        var yoy = await BuildRawMetricsAsync(tenantId, storeId, yearAgo.Item1, yearAgo.Item2, cancellationToken);

        return current with
        {
            Kpis = BuildKpis(current, mom, yoy)
        };
    }

    private async Task<ComputedReportSnapshot> BuildRawMetricsAsync(
        long tenantId,
        long storeId,
        DateTime startAt,
        DateTime endAt,
        CancellationToken cancellationToken)
    {
        var summary = await QueryRevenueSummaryAsync(tenantId, storeId, startAt, endAt, cancellationToken);
        var averageOrderValue = summary.OrderCount <= 0 ? 0m : RoundMoney(summary.RevenueAmount / summary.OrderCount);
        var refundRate = summary.OrderCount <= 0 ? 0m : RoundRatio((decimal)summary.RefundOrderCount / summary.OrderCount);
        var incomeBreakdowns = await QueryIncomeBreakdownsAsync(tenantId, storeId, startAt, endAt, summary.RevenueAmount, cancellationToken);
        var dailyRevenueMap = await QueryDailyRevenueMapAsync(tenantId, storeId, startAt, endAt, cancellationToken);
        var costBreakdowns = await BuildCostBreakdownsAsync(tenantId, storeId, startAt, endAt, dailyRevenueMap, cancellationToken);
        var costTotalAmount = RoundMoney(costBreakdowns.Sum(item => item.Amount));
        var netProfitAmount = RoundMoney(summary.RevenueAmount - costTotalAmount);
        var profitRate = summary.RevenueAmount <= 0 ? 0m : RoundRatio(netProfitAmount / summary.RevenueAmount);

        return new ComputedReportSnapshot
        {
            RevenueAmount = summary.RevenueAmount,
            OrderCount = summary.OrderCount,
            AverageOrderValue = averageOrderValue,
            RefundRate = refundRate,
            CostTotalAmount = costTotalAmount,
            NetProfitAmount = netProfitAmount,
            ProfitRate = profitRate,
            Kpis = [],
            IncomeBreakdowns = incomeBreakdowns,
            CostBreakdowns = costBreakdowns
        };
    }

    private async Task<(decimal RevenueAmount, int OrderCount, int RefundOrderCount)> QueryRevenueSummaryAsync(
        long tenantId,
        long storeId,
        DateTime startAt,
        DateTime endAt,
        CancellationToken cancellationToken)
    {
        var paidBaseQuery =
            from payment in context.PaymentRecords.AsNoTracking()
            join order in context.Orders.AsNoTracking()
                on payment.OrderId equals order.Id
            where payment.TenantId == tenantId
                  && order.TenantId == tenantId
                  && order.StoreId == storeId
                  && payment.Status == PaymentStatus.Paid
                  && (payment.PaidAt ?? payment.CreatedAt) >= startAt
                  && (payment.PaidAt ?? payment.CreatedAt) < endAt
            select new { payment.Amount, payment.OrderId };

        var paidAmount = await paidBaseQuery.Select(item => item.Amount).DefaultIfEmpty(0m).SumAsync(cancellationToken);
        var orderCount = await paidBaseQuery.Select(item => item.OrderId).Distinct().CountAsync(cancellationToken);

        var refundBaseQuery =
            from refund in context.PaymentRefundRecords.AsNoTracking()
            join order in context.Orders.AsNoTracking()
                on refund.OrderId equals order.Id
            where refund.TenantId == tenantId
                  && order.TenantId == tenantId
                  && order.StoreId == storeId
                  && refund.Status == PaymentRefundStatus.Succeeded
                  && (refund.CompletedAt ?? refund.RequestedAt) >= startAt
                  && (refund.CompletedAt ?? refund.RequestedAt) < endAt
            select new { refund.Amount, refund.OrderId };

        var refundAmount = await refundBaseQuery.Select(item => item.Amount).DefaultIfEmpty(0m).SumAsync(cancellationToken);
        var refundOrderCount = await refundBaseQuery.Select(item => item.OrderId).Distinct().CountAsync(cancellationToken);
        return (RoundMoney(paidAmount - refundAmount), orderCount, refundOrderCount);
    }

    private async Task<IReadOnlyDictionary<DateTime, decimal>> QueryDailyRevenueMapAsync(
        long tenantId,
        long storeId,
        DateTime startAt,
        DateTime endAt,
        CancellationToken cancellationToken)
    {
        var paidRows = await (
            from payment in context.PaymentRecords.AsNoTracking()
            join order in context.Orders.AsNoTracking()
                on payment.OrderId equals order.Id
            where payment.TenantId == tenantId
                  && order.TenantId == tenantId
                  && order.StoreId == storeId
                  && payment.Status == PaymentStatus.Paid
                  && (payment.PaidAt ?? payment.CreatedAt) >= startAt
                  && (payment.PaidAt ?? payment.CreatedAt) < endAt
            group payment by (payment.PaidAt ?? payment.CreatedAt).Date into grouped
            select new { BusinessDate = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
            .ToListAsync(cancellationToken);

        var refundRows = await (
            from refund in context.PaymentRefundRecords.AsNoTracking()
            join order in context.Orders.AsNoTracking()
                on refund.OrderId equals order.Id
            where refund.TenantId == tenantId
                  && order.TenantId == tenantId
                  && order.StoreId == storeId
                  && refund.Status == PaymentRefundStatus.Succeeded
                  && (refund.CompletedAt ?? refund.RequestedAt) >= startAt
                  && (refund.CompletedAt ?? refund.RequestedAt) < endAt
            group refund by (refund.CompletedAt ?? refund.RequestedAt).Date into grouped
            select new { BusinessDate = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
            .ToListAsync(cancellationToken);

        var map = new Dictionary<DateTime, decimal>();
        foreach (var row in paidRows)
        {
            var date = ToUtcDate(row.BusinessDate);
            map[date] = map.GetValueOrDefault(date, 0m) + row.Amount;
        }

        foreach (var row in refundRows)
        {
            var date = ToUtcDate(row.BusinessDate);
            map[date] = map.GetValueOrDefault(date, 0m) - row.Amount;
        }

        return map.ToDictionary(item => item.Key, item => RoundMoney(item.Value));
    }

    private async Task<List<FinanceBusinessReportBreakdownSnapshot>> QueryIncomeBreakdownsAsync(
        long tenantId,
        long storeId,
        DateTime startAt,
        DateTime endAt,
        decimal totalRevenue,
        CancellationToken cancellationToken)
    {
        var paidRows = await (
            from payment in context.PaymentRecords.AsNoTracking()
            join order in context.Orders.AsNoTracking()
                on payment.OrderId equals order.Id
            where payment.TenantId == tenantId
                  && order.TenantId == tenantId
                  && order.StoreId == storeId
                  && payment.Status == PaymentStatus.Paid
                  && (payment.PaidAt ?? payment.CreatedAt) >= startAt
                  && (payment.PaidAt ?? payment.CreatedAt) < endAt
            group payment by order.DeliveryType into grouped
            select new { DeliveryType = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
            .ToListAsync(cancellationToken);
        var refundRows = await (
            from refund in context.PaymentRefundRecords.AsNoTracking()
            join order in context.Orders.AsNoTracking()
                on refund.OrderId equals order.Id
            where refund.TenantId == tenantId
                  && order.TenantId == tenantId
                  && order.StoreId == storeId
                  && refund.Status == PaymentRefundStatus.Succeeded
                  && (refund.CompletedAt ?? refund.RequestedAt) >= startAt
                  && (refund.CompletedAt ?? refund.RequestedAt) < endAt
            group refund by order.DeliveryType into grouped
            select new { DeliveryType = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
            .ToListAsync(cancellationToken);
        var paidMap = paidRows.ToDictionary(item => item.DeliveryType, item => item.Amount);
        var refundMap = refundRows.ToDictionary(item => item.DeliveryType, item => item.Amount);

        return IncomeChannelOrder.Select(channel =>
        {
            var amount = paidMap.GetValueOrDefault(channel, 0m) - refundMap.GetValueOrDefault(channel, 0m);
            return new FinanceBusinessReportBreakdownSnapshot
            {
                Key = channel switch
                {
                    DeliveryType.Delivery => "delivery",
                    DeliveryType.Pickup => "pickup",
                    DeliveryType.DineIn => "dine_in",
                    _ => "delivery"
                },
                Label = channel switch
                {
                    DeliveryType.Delivery => "外卖",
                    DeliveryType.Pickup => "自提",
                    DeliveryType.DineIn => "堂食",
                    _ => "外卖"
                },
                Amount = RoundMoney(amount),
                Ratio = totalRevenue <= 0 ? 0m : RoundRatio(amount / totalRevenue)
            };
        }).ToList();
    }

    private async Task<List<FinanceBusinessReportBreakdownSnapshot>> BuildCostBreakdownsAsync(
        long tenantId,
        long storeId,
        DateTime startAt,
        DateTime endAt,
        IReadOnlyDictionary<DateTime, decimal> dailyRevenueMap,
        CancellationToken cancellationToken)
    {
        var profiles = await context.FinanceCostProfiles.AsNoTracking()
            .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null && item.IsEnabled)
            .OrderBy(item => item.SortOrder).ThenByDescending(item => item.EffectiveFrom).ToListAsync(cancellationToken);
        var overrides = await context.FinanceCostDailyOverrides.AsNoTracking()
            .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null && item.BusinessDate >= startAt.Date && item.BusinessDate < endAt.Date)
            .ToListAsync(cancellationToken);
        var overrideMap = overrides.ToDictionary(item => $"{item.BusinessDate:yyyyMMdd}:{(int)item.Category}", item => item.Amount);
        var categoryAmountMap = CostCategoryOrder.ToDictionary(item => item, _ => 0m);

        for (var businessDay = startAt.Date; businessDay < endAt.Date; businessDay = businessDay.AddDays(1))
        {
            var dayRevenue = dailyRevenueMap.GetValueOrDefault(ToUtcDate(businessDay), 0m);
            foreach (var category in CostCategoryOrder)
            {
                var key = $"{businessDay:yyyyMMdd}:{(int)category}";
                decimal amount;
                if (overrideMap.TryGetValue(key, out var overrideAmount))
                {
                    amount = overrideAmount;
                }
                else
                {
                    var profile = profiles.FirstOrDefault(item =>
                        item.Category == category &&
                        item.EffectiveFrom.Date <= businessDay &&
                        (!item.EffectiveTo.HasValue || item.EffectiveTo.Value.Date >= businessDay));
                    var defaults = DefaultCostProfileMap[category];
                    var mode = profile?.CalcMode ?? defaults.Mode;
                    var ratio = profile?.Ratio ?? defaults.Ratio;
                    var fixedDaily = profile?.FixedDailyAmount ?? defaults.Fixed;
                    amount = mode == FinanceCostCalcMode.FixedDaily ? fixedDaily : dayRevenue * Math.Max(0m, ratio);
                }

                categoryAmountMap[category] += RoundMoney(amount);
            }
        }

        var totalCostAmount = categoryAmountMap.Sum(item => item.Value);
        return CostCategoryOrder.Select(category => new FinanceBusinessReportBreakdownSnapshot
        {
            Key = category switch
            {
                FinanceCostCategory.FoodMaterial => "food_material",
                FinanceCostCategory.Labor => "labor",
                FinanceCostCategory.FixedExpense => "fixed_expense",
                FinanceCostCategory.PackagingConsumable => "packaging_consumable",
                _ => "food_material"
            },
            Label = category switch
            {
                FinanceCostCategory.FoodMaterial => "食材成本",
                FinanceCostCategory.Labor => "人工成本",
                FinanceCostCategory.FixedExpense => "固定成本",
                FinanceCostCategory.PackagingConsumable => "包装成本",
                _ => "食材成本"
            },
            Amount = RoundMoney(categoryAmountMap[category]),
            Ratio = totalCostAmount <= 0m ? 0m : RoundRatio(categoryAmountMap[category] / totalCostAmount)
        }).ToList();
    }

    private static List<FinanceBusinessReportKpiSnapshot> BuildKpis(ComputedReportSnapshot current, ComputedReportSnapshot mom, ComputedReportSnapshot yoy)
    {
        var definitions = new List<(string Key, string Label, decimal Current, decimal PrevMom, decimal PrevYoy)>
        {
            ("revenue", "营业额", current.RevenueAmount, mom.RevenueAmount, yoy.RevenueAmount),
            ("order_count", "订单数", current.OrderCount, mom.OrderCount, yoy.OrderCount),
            ("average_order_value", "客单价", current.AverageOrderValue, mom.AverageOrderValue, yoy.AverageOrderValue),
            ("refund_rate", "退款率", current.RefundRate, mom.RefundRate, yoy.RefundRate),
            ("net_profit", "净利润", current.NetProfitAmount, mom.NetProfitAmount, yoy.NetProfitAmount),
            ("profit_rate", "利润率", current.ProfitRate, mom.ProfitRate, yoy.ProfitRate)
        };

        return definitions.Select(item => new FinanceBusinessReportKpiSnapshot
        {
            Key = item.Key,
            Label = item.Label,
            Value = item.Current,
            MomChangeRate = CalculateChangeRate(item.Current, item.PrevMom),
            YoyChangeRate = CalculateChangeRate(item.Current, item.PrevYoy)
        }).ToList();
    }

    private static (DateTime StartAt, DateTime EndAt) ResolvePreviousPeriod(FinanceBusinessReportPeriodType periodType, DateTime startAt, DateTime endAt)
    {
        return periodType switch
        {
            FinanceBusinessReportPeriodType.Daily => (startAt.AddDays(-1), endAt.AddDays(-1)),
            FinanceBusinessReportPeriodType.Weekly => (startAt.AddDays(-7), endAt.AddDays(-7)),
            FinanceBusinessReportPeriodType.Monthly => (startAt.AddMonths(-1), endAt.AddMonths(-1)),
            _ => (startAt.AddDays(-1), endAt.AddDays(-1))
        };
    }

    private static List<(DateTime StartAt, DateTime EndAt)> BuildPagedPeriods(FinanceBusinessReportPeriodType periodType, int page, int pageSize, DateTime now)
    {
        var normalizedPage = Math.Max(1, page);
        var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
        var offsetStart = (normalizedPage - 1) * normalizedPageSize;
        var today = ToUtcDate(now);
        var list = new List<(DateTime StartAt, DateTime EndAt)>(normalizedPageSize);
        for (var index = 0; index < normalizedPageSize; index++)
        {
            var offset = offsetStart + index;
            if (periodType == FinanceBusinessReportPeriodType.Weekly)
            {
                var weekStart = GetWeekStart(today).AddDays(-7 * offset);
                list.Add((weekStart, weekStart.AddDays(7)));
            }
            else if (periodType == FinanceBusinessReportPeriodType.Monthly)
            {
                var monthStart = new DateTime(today.Year, today.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-offset);
                list.Add((monthStart, monthStart.AddMonths(1)));
            }
            else
            {
                var dayStart = today.AddDays(-offset);
                list.Add((dayStart, dayStart.AddDays(1)));
            }
        }

        return list;
    }

    private static List<TItem> Deserialize<TItem>(string? json)
    {
        if (string.IsNullOrWhiteSpace(json))
        {
            return [];
        }

        try
        {
            return JsonSerializer.Deserialize<List<TItem>>(json, JsonOptions) ?? [];
        }
        catch
        {
            return [];
        }
    }

    private sealed record ComputedReportSnapshot
    {
        public decimal RevenueAmount { get; init; }

        public int OrderCount { get; init; }

        public decimal AverageOrderValue { get; init; }

        public decimal RefundRate { get; init; }

        public decimal CostTotalAmount { get; init; }

        public decimal NetProfitAmount { get; init; }

        public decimal ProfitRate { get; init; }

        public List<FinanceBusinessReportKpiSnapshot> Kpis { get; init; } = [];

        public List<FinanceBusinessReportBreakdownSnapshot> IncomeBreakdowns { get; init; } = [];

        public List<FinanceBusinessReportBreakdownSnapshot> CostBreakdowns { get; init; } = [];
    }

    private static decimal CalculateChangeRate(decimal currentValue, decimal previousValue) => previousValue <= 0m ? (currentValue <= 0m ? 0m : 100m) : RoundRate((currentValue - previousValue) / previousValue * 100m);
    private static decimal RoundMoney(decimal value) => decimal.Round(value, 2, MidpointRounding.AwayFromZero);
    private static decimal RoundRate(decimal value) => decimal.Round(value, 2, MidpointRounding.AwayFromZero);
    private static decimal RoundRatio(decimal value) => decimal.Round(value, 4, MidpointRounding.AwayFromZero);
    private static DateTime ToUtcDate(DateTime value) => new(value.Year, value.Month, value.Day, 0, 0, 0, DateTimeKind.Utc);
    private static DateTime GetWeekStart(DateTime date) => date.AddDays(0 - (((int)date.DayOfWeek + 6) % 7));
}
