#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Baya.Domain.Entities.Payouts;
using Mediator;
using Microsoft.EntityFrameworkCore;

namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;

/// <summary>
/// Builds a weekly payout batch. Under <c>lock(payout:batch)</c> (so two runs can't grab the same bookings), it
/// holiday-shifts the period end + processing date, selects the eligible unpaid bookings, groups them per nurse,
/// and materializes payouts + booking links in one unit of work. The <b>BuildNursePayouts</b> and
/// <b>LinkPayoutBookings</b> steps from the phase are cohesive private steps here (mirroring b11's
/// <c>CreateRefund</c>). Netting caps clawbacks at whole recoverable amounts; a nurse without a verified primary
/// IBAN is skipped with a recorded reason, never silently dropped. The <c>booking_id</c> UNIQUE link is the
/// backstop that makes a re-run over an overlapping window unable to re-select an already-paid booking.
/// </summary>
internal sealed class GeneratePayoutBatchCommandHandler(
    IUnitOfWork unitOfWork,
    IDistributedLock distributedLock,
    IHolidayCalendar holidays,
    IPlatformConfig platformConfig,
    IDateTimeProvider dateTimeProvider,
    ICurrentUser currentUser)
    : IRequestHandler<GeneratePayoutBatchCommand, OperationResult<GeneratePayoutBatchResult>>
{
    public async ValueTask<OperationResult<GeneratePayoutBatchResult>> Handle(
        GeneratePayoutBatchCommand request, CancellationToken cancellationToken)
    {
        // A scheduled (system-initiated) run has no human initiator; the HTTP path still requires an authenticated
        // admin and records their id. SystemInitiated can only be set in-process by the scheduler.
        int? initiatedByAdminId;
        if (request.SystemInitiated)
            initiatedByAdminId = null;
        else if (currentUser.UserId is { } adminId)
            initiatedByAdminId = adminId;
        else
            return OperationResult<GeneratePayoutBatchResult>.UnauthorizedResult("Not authenticated.");

        var now = dateTimeProvider.UtcNow.UtcDateTime;

        await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken);

        // Holiday-aware shifting: a batch landing on a bank-closed Nowruz day must move to the next business day,
        // or PAYA/SATNA fails.
        var periodEnd = await holidays.NextBusinessDay(request.PeriodEnd, cancellationToken);
        var processingDate = await holidays.NextBusinessDay(DateOnly.FromDateTime(now), cancellationToken);
        var requireBnplSettlement = await platformConfig.GetConfig<bool>("require_bnpl_settlement_for_payout", cancellationToken);

        var eligible = await unitOfWork.PayoutRepository.GetEligibleBookingsAsync(
            request.PeriodStart, periodEnd, now, requireBnplSettlement, cancellationToken);
        if (eligible.Count == 0)
            return OperationResult<GeneratePayoutBatchResult>.FailureResult(
                "period", "No payout-eligible bookings in this window.");

        var batch = new NursePayoutBatch
        {
            PeriodStart = request.PeriodStart,
            PeriodEnd = periodEnd,
            ProcessingDate = processingDate,
            InitiatedByAdminId = initiatedByAdminId
        };

        var nurseIds = eligible.Select(e => e.NurseId).Distinct().ToList();
        var names = await unitOfWork.PayoutRepository.GetNurseNamesAsync(nurseIds, cancellationToken);

        var skipped = new List<SkippedNurseDto>();
        long batchTotal = 0;
        var payoutCount = 0;

        foreach (var group in eligible.GroupBy(e => e.NurseId).OrderBy(g => g.Key))
        {
            var nurseId = group.Key;
            var bookings = group.ToList();
            var gross = bookings.Sum(b => b.PayoutAmountIrr);

            // First-payout gate: only a verified primary IBAN may receive a transfer. No account → skip, recorded.
            var account = await unitOfWork.PayoutRepository.GetVerifiedPrimaryAccountAsync(nurseId, cancellationToken);
            if (account is null)
            {
                skipped.Add(new SkippedNurseDto(
                    nurseId, names.GetValueOrDefault(nurseId), gross.ToString(), "no_verified_primary_iban"));
                continue;
            }

            var clawbackApplied = await ComputeNettableClawbackAsync(nurseId, gross, cancellationToken);
            var net = gross - clawbackApplied;

            var payout = new NursePayout
            {
                NurseId = nurseId,
                BankAccountId = account.BankAccountId,
                IbanSnapshot = account.Iban, // encrypted at rest by the EF converter on save
                GrossEarningsIrr = gross,
                ClawbackAppliedIrr = clawbackApplied,
                NetAmountIrr = net,
                Amount = net,
                BookingCount = bookings.Count
            };

            foreach (var b in bookings)
                payout.BookingLinks.Add(new NursePayoutBookingLink
                {
                    BookingId = b.BookingId,
                    PayoutAmountIrr = b.PayoutAmountIrr
                });

            batch.Payouts.Add(payout);
            batchTotal += net;
            payoutCount++;
        }

        if (payoutCount == 0)
            return OperationResult<GeneratePayoutBatchResult>.FailureResult(
                "nurse", "No eligible nurse has a verified primary IBAN to be paid.");

        // total_amount = Σ net_amount_irr; payout_count = COUNT(payouts) — the batch invariant, frozen here.
        batch.SetTotals(batchTotal, payoutCount);

        await unitOfWork.PayoutRepository.AddBatchAsync(batch, cancellationToken);

        try
        {
            await unitOfWork.CommitAsync();
        }
        catch (DbUpdateException)
        {
            // The booking_id UNIQUE backstop: a booking was linked by a concurrent run despite the lock. Never
            // double-pay — surface a conflict rather than aborting into a half-written batch.
            await unitOfWork.RollBackAsync();
            return OperationResult<GeneratePayoutBatchResult>.ConflictResult(
                "Another payout run already claimed one of these bookings.");
        }

        var detail = await unitOfWork.PayoutRepository.GetBatchDetailAsync(
            batch.Id, page: 1, pageSize: Math.Max(payoutCount, 1), cancellationToken);

        return OperationResult<GeneratePayoutBatchResult>.SuccessResult(
            new GeneratePayoutBatchResult(detail!.Batch, detail.Payouts, skipped));
    }

    /// <summary>
    /// The clawback netting: recovers whole <c>pending</c> clawbacks (oldest first) that fit within the nurse's
    /// earnings this batch — never a negative net, never a partial recovery of a single clawback row. A clawback
    /// larger than this batch's earnings stays fully <c>pending</c> and recovers from a later, larger batch.
    /// </summary>
    private async Task<long> ComputeNettableClawbackAsync(long nurseId, long gross, CancellationToken cancellationToken)
    {
        var pending = await unitOfWork.PayoutRepository.GetPendingClawbacksAsync(nurseId, cancellationToken);

        long applied = 0;
        foreach (var clawback in pending)
        {
            if (applied + clawback.AmountIrr > gross)
                break;
            applied += clawback.AmountIrr;
        }

        return applied;
    }
}
