using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.Commerce;
using Tiku.Application.Jobs;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;

namespace Tiku.IntegrationTests.Api;

public sealed class TenantCommerceEndpointTests
{
    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
    {
        Converters = { new JsonStringEnumConverter() }
    };

    [Fact]
    public async Task Non_admin_cannot_access_tenant_commerce_operations()
    {
        await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
        var seed = await SeedLoginUserAsync(factory, TenantRole.Student);
        using var client = factory.CreateClient();
        await LoginAsync(client, seed);

        var response = await client.GetAsync("/api/tenant/commerce/orders");

        Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
    }

    [Fact]
    public async Task Payment_account_public_config_rejects_secrets()
    {
        await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
        var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
        using var client = factory.CreateClient();
        await LoginAsync(client, seed);

        var response = await client.PutAsJsonAsync(
            "/api/tenant/commerce/payment-accounts",
            new UpsertPaymentAccountDto
            {
                Provider = "wechat_pay",
                Status = TenantExternalProviderStatus.Active,
                ConfigPublic = JsonSerializer.SerializeToElement(new
                {
                    merchantId = "mch",
                    apiV3Key = "must-not-be-public"
                })
            });

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }

    [Fact]
    public async Task Admin_can_configure_payment_account_and_secret()
    {
        await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
        var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
        using var client = factory.CreateClient();
        await LoginAsync(client, seed);

        var secretResponse = await client.PutAsJsonAsync(
            "/api/tenant/commerce/secrets",
            new UpsertTenantSecretDto
            {
                Purpose = "payment",
                Provider = "wechat_pay",
                SecretKey = "default",
                SecretRef = "tenant_secrets:payment:wechat_pay:default",
                SecretPayload = JsonSerializer.SerializeToElement(new
                {
                    privateKey = "pem",
                    apiV3Key = "v3"
                })
            });
        var accountResponse = await client.PutAsJsonAsync(
            "/api/tenant/commerce/payment-accounts",
            new UpsertPaymentAccountDto
            {
                Provider = "wechat_pay",
                Status = TenantExternalProviderStatus.Active,
                SecretRef = "tenant_secrets:payment:wechat_pay:default",
                ConfigPublic = JsonSerializer.SerializeToElement(new
                {
                    merchantId = "mch"
                })
            });
        var accountsResponse = await client.GetAsync("/api/tenant/commerce/payment-accounts?provider=wechat_pay");

        Assert.Equal(HttpStatusCode.OK, secretResponse.StatusCode);
        Assert.DoesNotContain("privateKey", await secretResponse.Content.ReadAsStringAsync(),
            StringComparison.OrdinalIgnoreCase);
        Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode);
        Assert.Equal(HttpStatusCode.OK, accountsResponse.StatusCode);
        Assert.Contains("wechat_pay", await accountsResponse.Content.ReadAsStringAsync(),
            StringComparison.OrdinalIgnoreCase);

        using var scope = factory.CreateSystemScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
        var storedSecret = await dbContext.TenantSecrets
            .AsNoTracking()
            .SingleAsync(item => item.TenantId == seed.TenantId);
        var configService = scope.ServiceProvider.GetRequiredService<IPaymentProviderConfigService>();
        var resolvedAccount = await configService.GetActiveAccountAsync(seed.TenantId, "wechat_pay");

