using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger;
using Baya.Application.Features.Payments.Commands.HandlePaymentWebhook;
using Baya.Application.Features.Payments.Commands.InitiatePayment;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Payments;
using Mediator;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;

namespace Baya.Test.Foundation.Payments;

public class PaymentWebhookTests
{
    private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
    private const long Price = 23_300_000;

    private static ISender SenderRoutingConfirmTo(ConfirmPaymentAndPostLedgerCommandHandler confirm)
    {
        var sender = Substitute.For<ISender>();
        sender.Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>())
            .Returns(ci => confirm.Handle(ci.Arg<ConfirmPaymentAndPostLedgerCommand>(), ci.Arg<CancellationToken>()));
        return sender;
    }

    private static async Task<(long RequestId, string Reference)> SeedPendingAsync(PaymentsTestHost host)
    {
        var variantId = host.AddVariant(sessionCount: 1, price: Price);
        var requestId = host.AddAcceptedRequest(variantId);
        var initiate = new InitiatePaymentCommandHandler(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now));
        var init = await initiate.Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None);
        return (requestId, init.Result.GatewayReferenceCode);
    }

    private static string SuccessBody(string reference, string eventId)
        => $"{{\"external_event_id\":\"{eventId}\",\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}";

    [Fact]
    public async Task Webhook_success_confirms_the_booking_and_posts_one_balanced_group()
    {
        using var host = new PaymentsTestHost();
        var (_, reference) = await SeedPendingAsync(host);

        var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
            host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
        var handler = new HandlePaymentWebhookCommandHandler(
            SenderRoutingConfirmTo(confirm), host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));

        var result = await handler.Handle(
            new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), SuccessBody(reference, "evt-1")),
            CancellationToken.None);

        Assert.True(result.IsSuccess);
        Assert.Equal(WebhookProcessingStatus.Processed, result.Result.ProcessingStatus);
        Assert.False(result.Result.Duplicate);

        Assert.Equal(BookingStatus.Confirmed, host.Db.Set<BookingEntity>().AsNoTracking().Single().Status);
        Assert.Equal(3, host.Db.Set<LedgerEntry>().AsNoTracking().Count());
        Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
    }

    [Fact]
    public async Task Replayed_webhook_event_is_a_no_op()
    {
        using var host = new PaymentsTestHost();
        var (_, reference) = await SeedPendingAsync(host);

        var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
            host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
        var sender = SenderRoutingConfirmTo(confirm);
        var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));

        var body = SuccessBody(reference, "evt-1");
        await handler.Handle(new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);
        var replay = await handler.Handle(new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);

        Assert.True(replay.IsSuccess);
        Assert.True(replay.Result.Duplicate);

        // No second confirm, no second ledger group, one webhook event row.
        await sender.Received(1).Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>());
        Assert.Equal(3, host.Db.Set<LedgerEntry>().AsNoTracking().Count());
        Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
    }

    [Fact]
    public async Task Racing_same_key_insert_is_caught_as_an_idempotent_no_op()
    {
        using var host = new PaymentsTestHost();
        var (_, reference) = await SeedPendingAsync(host);

        // A provider that omits external_event_id skips the read-dedup, so the (provider_code, external_event_id)
        // UNIQUE is the SOLE backstop — exactly the state a true concurrent insert reaches when both requests read
        // "no existing event" before either commits. Pre-seed the colliding empty-key row so the handler's own
        // insert loses the unique race and must be treated as an idempotent no-op (DbUpdateException → duplicate).
        var existing = new PaymentWebhookEvent
        {
            ProviderCode = "zarinpal", ExternalEventId = string.Empty, EventType = "payment.succeeded",
            SignatureValid = true, PayloadJson = "{}", ReceivedAt = Now.UtcDateTime
        };
        existing.MarkProcessed(null, Now.UtcDateTime);
        host.Db.Set<PaymentWebhookEvent>().Add(existing);
        host.Db.SaveChanges();

        var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
            host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
        var sender = SenderRoutingConfirmTo(confirm);
        var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));

        // No external_event_id in the body → verification.ExternalEventId == "" (the read-dedup is skipped).
        var body = $"{{\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}";
        var result = await handler.Handle(
            new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);

        Assert.True(result.IsSuccess);
        Assert.True(result.Result.Duplicate);
        Assert.Equal(WebhookProcessingStatus.Processed, result.Result.ProcessingStatus);

        // The insert lost the race → no confirm, no ledger, and only the pre-existing event row survives.
        await sender.DidNotReceive().Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>());
        Assert.Empty(host.Db.Set<LedgerEntry>().AsNoTracking());
        Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
    }

    [Fact]
    public async Task Unverified_signature_callback_mutates_nothing()
    {
        using var host = new PaymentsTestHost();
        var (_, reference) = await SeedPendingAsync(host);

        var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
            host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
        var sender = SenderRoutingConfirmTo(confirm);
        var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));

        // The default MockWebhookVerifier marks a body carrying the invalid-signature marker as invalid.
        var body = $"{{\"external_event_id\":\"evt-x\",\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\",\"note\":\"INVALID_SIGNATURE\"}}";
        var result = await handler.Handle(
            new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);

        Assert.True(result.IsSuccess);
        Assert.Equal(WebhookProcessingStatus.Ignored, result.Result.ProcessingStatus);

        await sender.DidNotReceive().Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>());
        Assert.Empty(host.Db.Set<LedgerEntry>().AsNoTracking());
        Assert.Empty(host.Db.Set<BookingEntity>().AsNoTracking());
        var txn = host.Db.Set<PaymentTransaction>().AsNoTracking().Single();
        Assert.Equal(PaymentTransactionStatus.Pending, txn.Status);
    }
}
