using System.Net;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Identity;
using Baya.Application.Contracts.Search;
using Mediator;
using Microsoft.Extensions.Logging;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Payouts;
using Baya.Domain.Entities.Refunds;
using Baya.Domain.Entities.Reviews;
using Baya.Domain.Entities.Verification;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Services.Seeding;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;

namespace Baya.Test.Api;

/// <summary>
/// Proves the Development lifecycle seeder produces a coherent, invariant-satisfying world: every request
/// and booking state exists, every ledger group balances, the money/one-per-booking uniques hold, the
/// moderation/publish gate is respected on the public route, and a second run is a no-op.
/// </summary>
public class DemoLifecycleSeederTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
    private async Task RunSeedersAsync()
    {
        using var scope = factory.Services.CreateScope();

        var world = scope.ServiceProvider.GetRequiredService<DemoWorldSeeder>();
        await world.SeedAsync(CancellationToken.None);

        // The Testing host never runs SeedPaymentGatewaysAsync (a Program.cs Development-only step) — the
        // lifecycle seeder needs the gateway row, so mirror that tiny insert here.
        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
        if (!await db.Set<PaymentGateway>().AnyAsync(g => g.Type == PaymentGatewayType.Standard))
        {
            db.Set<PaymentGateway>().Add(new PaymentGateway
            {
                ProviderCode = "zarinpal",
                Type = PaymentGatewayType.Standard,
                DisplayName = "ZarinPal (test)",
                ConfigJson = "{}",
                IsActive = true,
                Priority = 0
            });
            await db.SaveChangesAsync();
        }

        var lifecycle = scope.ServiceProvider.GetRequiredService<DemoLifecycleSeeder>();
        await lifecycle.SeedAsync(CancellationToken.None);
    }

    [Fact]
    public async Task Seed_CoversEveryRequestAndBookingState()
    {
        await RunSeedersAsync();

        using var scope = factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

        var requestStatuses = await db.Set<BookingRequest>().Select(r => r.Status).Distinct().ToListAsync();
        Assert.Contains(BookingRequestStatus.PendingNurseResponse, requestStatuses);
        Assert.Contains(BookingRequestStatus.AcceptedAwaitingPayment, requestStatuses);
        Assert.Contains(BookingRequestStatus.RejectedByNurse, requestStatuses);
        Assert.Contains(BookingRequestStatus.ExpiredNoResponse, requestStatuses);
        Assert.Contains(BookingRequestStatus.CancelledByCustomer, requestStatuses);
        Assert.Contains(BookingRequestStatus.Converted, requestStatuses);

        var bookingStatuses = await db.Set<BookingEntity>().Select(b => b.Status).Distinct().ToListAsync();
        Assert.Contains(BookingStatus.Confirmed, bookingStatuses);
        Assert.Contains(BookingStatus.InProgress, bookingStatuses);
        Assert.Contains(BookingStatus.Completed, bookingStatuses);
        Assert.Contains(BookingStatus.Cancelled, bookingStatuses);

        // The multi-session package: 5 sessions, 2 completed, 1 in progress (checked in), 2 scheduled.
        var package = await db.Set<BookingEntity>().Include(b => b.Sessions)
            .Where(b => b.SessionCount == 5)
            .SingleAsync();
        Assert.Equal(2, package.Sessions.Count(s => s.Status == BookingSessionStatus.Completed));
        Assert.Equal(1, package.Sessions.Count(s => s.Status == BookingSessionStatus.InProgress));
        Assert.Equal(2, package.Sessions.Count(s => s.Status == BookingSessionStatus.Scheduled));
    }

    [Fact]
    public async Task Seed_EveryLedgerGroupBalances_AndMoneyUniquesHold()
    {
        await RunSeedersAsync();

        using var scope = factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

        // Σ(debit) = Σ(credit) per transaction group — the double-entry invariant over the whole world.
        var groups = await db.Set<LedgerEntry>()
            .GroupBy(e => e.TransactionGroupId)
            .Select(g => new
            {
                Debit = g.Where(e => e.Direction == LedgerDirection.Debit).Sum(e => e.AmountIrr),
                Credit = g.Where(e => e.Direction == LedgerDirection.Credit).Sum(e => e.AmountIrr)
            })
            .ToListAsync();
        Assert.NotEmpty(groups);
        Assert.All(groups, g => Assert.Equal(g.Debit, g.Credit));

        // Exactly one succeeded capture per booking; the three-amount split reconciles on every booking.
        var bookings = await db.Set<BookingEntity>().ToListAsync();
        Assert.All(bookings, b => Assert.Equal(b.GrossPriceIrr, b.BalinyaarCommissionIrr + b.NursePayoutAmount));
        foreach (var booking in bookings)
            Assert.Equal(1, await db.Set<PaymentTransaction>()
                .CountAsync(t => t.BookingId == booking.Id && t.Status == PaymentTransactionStatus.Succeeded));

        // Session split: Σ(visit_payout_amount) = nurse_payout_amount, exactly.
        foreach (var booking in bookings)
        {
            var sessionSum = await db.Set<BookingSession>()
                .Where(s => s.BookingId == booking.Id)
                .SumAsync(s => s.VisitPayoutAmount);
            Assert.Equal(booking.NursePayoutAmount, sessionSum);
        }

        // BNPL settle: settled = order − commission, and its refund is processing (clearing deferred).
        var bnpl = await db.Set<BnplTransaction>().SingleAsync();
        Assert.Equal(BnplStatus.Reverted, bnpl.Status);
        Assert.Equal(bnpl.OrderAmountIrr - bnpl.BnplCommissionIrr, bnpl.SettledAmountIrr);
    }

    [Fact]
    public async Task Seed_PayoutWorld_PaidBatchDraftBatchAndClawback()
    {
        await RunSeedersAsync();

        using var scope = factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

        var batches = await db.Set<NursePayoutBatch>().Include(b => b.Payouts).ToListAsync();
        Assert.Contains(batches, b => b.Status == PayoutBatchStatus.Completed);
        Assert.Contains(batches, b => b.Status == PayoutBatchStatus.Draft);

        var paidBatch = batches.Single(b => b.Status == PayoutBatchStatus.Completed);
        Assert.All(paidBatch.Payouts, p => Assert.Equal(PayoutStatus.Paid, p.Status));
        Assert.All(paidBatch.Payouts, p => Assert.Equal(p.GrossEarningsIrr - p.ClawbackAppliedIrr, p.NetAmountIrr));

        // One payout link per linked booking, and the refunded BNPL booking is in no batch at all.
        var links = await db.Set<NursePayoutBookingLink>().ToListAsync();
        Assert.Equal(links.Count, links.Select(l => l.BookingId).Distinct().Count());
        var bnplTxn = await db.Set<BnplTransaction>().SingleAsync();
        var bnplBookingId = await db.Set<PaymentTransaction>()
            .Where(t => t.Id == bnplTxn.PaymentTransactionId)
            .Select(t => t.BookingId)
            .SingleAsync();
        Assert.DoesNotContain(links, l => l.BookingId == bnplBookingId);

        // The post-payout refund opened a pending clawback tied to the original payout.
        var clawback = await db.Set<NurseClawback>().SingleAsync();
        Assert.Equal(ClawbackStatus.Pending, clawback.Status);
        Assert.NotNull(clawback.OriginalPayoutId);

        // Three refunds, one per scenario: succeeded card, processing bnpl_revert, succeeded clawback.
        var refunds = await db.Set<Refund>().ToListAsync();
        Assert.Equal(3, refunds.Count);
        Assert.All(refunds, r => Assert.Equal(r.Amount, r.PlatformFeeRefundedIrr + r.NursePayoutRefundedIrr));
        Assert.All(refunds, r => Assert.NotNull(r.TicketId));
        Assert.Contains(refunds, r => r.Status == RefundStatus.Processing && r.RefundChannel == RefundChannel.BnplRevert);
        Assert.Equal(2, refunds.Count(r => r.Status == RefundStatus.Succeeded && r.RefundChannel == RefundChannel.PspCard));
    }

    [Fact]
    public async Task Seed_PublicReviews_ShowOnlyThePublishedOne_AndAggregatesMatch()
    {
        await RunSeedersAsync();

        long verifiedNurseId;
        using (var scope = factory.Services.CreateScope())
        {
            var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
            verifiedNurseId = await db.Set<Review>()
                .Where(r => r.ModerationStatus == ReviewModerationStatus.Published)
                .Select(r => r.NurseProfileId)
                .SingleAsync();

            // The aggregate mirrors the published-only recompute rule.
            var profile = await db.Set<NurseProfile>().SingleAsync(p => p.Id == verifiedNurseId);
            Assert.Equal(1, profile.TotalReviews);
            Assert.Equal(5.00m, profile.AverageRating);
            Assert.True(profile.TotalCompletedBookings > 0);
        }

        var client = factory.CreateClient();
        var response = await client.GetAsync($"/api/v1/nurses/{verifiedNurseId}/reviews");
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var data = await AuthTestClient.ReadDataAsync(response);
        Assert.Equal(1, data.GetProperty("aggregate").GetProperty("publishedCount").GetInt32());
        Assert.Equal(1, data.GetProperty("reviews").GetProperty("total").GetInt32());
    }

    [Fact]
    public async Task Seed_TicketsCarryTheInternalNote_AndVerificationStaysUnapproved()
    {
        await RunSeedersAsync();

        using var scope = factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

        // The internal admin note exists in the store — the user-facing thread queries must strip it
        // (enforced at the query layer; asserted here so the demo world genuinely exercises the boundary).
        var support = await db.Set<Ticket>().Include(t => t.Messages)
            .SingleAsync(t => t.ReferenceCode == DemoLifecycleDefinitions.SupportTicketOpen);
        Assert.Contains(support.Messages, m => m.IsInternal);

        // The mid-pipeline verification case: steps exist, but is_verified stays false and the nurse
        // must have no searchable rows.
        var ahmadi = await db.Set<NurseProfile>().SingleAsync(p => !p.IsVerified);
        var verification = await db.Set<NurseVerification>().SingleAsync(v => v.NurseId == ahmadi.Id);
        Assert.Equal(VerificationStatus.InReview, verification.Status);
        Assert.True(await db.Set<VerificationStep>().AnyAsync(s => s.NurseVerificationId == verification.Id));
        Assert.False(ahmadi.IsVerified);
    }

    [Fact]
    public async Task Seed_IsIdempotent_SecondRunAddsNothing()
    {
        await RunSeedersAsync();

        int requests, bookings, refunds, reviews, tickets, batches, ledger;
        using (var scope = factory.Services.CreateScope())
        {
            var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
            requests = await db.Set<BookingRequest>().CountAsync();
            bookings = await db.Set<BookingEntity>().CountAsync();
            refunds = await db.Set<Refund>().CountAsync();
            reviews = await db.Set<Review>().CountAsync();
            tickets = await db.Set<Ticket>().CountAsync();
            batches = await db.Set<NursePayoutBatch>().CountAsync();
            ledger = await db.Set<LedgerEntry>().CountAsync();
        }

        await RunSeedersAsync();

        using (var scope = factory.Services.CreateScope())
        {
            var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
            Assert.Equal(requests, await db.Set<BookingRequest>().CountAsync());
            Assert.Equal(bookings, await db.Set<BookingEntity>().CountAsync());
            Assert.Equal(refunds, await db.Set<Refund>().CountAsync());
            Assert.Equal(reviews, await db.Set<Review>().CountAsync());
            Assert.Equal(tickets, await db.Set<Ticket>().CountAsync());
            Assert.Equal(batches, await db.Set<NursePayoutBatch>().CountAsync());
            Assert.Equal(ledger, await db.Set<LedgerEntry>().CountAsync());
        }
    }

    /// <summary>A clock frozen at a chosen instant — enough to run the seeder "on a later day".</summary>
    private sealed class FixedClock(DateTimeOffset now) : IDateTimeProvider
    {
        public DateTimeOffset UtcNow { get; } = now;
    }

    /// <summary>
    /// Runs the lifecycle seeder alone against a chosen clock. Built by hand rather than resolved, so the
    /// clock can move without swapping a registration on the fixture every other test shares.
    /// </summary>
    private async Task RunLifecycleSeedAsync(IServiceProvider services, IDateTimeProvider clock)
    {
        var seeder = new DemoLifecycleSeeder(
            services.GetRequiredService<ApplicationDbContext>(),
            services.GetRequiredService<IAppUserManager>(),
            services.GetRequiredService<ISender>(),
            services.GetRequiredService<IPlatformConfig>(),
            services.GetRequiredService<IVariantSnapshotSerializer>(),
            services.GetRequiredService<ISearchIndexMaintainer>(),
            services.GetRequiredService<IFieldEncryptor>(),
            clock,
            services.GetRequiredService<ILogger<DemoLifecycleSeeder>>());

        await seeder.SeedAsync(CancellationToken.None);
    }

    [Fact]
    public async Task Seed_IsIdempotent_EvenWhenTheSecondRunHappensOnALaterDay()
    {
        // The regression this locks down. Several scenario keys contain a date derived from "now"
        // (`Today(now, offset)`), so when the run was anchored to the wall clock the key silently moved
        // at midnight: the next day's boot matched none of yesterday's rows, re-created all thirteen
        // scenarios, and then collided on `GatewayReferenceCode` — which is date-independent — leaving a
        // half-written scenario for the boot after that to crash on. Seeding twice within one day (the
        // test above) can never surface that; only advancing the clock between runs does.
        await RunSeedersAsync();

        int requests, bookings, ledger, transactions;
        using (var scope = factory.Services.CreateScope())
        {
            var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
            requests = await db.Set<BookingRequest>().CountAsync();
            bookings = await db.Set<BookingEntity>().CountAsync();
            ledger = await db.Set<LedgerEntry>().CountAsync();
            transactions = await db.Set<PaymentTransaction>().CountAsync();
        }

        using (var scope = factory.Services.CreateScope())
        {
            var threeDaysOn = new FixedClock(DateTimeOffset.UtcNow.AddDays(3));
            await RunLifecycleSeedAsync(scope.ServiceProvider, threeDaysOn);
        }

        using (var scope = factory.Services.CreateScope())
        {
            var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
            Assert.Equal(requests, await db.Set<BookingRequest>().CountAsync());
            Assert.Equal(bookings, await db.Set<BookingEntity>().CountAsync());
            Assert.Equal(ledger, await db.Set<LedgerEntry>().CountAsync());
            Assert.Equal(transactions, await db.Set<PaymentTransaction>().CountAsync());
        }
    }
}