        Assert.Equal("development-v1", storedSecret.EncryptionKeyId);
        Assert.NotEmpty(storedSecret.EncryptedPayload);
        Assert.Equal(12, storedSecret.EncryptionNonce.Length);
        Assert.Equal(16, storedSecret.EncryptionTag.Length);
        Assert.Equal("pem", resolvedAccount.SecretPayload.GetProperty("privateKey").GetString());
        Assert.Equal("v3", resolvedAccount.SecretPayload.GetProperty("apiV3Key").GetString());
    }

    [Fact]
    public async Task Admin_can_create_and_redeem_activation_code_batch()
    {
        await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
        var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
        using var client = factory.CreateClient();
        await LoginAsync(client, seed);

        var batchResponse = await client.PostAsJsonAsync(
            "/api/tenant/commerce/code-batches",
            new CreateCodeBatchDto
            {
                Name = "测试批次",
                TotalCount = 2,
                Days = 30
            });
        var codesResponse = await client.GetAsync("/api/tenant/commerce/activation-codes?status=unused&limit=5");
        var codes = await codesResponse.Content.ReadFromJsonAsync<ActivationCodeList>();
        var code = codes!.Items.First().Code;
        var redeemResponse = await client.PostAsJsonAsync(
            "/api/tenant/commerce/activation-codes/redeem",
            new RedeemActivationCodeDto
            {
                Code = code,
                UserId = seed.UserId
            });

        Assert.Equal(HttpStatusCode.OK, batchResponse.StatusCode);
        Assert.Equal(HttpStatusCode.OK, codesResponse.StatusCode);
        Assert.Equal(2, codes.Items.Count);
        Assert.Equal(HttpStatusCode.OK, redeemResponse.StatusCode);

        using var scope = factory.CreateSystemScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
        Assert.Contains(dbContext.Entitlements, item =>
            item.TenantId == seed.TenantId &&
            item.UserId == seed.UserId &&
            item.SourceType == "activation_code");
    }

    [Fact]
    public async Task Commerce_reconciliation_worker_does_not_succeed_without_active_provider_config()
    {
        await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
        var tenantId = Guid.NewGuid();
        await factory.SeedAsync(new Tenant
        {
            Id = tenantId,
            Slug = tenantId.ToString("N"),
            Name = "Worker Provider Required"
        });
        using var scope = factory.CreateSystemScope();
        var jobService = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
        var job = await jobService.EnqueueAsync(new CreateBackgroundJobCommand(
            tenantId,
            "commerce_reconciliation",
            JsonSerializer.SerializeToElement(new
            {
                provider = PaymentProviders.WechatPay,
                billDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
                billType = ReconciliationBillType.Combined.ToString()
            })));

        var processed = await jobService.ProcessPendingAsync("integration-worker", 10);

        var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
        var storedJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == job.Id);
        Assert.Equal(1, processed);
        Assert.Equal(BackgroundJobStatus.Pending, storedJob.Status);
        Assert.Equal(1, storedJob.RetryCount);
        Assert.Contains("Active payment provider", storedJob.LastError, StringComparison.OrdinalIgnoreCase);
        Assert.False(await dbContext.CommerceReconciliationBatches.AnyAsync(item => item.TenantId == tenantId));
    }

    [Fact]
    public async Task Background_service_processes_content_export_and_statistics_aggregation()
    {
        await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
        var tenantId = Guid.NewGuid();
        var userId = Guid.NewGuid();
        await factory.SeedAsync(
            new Tenant
            {
                Id = tenantId,
                Slug = tenantId.ToString("N"),
                Name = "Worker Export Tenant"
            },
            new User { Id = userId, Phone = "13900006666", Name = "Worker Student" },
            new StudentProfile { TenantId = tenantId, UserId = userId },
            new PracticeSession { TenantId = tenantId, UserId = userId, QuestionCount = 1 },
            new Order
            {
                TenantId = tenantId,
                UserId = userId,
                OrderNo = "WORKER-STATS-ORDER",
                Status = OrderStatus.Paid,
                AmountCents = 800,
                PaidAt = DateTimeOffset.UtcNow
            });
        using var scope = factory.CreateSystemScope();
        var jobService = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
        var exportJob = await jobService.EnqueueAsync(new CreateBackgroundJobCommand(
            tenantId,
            "content_export",
            JsonSerializer.SerializeToElement(new { exportType = "students" })));
        var statsJob = await jobService.EnqueueAsync(new CreateBackgroundJobCommand(
            tenantId,
            "statistics_aggregation",
            JsonSerializer.SerializeToElement(new { scope = "tenant" })));

        var processed = await jobService.ProcessPendingAsync("integration-worker", 10);

        var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
        var storedExportJob =
            await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == exportJob.Id);
        var storedStatsJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == statsJob.Id);
        Assert.Equal(2, processed);
        Assert.Equal(BackgroundJobStatus.Succeeded, storedExportJob.Status);
        Assert.NotNull(storedExportJob.OutputAssetId);
        Assert.True(await dbContext.ContentAssets.AnyAsync(item => item.Id == storedExportJob.OutputAssetId));
        Assert.Equal(BackgroundJobStatus.Succeeded, storedStatsJob.Status);
        Assert.Equal(800, storedStatsJob.Result.GetProperty("revenueCents").GetInt32());
    }

    [Fact]
    public async Task Admin_can_manage_points_and_coupons()
    {
        await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
        var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
        using var client = factory.CreateClient();
        await LoginAsync(client, seed);

        var taskResponse = await client.PutAsJsonAsync(
            "/api/tenant/commerce/point-activity-tasks",
            new UpsertPointTaskDto
            {
                TaskKey = "admin_daily",
                Title = "后台每日任务",
                TaskType = PointActivityTaskType.DailyLogin,
                Points = 10,
                MaxClaimsPerUser = 1
            });
        var tasksResponse = await client.GetAsync("/api/tenant/commerce/point-activity-tasks");
        var exchangeItemResponse = await client.PutAsJsonAsync(
            "/api/tenant/commerce/point-exchange-items",
            new UpsertPointExchangeItemDto
            {
                ItemKey = "admin_svip_7d",
                Name = "后台 SVIP 7 天",
                ItemType = PointExchangeItemType.Entitlement,
                PointsCost = 30,
                Days = 7,
                Stock = 10
            });
        var exchangeItemsResponse = await client.GetAsync("/api/tenant/commerce/point-exchange-items");
        var couponResponse = await client.PutAsJsonAsync(
            "/api/tenant/commerce/coupons",
            new UpsertTenantCouponDto
            {
                Code = "ADMIN10",
                DiscountType = DiscountType.Fixed,
                DiscountValue = 10,
                MaxUses = 100
            });
        var couponsResponse = await client.GetAsync("/api/tenant/commerce/coupons");
        var reportResponse = await client.GetAsync("/api/tenant/commerce/coupons/report");

        Assert.Equal(HttpStatusCode.OK, taskResponse.StatusCode);
        Assert.Contains("admin_daily", await tasksResponse.Content.ReadAsStringAsync(),
            StringComparison.OrdinalIgnoreCase);
        Assert.Equal(HttpStatusCode.OK, exchangeItemResponse.StatusCode);
        Assert.Contains("admin_svip_7d", await exchangeItemsResponse.Content.ReadAsStringAsync(),
            StringComparison.OrdinalIgnoreCase);
        Assert.Equal(HttpStatusCode.OK, couponResponse.StatusCode);
        Assert.Contains("ADMIN10", await couponsResponse.Content.ReadAsStringAsync(),
            StringComparison.OrdinalIgnoreCase);
        Assert.Equal(HttpStatusCode.OK, reportResponse.StatusCode);
    }

    [Fact]
    public async Task OrderAndRefundOperations_ApplySelfAndRestrictedScopesInSql()
    {
        await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
        var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
        var allowedRegionId = Guid.NewGuid();
        var otherRegionId = Guid.NewGuid();
        var regionalUserId = Guid.NewGuid();
        var outsideUserId = Guid.NewGuid();
        var ownOrder = NewPaidOrder(seed.TenantId, seed.UserId, otherRegionId, "SELF-ORDER");
        var regionalOrder = NewPaidOrder(seed.TenantId, regionalUserId, allowedRegionId, "REGION-ORDER");
        var outsideOrder = NewPaidOrder(seed.TenantId, outsideUserId, otherRegionId, "OUTSIDE-ORDER");
        await factory.SeedAsync(
            new Region { Id = allowedRegionId, TenantId = seed.TenantId, Name = "Allowed Region" },
            new Region { Id = otherRegionId, TenantId = seed.TenantId, Name = "Other Region" },
            new User { Id = regionalUserId, Name = "Regional Buyer" },
            new User { Id = outsideUserId, Name = "Outside Buyer" },
            ownOrder,
            regionalOrder,
            outsideOrder);
        await SetAdminDataScopeAsync(factory, seed.TenantId, new { mode = "self" });

        using var client = factory.CreateClient();
        await LoginAsync(client, seed);
        var selfOrders = await client.GetFromJsonAsync<AdminOrderList>("/api/tenant/commerce/orders");

        await SetAdminDataScopeAsync(factory, seed.TenantId, new
        {
            mode = "restricted",
            regionIds = new[] { allowedRegionId },
            includesSelf = false
        });
        var regionalOrders = await client.GetFromJsonAsync<AdminOrderList>("/api/tenant/commerce/orders");
        using var deniedRefund = await client.PostAsJsonAsync(
            "/api/tenant/commerce/refunds",
            new CreateRefundRequestDto { OrderId = outsideOrder.Id, AmountCents = 100 });
        using var allowedRefund = await client.PostAsJsonAsync(
            "/api/tenant/commerce/refunds",
            new CreateRefundRequestDto { OrderId = regionalOrder.Id, AmountCents = 100 });

        Assert.NotNull(selfOrders);
        Assert.Equal(["SELF-ORDER"], selfOrders.Items.Select(item => item.OrderNo));
        Assert.NotNull(regionalOrders);
        Assert.Equal(["REGION-ORDER"], regionalOrders.Items.Select(item => item.OrderNo));
        Assert.Equal(HttpStatusCode.NotFound, deniedRefund.StatusCode);
        Assert.Equal(HttpStatusCode.OK, allowedRefund.StatusCode);
    }

    [Fact]
    public async Task Admin_can_preview_import_and_query_reconciliation_operations()
    {
        await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
        var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
        using var client = factory.CreateClient();
        await LoginAsync(client, seed);
        var rows = JsonSerializer.SerializeToElement(new object[]
        {
            new
            {
                transactionType = "payment",
                providerTradeNo = "wx-trade-1",
                orderNo = "ORDER-1",
                amountCents = 1000,
                providerStatus = "paid",
                localStatus = "paid",
                matchStatus = "matched"
            },
            new
            {
                transactionType = "refund",
                providerTradeNo = "wx-trade-2",
                providerRefundNo = "wx-refund-2",
                orderNo = "ORDER-2",
                refundNo = "REFUND-2",
                refundAmountCents = 300,
                providerStatus = "succeeded",
                localStatus = "processing",
                matchStatus = "status_mismatch",
                issueCode = "refund_status_mismatch"
            }
        });
        var request = new PreviewReconciliationImportDto
        {
            Provider = PaymentProviders.WechatPay,
            BillDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
            BillType = ReconciliationBillType.Combined,
            SourceName = "wechat-bill.csv",
            Rows = rows
        };

        var previewResponse = await client.PostAsJsonAsync("/api/tenant/commerce/reconciliation/preview", request);
        var preview = await previewResponse.Content.ReadFromJsonAsync<ReconciliationImportPreview>(JsonOptions);
        var importResponse = await client.PostAsJsonAsync("/api/tenant/commerce/reconciliation/import", request);
        var batch = await importResponse.Content.ReadFromJsonAsync<CommerceReconciliationBatch>(JsonOptions);
        var itemsResponse = await client.GetAsync($"/api/tenant/commerce/reconciliation/items?batchId={batch!.Id}");
        var items = await itemsResponse.Content.ReadFromJsonAsync<TenantReconciliationItemList>(JsonOptions);
        var issuesResponse = await client.GetAsync("/api/tenant/commerce/reconciliation/issues");
        var issues = await issuesResponse.Content.ReadFromJsonAsync<TenantReconciliationIssueList>(JsonOptions);
        var issueEventsResponse =
            await client.GetAsync(
                $"/api/tenant/commerce/reconciliation/issues/events?issueId={issues!.Items.First().Id}");
        var anomalies =
            await client.GetFromJsonAsync<TenantCommerceAnomalySummary>("/api/tenant/commerce/reconciliation/anomalies",
                JsonOptions);
        var accountResponse = await client.PutAsJsonAsync(
            "/api/tenant/commerce/payment-accounts",
            new UpsertPaymentAccountDto
            {
                Provider = PaymentProviders.WechatPay,
                Status = TenantExternalProviderStatus.Active,
                ConfigPublic = JsonSerializer.SerializeToElement(new { merchantId = "mch" })
            });
        var jobResponse = await client.PostAsJsonAsync(
            "/api/tenant/commerce/reconciliation/provider-bills/request",
            new RequestProviderBillJobDto
            {
                Provider = PaymentProviders.WechatPay,
                BillDate = request.BillDate,
                BillType = ReconciliationBillType.Combined
            });
        var jobsResponse = await client.GetAsync("/api/tenant/commerce/reconciliation/provider-bills/jobs?limit=5");
        var jobs = await jobsResponse.Content.ReadFromJsonAsync<IReadOnlyCollection<BackgroundJobItem>>(JsonOptions);
        using var scope = factory.CreateSystemScope();
        var jobService = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
        var processedJobs = await jobService.ProcessPendingAsync("integration-worker", 10);
        var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
        var providerBatch = await dbContext.CommerceReconciliationBatches.AsNoTracking()
            .SingleAsync(item =>
                item.TenantId == seed.TenantId &&
                item.Provider == PaymentProviders.WechatPay &&
                item.Source == ReconciliationSource.ProviderDownload);
        var storedJob = await dbContext.BackgroundJobs.AsNoTracking()
            .SingleAsync(item => item.TenantId == seed.TenantId && item.JobType == "commerce_reconciliation");

        Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
        Assert.NotNull(preview);
        Assert.Equal(2, preview.TotalCount);
        Assert.Equal(1, preview.PaymentCount);
        Assert.Equal(1, preview.RefundCount);
        Assert.Equal(1, preview.InvalidCount);
        Assert.Equal(1000, preview.AmountCents);
        Assert.Equal(300, preview.RefundAmountCents);
        Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode);
        Assert.Equal(ReconciliationBatchStatus.CompletedWithIssues, batch.Status);
        Assert.Equal(HttpStatusCode.OK, itemsResponse.StatusCode);
        Assert.NotNull(items);
        Assert.Equal(2, items.Items.Count);
        Assert.Equal(HttpStatusCode.OK, issuesResponse.StatusCode);
        Assert.Single(issues.Items);
        Assert.Equal(HttpStatusCode.OK, issueEventsResponse.StatusCode);
        Assert.NotNull(anomalies);
        Assert.True(anomalies.OpenReconciliationIssueCount >= 1);
        Assert.True(anomalies.PaymentMismatchCount >= 1);
        Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode);
        Assert.Equal(HttpStatusCode.OK, jobResponse.StatusCode);
        Assert.Equal(HttpStatusCode.OK, jobsResponse.StatusCode);
        Assert.NotNull(jobs);
        Assert.Contains(jobs, item => item.JobType == "commerce_reconciliation");
        Assert.True(processedJobs >= 1);
        Assert.Equal(ReconciliationBatchStatus.Pending, providerBatch.Status);
        Assert.Equal(BackgroundJobStatus.Succeeded, storedJob.Status);
        Assert.Equal(providerBatch.Id, storedJob.Result.GetProperty("batchId").GetGuid());
    }

    [Fact]
    public async Task Refund_notification_is_idempotent_and_updates_order_once()
    {
        await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
        var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
        var order = NewPaidOrder(seed.TenantId, seed.UserId, null, "REFUND-NOTIFY-ORDER");
        var payment = new Payment
        {
            TenantId = seed.TenantId,
            OrderId = order.Id,
            Provider = PaymentProviders.WechatPay,
            Method = "jsapi",
            Status = PaymentStatus.Paid,
            AmountCents = order.AmountCents,
            ProviderTradeNo = "wx-paid-trade",
            PaidAt = DateTimeOffset.UtcNow
        };
        var refund = new CommerceRefundRequest
        {
            TenantId = seed.TenantId,
            OrderId = order.Id,
            PaymentId = payment.Id,
            RequestedBy = seed.UserId,
            ReviewedBy = seed.UserId,
            ProcessedBy = seed.UserId,
            RefundNo = "REFUND-NOTIFY-1",
            Provider = PaymentProviders.WechatPay,
            Status = CommerceRefundStatus.Processing,
            AmountCents = 400,
            Reason = "integration test",
            RequestedAt = DateTimeOffset.UtcNow,
            ReviewedAt = DateTimeOffset.UtcNow,
            ProcessedAt = DateTimeOffset.UtcNow
        };
        await factory.SeedAsync(order, payment, refund);
        using var client = factory.CreateClient();
        var request = new RefundNotificationDto
        {
            RefundNo = refund.RefundNo,
            ProviderRefundNo = "wx-refund-notify-1",
            Status = CommerceRefundStatus.Succeeded,
            EventId = "refund-event-1",
            Payload = JsonSerializer.SerializeToElement(new { status = "SUCCESS" })
        };

        var first = await client.PostAsJsonAsync(
            $"/api/student/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request);
        var second =
            await client.PostAsJsonAsync(
                $"/api/student/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request);

        Assert.Equal(HttpStatusCode.OK, first.StatusCode);
        Assert.Equal(HttpStatusCode.OK, second.StatusCode);
        using var scope = factory.CreateSystemScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
        var storedOrder = await dbContext.Orders.AsNoTracking().SingleAsync(item => item.Id == order.Id);
        var storedPayment = await dbContext.Payments.AsNoTracking().SingleAsync(item => item.Id == payment.Id);
        var storedRefund =
            await dbContext.CommerceRefundRequests.AsNoTracking().SingleAsync(item => item.Id == refund.Id);
        var matchingEvents = await dbContext.PaymentEvents.AsNoTracking()
            .CountAsync(item =>
                item.TenantId == seed.TenantId &&
                item.Provider == PaymentProviders.WechatPay &&
                item.EventType == "refund" &&
                item.EventId == "refund-event-1");
        var refundEvents = await dbContext.CommerceRefundEvents.AsNoTracking()
            .CountAsync(item => item.TenantId == seed.TenantId && item.RefundRequestId == refund.Id);

        Assert.Equal(400, storedOrder.RefundedAmountCents);
        Assert.Equal(OrderStatus.PartiallyRefunded, storedOrder.Status);
        Assert.Equal(400, storedPayment.RefundedAmountCents);
        Assert.Equal(PaymentStatus.PartiallyRefunded, storedPayment.Status);
        Assert.Equal(CommerceRefundStatus.Succeeded, storedRefund.Status);
        Assert.Equal("wx-refund-notify-1", storedRefund.ProviderRefundNo);
        Assert.Equal(1, matchingEvents);
        Assert.Equal(1, refundEvents);
    }

    [Fact]
    public async Task Admin_can_create_review_and_report_adjustment_vouchers()
    {
        await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
        var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
        var order = NewPaidOrder(seed.TenantId, seed.UserId, null, "ADJUSTMENT-ORDER");
        var payment = new Payment
        {
            TenantId = seed.TenantId,
            OrderId = order.Id,
            Provider = PaymentProviders.WechatPay,
            Method = "jsapi",
            Status = PaymentStatus.Paid,
            AmountCents = order.AmountCents,
            ProviderTradeNo = "adjustment-trade",
            PaidAt = DateTimeOffset.UtcNow
        };
        await factory.SeedAsync(order, payment);
        using var client = factory.CreateClient();
        await LoginAsync(client, seed);

        var createResponse = await client.PostAsJsonAsync(
            "/api/tenant/commerce/adjustment-vouchers",
            new CreateAdjustmentVoucherDto
            {
                OrderId = order.Id,
                PaymentId = payment.Id,
                Direction = CommerceAdjustmentDirection.IncreaseRevenue,
                AmountCents = 100,
                Reason = "manual reconciliation adjustment",
                ProofAssetKey = "proofs/adjustment.txt"
            });
        var created = await createResponse.Content.ReadFromJsonAsync<CommerceAdjustmentVoucher>(JsonOptions);
        var pendingResponse = await client.PostAsJsonAsync(
            "/api/tenant/commerce/adjustment-vouchers/status",
            new UpdateAdjustmentVoucherStatusDto
            {
                VoucherId = created!.Id,
                Status = CommerceAdjustmentVoucherStatus.PendingReview,
                Note = "submit"
            });
        var approvedResponse = await client.PostAsJsonAsync(
            "/api/tenant/commerce/adjustment-vouchers/status",
            new UpdateAdjustmentVoucherStatusDto
            {
                VoucherId = created.Id,
                Status = CommerceAdjustmentVoucherStatus.Approved,
                Note = "approved"
            });
        var detailResponse =
            await client.GetAsync($"/api/tenant/commerce/adjustment-vouchers/detail?voucherId={created.Id}");
        var eventsResponse =
            await client.GetAsync($"/api/tenant/commerce/adjustment-vouchers/events?voucherId={created.Id}");
        var listResponse = await client.GetAsync("/api/tenant/commerce/adjustment-vouchers?status=approved");
        var reportResponse = await client.GetAsync("/api/tenant/commerce/adjustment-vouchers/report");

        Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
        Assert.Equal(HttpStatusCode.OK, pendingResponse.StatusCode);
        Assert.Equal(HttpStatusCode.OK, approvedResponse.StatusCode);
        Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
        Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode);
        Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
        Assert.Contains("increaseRevenueCents", await reportResponse.Content.ReadAsStringAsync(),
            StringComparison.OrdinalIgnoreCase);

        using var scope = factory.CreateSystemScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
        Assert.Equal(CommerceAdjustmentVoucherStatus.Approved, await dbContext.CommerceAdjustmentVouchers
            .Where(item => item.Id == created.Id)
            .Select(item => item.Status)
            .SingleAsync());
        Assert.Equal(3,
            await dbContext.CommerceAdjustmentVoucherEvents.CountAsync(item => item.VoucherId == created.Id));
        Assert.True(await dbContext.AuditLogs.AnyAsync(item =>
            item.TenantId == seed.TenantId &&
            item.Action == "commerce.adjustment_voucher.status_changed"));
    }

    private static async Task<LoginSeed> SeedLoginUserAsync(ApiTestFactory factory, TenantRole role)
    {
        var tenantId = Guid.NewGuid();
        var userId = Guid.NewGuid();
        var phone = "13800000000";
        await factory.SeedAsync(
            new Tenant
            {
                Id = tenantId,
                Slug = tenantId.ToString("N"),
                Name = "Tenant Commerce"
            },
            new User
            {
                Id = userId,
                Phone = phone,
                Name = "Tenant Commerce User"
            }.WithTestPassword(),
            new TenantMembership
            {
                TenantId = tenantId,
                UserId = userId,
                Role = role,
                Status = MembershipStatus.Active
            });

        return new LoginSeed(tenantId, userId, phone);
    }

    private static async Task LoginAsync(HttpClient client, LoginSeed seed)
    {
        client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
    }

    private static Order NewPaidOrder(Guid tenantId, Guid userId, Guid? regionId, string orderNo)
    {
        return new Order
        {
            TenantId = tenantId,
            UserId = userId,
            RegionId = regionId,
            OrderNo = orderNo,
            Status = OrderStatus.Paid,
            AmountCents = 1_000,
            PaidAt = DateTimeOffset.UtcNow
        };
    }

    private static async Task SetAdminDataScopeAsync(ApiTestFactory factory, Guid tenantId, object value)
    {
        using var scope = factory.CreateSystemScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
        var role = await dbContext.TenantBackendRoles.SingleAsync(item =>
            item.TenantId == tenantId && item.Code == "integration_test_admin");
        role.DataScope = JsonSerializer.SerializeToElement(value);
        await dbContext.SaveChangesAsync();
    }

    private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone);

    private sealed class FakePaymentGateway : IPaymentProviderGateway
    {
        public Task<CreatePaymentProviderResult> CreatePaymentAsync(
            string provider,
            CreatePaymentProviderRequest request,
            CancellationToken cancellationToken = default)
        {
            throw new NotSupportedException();
        }

        public Task<PaymentNotificationResult> ParsePaymentNotificationAsync(
            string provider,
            PaymentNotificationRequest request,
            CancellationToken cancellationToken = default)
        {
            throw new NotSupportedException();
        }
    }
}