#nullable enable
using System.Text.Json;
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Messaging.Commands.OpenTicket;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Baya.Domain.Entities.SupportAlerts;
using Mediator;

namespace Baya.Application.Features.Refunds.Commands.CreateRefund;

/// <summary>
/// The money-side of a cancellation/dispute refund. Runs the whole reversal under
/// <c>lock(booking:{id}:refund)</c> so a cancellation-driven and a webhook-driven refund can't both fire and
/// breach <c>Σ refunded ≤ captured</c>. It decomposes the refund across both fee legs, forks on whether the
/// nurse was already paid (clean <c>nurse_payable</c> reversal vs a <c>nurse_clawbacks</c> receivable), executes
/// the channel behind its seam, and posts the balanced ledger group(s) via b10's helper. The channel-execution
/// and ledger-posting "internal steps" from the phase are cohesive private steps here (mirroring b10's
/// <c>ConfirmPaymentAndPostLedger</c>) so they stay atomic under one lock.
/// </summary>
internal sealed class CreateRefundCommandHandler(
    IUnitOfWork unitOfWork,
    IDistributedLock distributedLock,
    IPlatformConfig platformConfig,
    IDateTimeProvider dateTimeProvider,
    ICurrentUser currentUser,
    IPaymentProvider paymentProvider,
    IBnplProvider bnplProvider,
    INursePayoutStatus nursePayoutStatus,
    ISupportAlertService supportAlerts,
    INotificationDispatcher notifications,
    ISender sender)
    : IRequestHandler<CreateRefundCommand, OperationResult<CreateRefundResult>>
{
    public async ValueTask<OperationResult<CreateRefundResult>> Handle(CreateRefundCommand request, CancellationToken cancellationToken)
    {
        await using var _ = await distributedLock.AcquireAsync($"booking:{request.BookingId}:refund", cancellationToken);

        var context = await unitOfWork.RefundRepository.GetRefundContextAsync(request.BookingId, cancellationToken);
        if (context is null)
            return OperationResult<CreateRefundResult>.NotFoundResult("No captured payment exists for this booking to refund.");

        // Every admin refund hangs off a ticket (the dispute paper trail). b15 ships the ticket system this link
        // targets: if the caller didn't supply one, open a category=refund ticket now (staff bypass on the booking
        // link), so refunds.ticket_id is always non-null. Only done once the booking's captured payment exists.
        var ticketId = request.TicketId;
        if (ticketId is null)
        {
            var opened = await sender.Send(
                new OpenTicketCommand(TicketCategory.Refund, "Refund", null, request.BookingId, null), cancellationToken);
            if (!opened.IsSuccess)
                return OperationResult<CreateRefundResult>.FailureResult("ticket", "Could not open a refund ticket to anchor this refund.");
            ticketId = opened.Result.TicketId;
        }

        var decomposition = ResolveDecomposition(request, context);
        if (decomposition is null)
            return OperationResult<CreateRefundResult>.FailureResult(
                "refund_percentage", "Provide a refund percentage, explicit legs, or a booking cancellation snapshot.");

        var (platformFeeRefunded, nursePayoutRefunded, resolvedPct) = decomposition.Value;
        var amount = platformFeeRefunded + nursePayoutRefunded;

        if (amount <= 0)
            return OperationResult<CreateRefundResult>.FailureResult("amount", "The refund amount must be positive.");
        if (platformFeeRefunded > context.CommissionIrr || nursePayoutRefunded > context.PayoutIrr)
            return OperationResult<CreateRefundResult>.FailureResult("amount", "A refund leg exceeds the captured booking leg.");

        // Σ refunded ≤ captured — the authoritative backstop, summed under the lock.
        var priorRefunded = await unitOfWork.RefundRepository.GetRefundedSumForTransactionAsync(context.PaymentTransactionId, cancellationToken);
        if (priorRefunded + amount > context.CapturedAmount)
            return OperationResult<CreateRefundResult>.ConflictResult(
                "This refund would push total refunds over the captured amount.");

        var now = dateTimeProvider.UtcNow.UtcDateTime;
        var channel = SelectChannel(request, context.GatewayType);
        var idempotencyKey = $"booking:{request.BookingId}:refund:{context.PaymentTransactionId}:{priorRefunded + amount}";

        var refund = new Refund
        {
            PaymentTransactionId = context.PaymentTransactionId,
            BookingId = context.BookingId,
            RequestedByCustomerId = context.CustomerId,
            TicketId = ticketId,
            Amount = amount,
            PlatformFeeRefundedIrr = platformFeeRefunded,
            NursePayoutRefundedIrr = nursePayoutRefunded,
            RefundPercentage = resolvedPct,
            RefundChannel = channel,
            ReasonCategory = request.ReasonCategory,
            ReasonNotes = request.ReasonNotes,
            AdminNotes = request.AdminNotes,
            ApprovedByAdminId = currentUser.UserId,
            CancellationPolicyCode = context.CancellationPolicyCode,
            RefundPercentageApplied = context.CancellationRefundPercentage
        };

        // Crash-window fix: persist the refund as an approved INTENT (committed) BEFORE the external channel call,
        // so a crash between provider success and our commit leaves a reconcilable record rather than a silently
        // executed refund with no row. This is the same claim-first / execute-second shape the webhook handler uses.
        await unitOfWork.RefundRepository.AddRefundAsync(refund, cancellationToken);
        await unitOfWork.CommitAsync();

        // Execute the channel (external, behind its seam) against the already-persisted row. The idempotency key
        // makes a retried channel call a no-op; a provider refusal marks the row failed with no ledger.
        var executed = await ExecuteChannelAsync(refund, channel, context, request.ManualBankReference, amount, priorRefunded, idempotencyKey, now, cancellationToken);

        if (!executed)
        {
            await unitOfWork.CommitAsync(); // persist the failed transition; no ledger is posted
            return OperationResult<CreateRefundResult>.FailureResult("channel", "The refund channel refused the reversal.");
        }

        // Pre-payout (clean reversal) vs post-payout (clawback receivable) fork — an Iranian IBAN transfer is
        // irreversible, so a paid-out nurse's payout leg becomes owed-back, never silently absorbed.
        var isNursePaid = await nursePayoutStatus.IsNursePaidForBookingAsync(context.BookingId, cancellationToken);
        long? clawbackId = null;

        var reversal = isNursePaid
            ? LedgerPosting.ClawbackReversalPostPayout(context.BookingId, context.NurseId, platformFeeRefunded, nursePayoutRefunded, refund.Id, now)
            : LedgerPosting.RefundReversalPrePayout(context.BookingId, context.NurseId, platformFeeRefunded, nursePayoutRefunded, refund.Id, now);
        await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(reversal, cancellationToken);

        NurseClawback? clawback = null;
        if (isNursePaid && nursePayoutRefunded > 0)
        {
            clawback = new NurseClawback
            {
                NurseId = context.NurseId,
                BookingId = context.BookingId,
                RefundId = refund.Id,
                AmountIrr = nursePayoutRefunded
            };
            await unitOfWork.RefundRepository.AddClawbackAsync(clawback, cancellationToken);
        }

        // The customer cash-back clears refund_payable ↔ escrow_held only once confirmed — immediate for a
        // succeeded card refund; deferred to reconciliation while a BNPL/manual refund sits in processing.
        if (refund.Status == RefundStatus.Succeeded)
        {
            var clearing = LedgerPosting.RefundPayableClearing(context.BookingId, amount, refund.Id, now);
            await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(clearing, cancellationToken);
        }

        await unitOfWork.CommitAsync();

        // Self-committing facades run only after the atomic commit (they flush the shared DbContext).
        if (clawback is not null)
        {
            clawbackId = clawback.Id;
            await supportAlerts.RaiseAsync(
                SupportAlertType.NurseClawback, entityType: "refund", entityId: refund.Id.ToString(),
                severity: SupportAlertSeverity.High, bookingId: context.BookingId, cancellationToken: cancellationToken);
        }

        await NotifyCustomerAsync(context, refund, cancellationToken);

        return OperationResult<CreateRefundResult>.SuccessResult(new CreateRefundResult(
            refund.Id, context.BookingId, refund.Status, channel,
            amount.ToString(), platformFeeRefunded.ToString(), nursePayoutRefunded.ToString(),
            refund.ExpectedCustomerRefundEta, clawbackId));
    }

    private static (long PlatformFee, long NursePayout, decimal Pct)? ResolveDecomposition(CreateRefundCommand request, RefundMoneyContext context)
    {
        // Admin-supplied explicit legs take precedence; they must sum to the amount (validator ensures both set).
        if (request.PlatformFeeRefundedIrr is { } fee && request.NursePayoutRefundedIrr is { } payout)
        {
            var amount = fee + payout;
            var impliedPct = context.GrossPriceIrr > 0 ? (decimal)amount / context.GrossPriceIrr : 0m;
            return (fee, payout, impliedPct);
        }

        // Otherwise pro-rata each booking leg at the resolved fraction: the command's, else the b9 snapshot's
        // (percentage stored 0–100 on the booking) — never re-resolved from live config.
        var pct = request.RefundPercentage
                  ?? (context.CancellationRefundPercentage is { } snap ? snap / 100m : (decimal?)null);
        if (pct is not { } fraction || fraction <= 0)
            return null;

        var feeLeg = (long)Math.Round(context.CommissionIrr * fraction, MidpointRounding.AwayFromZero);
        var payoutLeg = (long)Math.Round(context.PayoutIrr * fraction, MidpointRounding.AwayFromZero);
        return (feeLeg, payoutLeg, fraction);
    }

    private static string SelectChannel(CreateRefundCommand request, string gatewayType)
    {
        if (!string.IsNullOrWhiteSpace(request.ManualBankReference))
            return RefundChannel.Manual;
        return gatewayType == PaymentGatewayType.Bnpl ? RefundChannel.BnplRevert : RefundChannel.PspCard;
    }

    private async Task<bool> ExecuteChannelAsync(
        Refund refund, string channel, RefundMoneyContext context, string? manualBankReference,
        long amount, long priorRefunded, string idempotencyKey, DateTime now, CancellationToken cancellationToken)
    {
        switch (channel)
        {
            case RefundChannel.PspCard:
            {
                var result = await paymentProvider.RefundAsync(
                    context.GatewayReferenceCode ?? string.Empty, amount, idempotencyKey, cancellationToken);
                if (result.Status != PaymentProviderStatus.Succeeded)
                {
                    refund.MarkFailed(result.Status.ToString());
                    return false;
                }
                refund.MarkSucceededCard(result.GatewayRefundReference ?? idempotencyKey, now);
                return true;
            }
            case RefundChannel.BnplRevert:
            {
                // Full = revert; partial/shortened = update to the strictly-lower remaining amount.
                var isFull = priorRefunded == 0 && amount == context.CapturedAmount;
                var result = isFull
                    ? await bnplProvider.RevertAsync(context.GatewayReferenceCode ?? string.Empty, amount, idempotencyKey, cancellationToken)
                    : await bnplProvider.UpdateAsync(context.GatewayReferenceCode ?? string.Empty, context.CapturedAmount - (priorRefunded + amount), idempotencyKey, cancellationToken);
                if (result.Status == PaymentProviderStatus.Failed)
                {
                    refund.MarkFailed(result.Status.ToString());
                    return false;
                }
                var eta = await ExpectedEtaAsync(now, cancellationToken);
                refund.MarkProcessing(result.ExternalRevertReference, eta);
                return true;
            }
            default: // manual out-of-band bank refund — admin records the bank ref; the cash-back confirms later.
            {
                refund.MarkProcessing(manualBankReference, null);
                return true;
            }
        }
    }

    private async Task<DateOnly> ExpectedEtaAsync(DateTime now, CancellationToken cancellationToken)
    {
        var days = await platformConfig.GetConfig<int>("bnpl_refund_eta_business_days", cancellationToken);
        return BusinessDays.Add(DateOnly.FromDateTime(now), days);
    }

    private async Task NotifyCustomerAsync(RefundMoneyContext context, Refund refund, CancellationToken cancellationToken)
    {
        var payload = JsonSerializer.Serialize(new { booking_id = context.BookingId, refund_id = refund.Id, refund.Status });
        var body = refund.Status == RefundStatus.Succeeded
            ? "Your refund has been processed."
            : "Your refund is on its way and should arrive within a few business days.";

        await notifications.DispatchAsync(
            new Notification(context.CustomerUserId, "refund_issued", "Refund issued", body, payload),
            cancellationToken);
    }
}
