using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;

namespace Tiku.Infrastructure.Commerce;

internal sealed class CommerceAdminService(
    TikuDbContext dbContext,
    ITenantSecretProtector tenantSecretProtector,
    ITenantExternalProviderConfigService providerConfigService,
    ICurrentAccessContext currentAccessContext,
    IBackgroundJobService backgroundJobService) : ICommerceAdminService
{
    public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
        CommerceAdminActor actor,
        CommerceAdminQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var accounts = await providerConfigService.GetProvidersAsync(
            actor.TenantId,
            TenantExternalProviderCapability.Payment,
            string.IsNullOrWhiteSpace(query.Provider) ? null : NormalizeProvider(query.Provider),
            query.Limit,
            cancellationToken);

        return accounts.Select(ToPaymentAccountItem).ToArray();
    }

    public async Task<TenantPaymentProviderItem> UpsertPaymentAccountAsync(
        CommerceAdminActor actor,
        UpsertPaymentAccountCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var account = await providerConfigService.UpsertProviderAsync(
            actor.TenantId,
            new UpsertTenantExternalProviderCommand(
                TenantExternalProviderCapability.Payment,
                command.Provider,
                command.Status,
                command.DisplayName,
                command.SecretRef,
                command.Priority,
                WithPaymentMode(command.ConfigPublic, command.Mode),
                JsonObjectOrDefault(default)),
            cancellationToken);

        return ToPaymentAccountItem(account);
    }

    public async Task<TenantSecretItem> UpsertTenantSecretAsync(
        CommerceAdminActor actor,
        UpsertTenantSecretCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var secretRef = string.IsNullOrWhiteSpace(command.SecretRef)
            ? $"tenant_secrets:{command.Purpose}:{NormalizeProvider(command.Provider)}:{command.SecretKey}"
            : command.SecretRef.Trim();
        var provider = NormalizeProvider(command.Provider);
        var secret = await dbContext.TenantSecrets
            .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.SecretRef == secretRef, cancellationToken);
        if (secret is null)
        {
            secret = new TenantSecret
            {
                TenantId = actor.TenantId,
                SecretRef = secretRef
            };
            dbContext.TenantSecrets.Add(secret);
        }
        else
        {
            secret.RotatedAt = DateTimeOffset.UtcNow;
        }

        var protectedPayload = tenantSecretProtector.Protect(
            actor.TenantId,
            secretRef,
            JsonObjectOrDefault(command.SecretPayload));

        secret.Purpose = command.Purpose.Trim();
        secret.Provider = provider;
        secret.SecretKey = command.SecretKey.Trim();
        secret.Status = command.Status;
        secret.EncryptionKeyId = protectedPayload.KeyId;
        secret.EncryptedPayload = protectedPayload.Ciphertext;
        secret.EncryptionNonce = protectedPayload.Nonce;
        secret.EncryptionTag = protectedPayload.Tag;
        secret.ExpiresAt = command.ExpiresAt;

        await dbContext.SaveChangesAsync(cancellationToken);
        return ToSecretItem(secret);
    }

    public async Task<AdminOrderList> GetOrdersAsync(
        CommerceAdminActor actor,
        CommerceAdminQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var orders = dbContext.Orders.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                item => item.UserId == actor.UserId,
                item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
        if (!string.IsNullOrWhiteSpace(query.Status))
        {
            orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
        }

        var items = await orders
            .OrderByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new AdminOrderList(items.Select(ToOrderItem).ToArray());
    }

    public async Task<AdminPaymentList> GetPaymentsAsync(
        CommerceAdminActor actor,
        CommerceAdminQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var scopedOrders = dbContext.Orders.AsNoTracking()
            .Where(order => order.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                order => order.UserId == actor.UserId,
                order => order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value));
        var payments = from payment in dbContext.Payments.AsNoTracking()
                       join order in scopedOrders
                           on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id }
                       where payment.TenantId == actor.TenantId
                       select new { payment, order.OrderNo };
        if (!string.IsNullOrWhiteSpace(query.Provider))
        {
            var provider = NormalizeProvider(query.Provider);
            payments = payments.Where(item => item.payment.Provider == provider);
        }

        if (!string.IsNullOrWhiteSpace(query.Status))
        {
            payments = payments.Where(item => item.payment.Status == ParsePaymentStatus(query.Status));
        }

        var rows = await payments
            .OrderByDescending(item => item.payment.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new AdminPaymentList(rows.Select(item => ToPaymentItem(item.payment, item.OrderNo)).ToArray());
    }

    public async Task<CodeBatchItem> CreateCodeBatchAsync(
        CommerceAdminActor actor,
        CreateCodeBatchCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        if (command.TotalCount is < 1 or > 1000)
        {
            throw new CommerceException("Code batch total count must be between 1 and 1000.", "invalid_code_batch_count");
        }

        if (command.Days <= 0)
        {
            throw new CommerceException("Activation code days must be positive.", "invalid_activation_days");
        }

        if (command.RegionId.HasValue)
        {
            var regionExists = await dbContext.Regions
                .AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken);
            if (!regionExists)
            {
                throw new CommerceException("Region was not found.", "region_not_found");
            }
        }

        var batch = new CodeBatch
        {
            TenantId = actor.TenantId,
            RegionId = command.RegionId,
            CreatedBy = actor.UserId,
            Name = command.Name.Trim(),
            SaleType = command.SaleType?.Trim(),
            Channel = command.Channel?.Trim(),
            DefaultUnitPriceCents = command.DefaultUnitPriceCents ?? 0,
            CostPriceCents = command.CostPriceCents ?? 0,
            TotalCount = command.TotalCount,
            Days = command.Days,
            IssuedAt = DateTimeOffset.UtcNow,
            Remark = command.Remark
        };
        dbContext.CodeBatches.Add(batch);
        for (var index = 0; index < command.TotalCount; index++)
        {
            dbContext.ActivationCodes.Add(new ActivationCode
            {
                TenantId = actor.TenantId,
                BatchId = batch.Id,
                Code = GenerateActivationCode(),
                Days = command.Days,
                SaleType = batch.SaleType,
                UnitPriceCents = batch.DefaultUnitPriceCents,
                Remark = batch.Remark
            });
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        return ToCodeBatchItem(batch);
    }

    public async Task<ActivationCodeList> GetActivationCodesAsync(
        CommerceAdminActor actor,
        CommerceAdminQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var codes = dbContext.ActivationCodes.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId);
        if (!string.IsNullOrWhiteSpace(query.Status))
        {
            var used = string.Equals(query.Status, "used", StringComparison.OrdinalIgnoreCase);
            codes = codes.Where(item => item.IsUsed == used);
        }

        var items = await codes
            .OrderByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new ActivationCodeList(items.Select(ToActivationCodeItem).ToArray());
    }

    public async Task<ActivationCodeItem> RedeemActivationCodeAsync(
        CommerceAdminActor actor,
        RedeemActivationCodeCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var code = await dbContext.ActivationCodes
            .SingleOrDefaultAsync(item =>
                item.TenantId == actor.TenantId &&
                item.Code == command.Code.Trim(),
                cancellationToken)
            ?? throw new CommerceException("Activation code was not found.", "activation_code_not_found");
        if (code.IsUsed)
        {
            throw new CommerceException("Activation code has already been used.", "activation_code_used");
        }

        var userIsMember = await dbContext.TenantMemberships.AnyAsync(item =>
            item.TenantId == actor.TenantId &&
            item.UserId == command.UserId &&
            item.Status == MembershipStatus.Active,
            cancellationToken);
        if (!userIsMember)
        {
            throw new CommerceException("Target user is not a tenant member.", "tenant_member_not_found");
        }

        code.IsUsed = true;
        code.UsedBy = command.UserId;
        code.UsedRegionId = command.RegionId;
        code.UsedAt = DateTimeOffset.UtcNow;
        dbContext.Entitlements.Add(new Entitlement
        {
            TenantId = actor.TenantId,
            UserId = command.UserId,
            EntitlementType = "svip",
            SourceType = "activation_code",
            SourceId = code.Id,
            StartsAt = DateTimeOffset.UtcNow,
            ExpiresAt = DateTimeOffset.UtcNow.AddDays(code.Days),
            Status = EntitlementStatus.Active,
            Metadata = JsonSerializer.SerializeToElement(new { code.Code, code.BatchId })
        });

        await dbContext.SaveChangesAsync(cancellationToken);
        return ToActivationCodeItem(code);
    }

    public async Task<TenantPointTaskList> GetPointTasksAsync(
        CommerceAdminActor actor,
        TenantPointQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var tasks = dbContext.PointActivityTasks.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId);
        if (!string.IsNullOrWhiteSpace(query.Status))
        {
            tasks = tasks.Where(item => item.Status == ParsePointTaskStatus(query.Status));
        }

        var items = await tasks
            .OrderBy(item => item.SortOrder)
            .ThenByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new TenantPointTaskList(items);
    }

    public async Task<PointActivityTask> UpsertPointTaskAsync(
        CommerceAdminActor actor,
        UpsertPointTaskCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        if (command.Points <= 0 || command.MaxClaimsPerUser <= 0)
        {
            throw new CommerceException("Point task points and claim limit must be positive.", "invalid_point_task");
        }

        var task = command.Id.HasValue
            ? await dbContext.PointActivityTasks.SingleOrDefaultAsync(
                item => item.TenantId == actor.TenantId && item.Id == command.Id.Value,
                cancellationToken)
            : await dbContext.PointActivityTasks.SingleOrDefaultAsync(
                item => item.TenantId == actor.TenantId && item.TaskKey == command.TaskKey.Trim(),
                cancellationToken);
        if (task is null)
        {
            task = new PointActivityTask { TenantId = actor.TenantId };
            dbContext.PointActivityTasks.Add(task);
        }

        task.TaskKey = command.TaskKey.Trim();
        task.Title = command.Title.Trim();
        task.Description = command.Description?.Trim();
        task.TaskType = command.TaskType;
        task.Status = command.Status;
        task.Points = command.Points;
        task.MaxClaimsPerUser = command.MaxClaimsPerUser;
        task.StartsAt = command.StartsAt;
        task.EndsAt = command.EndsAt;
        task.SortOrder = command.SortOrder;
        task.Rules = JsonObjectOrDefault(command.Rules);
        task.Metadata = JsonObjectOrDefault(command.Metadata);

        await dbContext.SaveChangesAsync(cancellationToken);
        return task;
    }

    public async Task<TenantPointClaimList> GetPointClaimsAsync(
        CommerceAdminActor actor,
        TenantPointQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var claims = dbContext.PointActivityClaims.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId);
        if (query.UserId.HasValue)
        {
            claims = claims.Where(item => item.UserId == query.UserId.Value);
        }

        var items = await claims
            .OrderByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new TenantPointClaimList(items);
    }

    public async Task<TenantPointExchangeItemList> GetPointExchangeItemsAsync(
        CommerceAdminActor actor,
        TenantPointQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var items = dbContext.PointExchangeItems.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId);
        if (!string.IsNullOrWhiteSpace(query.Status))
        {
            items = items.Where(item => item.Status == ParsePointExchangeItemStatus(query.Status));
        }

        if (query.RegionId.HasValue)
        {
            items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value);
        }

        var result = await items
            .OrderBy(item => item.SortOrder)
            .ThenByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new TenantPointExchangeItemList(result);
    }

    public async Task<PointExchangeItem> UpsertPointExchangeItemAsync(
        CommerceAdminActor actor,
        UpsertPointExchangeItemCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        if (command.PointsCost <= 0)
        {
            throw new CommerceException("Point exchange item cost must be positive.", "invalid_point_exchange_item");
        }

        var item = command.Id.HasValue
            ? await dbContext.PointExchangeItems.SingleOrDefaultAsync(
                entry => entry.TenantId == actor.TenantId && entry.Id == command.Id.Value,
                cancellationToken)
            : await dbContext.PointExchangeItems.SingleOrDefaultAsync(
                entry => entry.TenantId == actor.TenantId && entry.ItemKey == command.ItemKey.Trim(),
                cancellationToken);
        if (item is null)
        {
            item = new PointExchangeItem { TenantId = actor.TenantId };
            dbContext.PointExchangeItems.Add(item);
        }

        item.RegionId = command.RegionId;
        item.ItemKey = command.ItemKey.Trim();
        item.Name = command.Name.Trim();
        item.Description = command.Description?.Trim();
        item.ItemType = command.ItemType;
        item.Status = command.Status;
        item.PointsCost = command.PointsCost;
        item.Stock = command.Stock;
        item.Days = command.Days;
        item.SortOrder = command.SortOrder;
        item.StartsAt = command.StartsAt;
        item.EndsAt = command.EndsAt;
        item.FulfillmentPayload = JsonObjectOrDefault(command.FulfillmentPayload);
        item.Metadata = JsonObjectOrDefault(command.Metadata);

        await dbContext.SaveChangesAsync(cancellationToken);
        return item;
    }

    public async Task<TenantPointExchangeOrderList> GetPointExchangeOrdersAsync(
        CommerceAdminActor actor,
        TenantPointQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var orders = dbContext.PointExchangeOrders.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId);
        if (query.UserId.HasValue)
        {
            orders = orders.Where(item => item.UserId == query.UserId.Value);
        }

        if (!string.IsNullOrWhiteSpace(query.Status))
        {
            orders = orders.Where(item => item.Status == ParsePointExchangeOrderStatus(query.Status));
        }

        var result = await orders
            .OrderByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new TenantPointExchangeOrderList(result);
    }

    public async Task<PointExchangeOrder> UpdatePointExchangeOrderStatusAsync(
        CommerceAdminActor actor,
        UpdatePointExchangeOrderStatusCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var order = await dbContext.PointExchangeOrders
            .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId, cancellationToken)
            ?? throw new CommerceException("Point exchange order was not found.", "point_exchange_order_not_found");
        order.Status = command.Status;
        if (command.Status == PointExchangeOrderStatus.Completed)
        {
            order.CompletedAt ??= DateTimeOffset.UtcNow;
            order.CancelledAt = null;
        }
        else if (command.Status == PointExchangeOrderStatus.Cancelled)
        {
            order.CancelledAt ??= DateTimeOffset.UtcNow;
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        return order;
    }

    public async Task<TenantCouponList> GetCouponsAsync(
        CommerceAdminActor actor,
        CommerceAdminQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var coupons = dbContext.Coupons.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId);
        var items = await coupons
            .OrderByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new TenantCouponList(items);
    }

    public async Task<Coupon> UpsertCouponAsync(
        CommerceAdminActor actor,
        UpsertCouponCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var coupon = command.Id.HasValue
            ? await dbContext.Coupons.SingleOrDefaultAsync(
                item => item.TenantId == actor.TenantId && item.Id == command.Id.Value,
                cancellationToken)
            : await dbContext.Coupons.SingleOrDefaultAsync(
                item => item.TenantId == actor.TenantId && item.Code == command.Code.Trim(),
                cancellationToken);
        if (coupon is null)
        {
            coupon = new Coupon { TenantId = actor.TenantId };
            dbContext.Coupons.Add(coupon);
        }

        coupon.Code = command.Code.Trim();
        coupon.PlanId = command.PlanId;
        coupon.DiscountType = command.DiscountType;
        coupon.DiscountValue = command.DiscountValue;
        coupon.ValidFrom = command.ValidFrom;
        coupon.ValidTo = command.ValidTo;
        coupon.MaxUses = command.MaxUses;
        coupon.Source = command.Source?.Trim();
        coupon.Remark = command.Remark;

        await dbContext.SaveChangesAsync(cancellationToken);
        return coupon;
    }

    public async Task<TenantCouponRedemptionList> GetCouponRedemptionsAsync(
        CommerceAdminActor actor,
        CommerceAdminQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var redemptions = dbContext.CouponRedemptions.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId);
        if (!string.IsNullOrWhiteSpace(query.Status))
        {
            redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status));
        }

        var items = await redemptions
            .OrderByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new TenantCouponRedemptionList(items);
    }

    public async Task<TenantCouponReport> GetCouponReportAsync(
        CommerceAdminActor actor,
        CommerceAdminQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var couponCount = await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken);
        var redemptions = dbContext.CouponRedemptions.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId);
        var claimedCount = await redemptions.CountAsync(cancellationToken);
        var usedCount = await redemptions.CountAsync(item => item.Status == CouponRedemptionStatus.Used, cancellationToken);
        var discountApplied = await redemptions
            .Where(item => item.Status == CouponRedemptionStatus.Used)
            .SumAsync(item => item.DiscountAppliedCents, cancellationToken) ?? 0;
        return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied);
    }

    public async Task<TenantRefundList> GetRefundsAsync(
        CommerceAdminActor actor,
        CommerceAdminQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var refunds = dbContext.CommerceRefundRequests.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
                    order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
                item => dbContext.Orders.Any(order =>
                    order.TenantId == actor.TenantId &&
                    order.Id == item.OrderId &&
                    order.RegionId.HasValue &&
                    regionIds.Contains(order.RegionId.Value)));
        if (!string.IsNullOrWhiteSpace(query.Status))
        {
            refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status));
        }

        var items = await refunds
            .OrderByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new TenantRefundList(items);
    }

    public async Task<CommerceRefundRequest> CreateRefundRequestAsync(
        CommerceAdminActor actor,
        CreateRefundRequestCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var order = await dbContext.Orders
            .Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId)
            .ApplyDataScope(
                scope,
                item => item.UserId == actor.UserId,
                item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))
            .SingleOrDefaultAsync(cancellationToken)
            ?? throw new CommerceException("Order was not found.", "order_not_found");
        if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded))
        {
            throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable");
        }

        if (command.AmountCents <= 0 || command.AmountCents > order.AmountCents - order.RefundedAmountCents)
        {
            throw new CommerceException("Refund amount is invalid.", "invalid_refund_amount");
        }

        if (command.PaymentId.HasValue)
        {
            var paymentExists = await dbContext.Payments.AnyAsync(
                item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && item.OrderId == order.Id,
                cancellationToken);
            if (!paymentExists)
            {
                throw new CommerceException("Payment was not found.", "payment_not_found");
            }
        }

        var refund = new CommerceRefundRequest
        {
            TenantId = actor.TenantId,
            OrderId = order.Id,
            PaymentId = command.PaymentId,
            RequestedBy = actor.UserId,
            RefundNo = $"RF{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{RandomNumberGenerator.GetInt32(1000, 9999)}",
            Provider = order.PayProvider,
            Status = CommerceRefundStatus.Requested,
            AmountCents = command.AmountCents,
            Reason = command.Reason?.Trim(),
            EntitlementAction = command.EntitlementAction,
            Metadata = JsonObjectOrDefault(command.Metadata)
        };
        dbContext.CommerceRefundRequests.Add(refund);
        AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId, new { refund.AmountCents, refund.Reason });
        await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id, new { refund.RefundNo, refund.AmountCents }, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return refund;
    }

    public async Task<CommerceRefundRequest> UpdateRefundStatusAsync(
        CommerceAdminActor actor,
        UpdateRefundStatusCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var refund = await dbContext.CommerceRefundRequests
            .Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId)
            .ApplyDataScope(
                scope,
                item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
                    order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
                item => dbContext.Orders.Any(order =>
                    order.TenantId == actor.TenantId &&
                    order.Id == item.OrderId &&
                    order.RegionId.HasValue &&
                    regionIds.Contains(order.RegionId.Value)))
            .SingleOrDefaultAsync(cancellationToken)
            ?? throw new CommerceException("Refund request was not found.", "refund_not_found");
        var fromStatus = refund.Status;
        if (!IsAllowedRefundTransition(fromStatus, command.Status))
        {
            throw new CommerceException("Refund status transition is invalid.", "invalid_refund_transition");
        }

        refund.Status = command.Status;
        refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo)
            ? refund.ProviderRefundNo
            : command.ProviderRefundNo.Trim();
        switch (command.Status)
        {
            case CommerceRefundStatus.Approved:
                refund.ReviewedBy = actor.UserId;
                refund.ReviewedAt = DateTimeOffset.UtcNow;
                break;
            case CommerceRefundStatus.Processing:
                refund.ProcessedBy = actor.UserId;
                refund.ProcessedAt = DateTimeOffset.UtcNow;
                break;
            case CommerceRefundStatus.Succeeded:
                refund.SucceededAt = DateTimeOffset.UtcNow;
                await ApplyRefundToOrderAsync(refund, cancellationToken);
                break;
            case CommerceRefundStatus.Failed:
                refund.FailedAt = DateTimeOffset.UtcNow;
                refund.FailureReason = command.Reason;
                break;
            case CommerceRefundStatus.Cancelled or CommerceRefundStatus.Rejected:
                refund.CancelledAt = DateTimeOffset.UtcNow;
                break;
        }

        AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId, new { command.Reason, command.ProviderRefundNo });
        await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id, new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return refund;
    }

    public async Task<TenantRefundEventList> GetRefundEventsAsync(
        CommerceAdminActor actor,
        Guid refundRequestId,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var refundExists = await dbContext.CommerceRefundRequests
            .Where(item => item.TenantId == actor.TenantId && item.Id == refundRequestId)
            .ApplyDataScope(
                scope,
                item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
                    order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
                item => dbContext.Orders.Any(order =>
                    order.TenantId == actor.TenantId &&
                    order.Id == item.OrderId &&
                    order.RegionId.HasValue &&
                    regionIds.Contains(order.RegionId.Value)))
            .AnyAsync(cancellationToken);
        if (!refundExists)
        {
            throw new CommerceException("Refund request was not found.", "refund_not_found");
        }

        var items = await dbContext.CommerceRefundEvents.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId)
            .OrderBy(item => item.CreatedAt)
            .ToArrayAsync(cancellationToken);
        return new TenantRefundEventList(items);
    }

    public async Task<TenantReconciliationBatchList> GetReconciliationBatchesAsync(
        CommerceAdminActor actor,
        CommerceAdminQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var batches = dbContext.CommerceReconciliationBatches.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId);
        if (!string.IsNullOrWhiteSpace(query.Provider))
        {
            var provider = NormalizeProvider(query.Provider);
            batches = batches.Where(item => item.Provider == provider);
        }

        if (!string.IsNullOrWhiteSpace(query.Status))
        {
            batches = batches.Where(item => item.Status == ParseReconciliationBatchStatus(query.Status));
        }

        var items = await batches.OrderByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new TenantReconciliationBatchList(items);
    }

    public async Task<CommerceReconciliationBatch> CreateReconciliationBatchAsync(
        CommerceAdminActor actor,
        CreateReconciliationBatchCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var batch = new CommerceReconciliationBatch
        {
            TenantId = actor.TenantId,
            CreatedBy = actor.UserId,
            Provider = NormalizeProvider(command.Provider),
            BillDate = command.BillDate,
            BillType = command.BillType,
            Source = command.Source,
            SourceName = command.SourceName?.Trim(),
            SourceHash = command.SourceHash.Trim(),
            Status = ReconciliationBatchStatus.Pending,
            Metadata = JsonObjectOrDefault(command.Metadata)
        };
        dbContext.CommerceReconciliationBatches.Add(batch);
        await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate }, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return batch;
    }

    public async Task<TenantReconciliationIssueList> GetReconciliationIssuesAsync(
        CommerceAdminActor actor,
        CommerceAdminQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var issues = dbContext.CommerceReconciliationIssues.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId);
        if (!string.IsNullOrWhiteSpace(query.Provider))
        {
            var provider = NormalizeProvider(query.Provider);
            issues = issues.Where(item => item.Provider == provider);
        }

        if (!string.IsNullOrWhiteSpace(query.Status))
        {
            issues = issues.Where(item => item.Status == ParseReconciliationIssueStatus(query.Status));
        }

        var items = await issues.OrderByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new TenantReconciliationIssueList(items);
    }

    public async Task<CommerceReconciliationIssue> UpdateReconciliationIssueAsync(
        CommerceAdminActor actor,
        UpdateReconciliationIssueCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var issue = await dbContext.CommerceReconciliationIssues.SingleOrDefaultAsync(
            item => item.TenantId == actor.TenantId && item.Id == command.IssueId,
            cancellationToken) ?? throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found");
        var fromStatus = issue.Status;
        issue.Status = command.Status;
        issue.ResolutionType = command.ResolutionType;
        issue.ResolutionNote = command.Note?.Trim();
        issue.AssignedTo = command.AssignedTo ?? issue.AssignedTo;
        if (command.Status is ReconciliationIssueStatus.Resolved or ReconciliationIssueStatus.Ignored)
        {
            issue.ResolvedBy = actor.UserId;
            issue.ResolvedAt = DateTimeOffset.UtcNow;
        }

        dbContext.CommerceReconciliationIssueEvents.Add(new CommerceReconciliationIssueEvent
        {
            TenantId = actor.TenantId,
            IssueId = issue.Id,
            FromStatus = fromStatus,
            ToStatus = command.Status,
            EventType = "status_changed",
            ActorUserId = actor.UserId,
            Note = command.Note,
            Details = JsonSerializer.SerializeToElement(new { command.ResolutionType, command.AssignedTo })
        });
        await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues", issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return issue;
    }

    public async Task<TenantAdjustmentVoucherList> GetAdjustmentVouchersAsync(
        CommerceAdminActor actor,
        CommerceAdminQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var vouchers = dbContext.CommerceAdjustmentVouchers.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId);
        if (!string.IsNullOrWhiteSpace(query.Status))
        {
            vouchers = vouchers.Where(item => item.Status == ParseAdjustmentVoucherStatus(query.Status));
        }

        var items = await vouchers
            .OrderByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .ToArrayAsync(cancellationToken);
        return new TenantAdjustmentVoucherList(items);
    }

    public async Task<CommerceAdjustmentVoucher> GetAdjustmentVoucherAsync(
        CommerceAdminActor actor,
        Guid voucherId,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        return await dbContext.CommerceAdjustmentVouchers.AsNoTracking()
            .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId, cancellationToken)
            ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
    }

    public async Task<CommerceAdjustmentVoucher> CreateAdjustmentVoucherAsync(
        CommerceAdminActor actor,
        CreateAdjustmentVoucherCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Reason);
        await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId, "reconciliation_issue_not_found", cancellationToken);
        await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId, "reconciliation_batch_not_found", cancellationToken);
        await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId, "reconciliation_item_not_found", cancellationToken);
        await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found", cancellationToken);
        await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found", cancellationToken);
        await AssertOptionalReferenceAsync(dbContext.CommerceRefundRequests, actor.TenantId, command.RefundRequestId, "refund_not_found", cancellationToken);
        var voucher = new CommerceAdjustmentVoucher
        {
            TenantId = actor.TenantId,
            IssueId = command.IssueId,
            BatchId = command.BatchId,
            ItemId = command.ItemId,
            OrderId = command.OrderId,
            PaymentId = command.PaymentId,
            RefundRequestId = command.RefundRequestId,
            CreatedBy = actor.UserId,
            VoucherNo = $"ADJ{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}",
            Status = CommerceAdjustmentVoucherStatus.Draft,
            Direction = command.Direction,
            AmountCents = command.AmountCents,
            Currency = string.IsNullOrWhiteSpace(command.Currency) ? "CNY" : command.Currency.Trim().ToUpperInvariant(),
            Reason = command.Reason.Trim(),
            ProofAssetKey = string.IsNullOrWhiteSpace(command.ProofAssetKey) ? null : command.ProofAssetKey.Trim(),
            Metadata = JsonObjectOrDefault(command.Metadata)
        };
        dbContext.CommerceAdjustmentVouchers.Add(voucher);
        dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent
        {
            TenantId = actor.TenantId,
            VoucherId = voucher.Id,
            ToStatus = voucher.Status,
            ActorUserId = actor.UserId,
            Note = voucher.Reason,
            Details = JsonSerializer.SerializeToElement(new { voucher.Direction, voucher.AmountCents })
        });
        await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return voucher;
    }

    public async Task<CommerceAdjustmentVoucher> UpdateAdjustmentVoucherStatusAsync(
        CommerceAdminActor actor,
        UpdateAdjustmentVoucherStatusCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var voucher = await dbContext.CommerceAdjustmentVouchers.SingleOrDefaultAsync(
            item => item.TenantId == actor.TenantId && item.Id == command.VoucherId,
            cancellationToken) ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
        var fromStatus = voucher.Status;
        if (fromStatus != command.Status && !IsAllowedAdjustmentTransition(fromStatus, command.Status))
        {
            throw new CommerceException("Adjustment voucher status transition is invalid.", "invalid_adjustment_status_transition");
        }

        voucher.Status = command.Status;
        if (command.Status is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected)
        {
            voucher.ReviewedBy = actor.UserId;
            voucher.ReviewedAt ??= DateTimeOffset.UtcNow;
        }
        else if (command.Status is CommerceAdjustmentVoucherStatus.Closed or CommerceAdjustmentVoucherStatus.Void)
        {
            voucher.ClosedAt ??= DateTimeOffset.UtcNow;
        }

        dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent
        {
            TenantId = actor.TenantId,
            VoucherId = voucher.Id,
            FromStatus = fromStatus,
            ToStatus = command.Status,
            ActorUserId = actor.UserId,
            Note = command.Note,
            Details = JsonSerializer.SerializeToElement(new { })
        });
        await AddAuditAsync(actor, "commerce.adjustment_voucher.status_changed", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, From = fromStatus, To = command.Status }, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return voucher;
    }

    public async Task<TenantAdjustmentVoucherEventList> GetAdjustmentVoucherEventsAsync(
        CommerceAdminActor actor,
        Guid voucherId,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var exists = await dbContext.CommerceAdjustmentVouchers.AnyAsync(
            item => item.TenantId == actor.TenantId && item.Id == voucherId,
            cancellationToken);
        if (!exists)
        {
            throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
        }

        var events = await dbContext.CommerceAdjustmentVoucherEvents.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.VoucherId == voucherId)
            .OrderBy(item => item.CreatedAt)
            .ToArrayAsync(cancellationToken);
        return new TenantAdjustmentVoucherEventList(events);
    }

    public async Task<TenantAdjustmentReport> GetAdjustmentReportAsync(
        CommerceAdminActor actor,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        return new TenantAdjustmentReport(
            await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft, cancellationToken),
            await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview, cancellationToken),
            await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved, cancellationToken),
            await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed, cancellationToken),
            await dbContext.CommerceAdjustmentVouchers
                .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.IncreaseRevenue)
                .SumAsync(item => item.AmountCents, cancellationToken),
            await dbContext.CommerceAdjustmentVouchers
                .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.DecreaseRevenue)
                .SumAsync(item => item.AmountCents, cancellationToken));
    }

    public async Task<TenantReconciliationItemList> GetReconciliationItemsAsync(
        CommerceAdminActor actor,
        Guid batchId,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var batchExists = await dbContext.CommerceReconciliationBatches.AnyAsync(
            item => item.TenantId == actor.TenantId && item.Id == batchId,
            cancellationToken);
        if (!batchExists)
        {
            throw new CommerceException("Reconciliation batch was not found.", "reconciliation_batch_not_found");
        }

        var items = await dbContext.CommerceReconciliationItems.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.BatchId == batchId)
            .OrderBy(item => item.RowNo)
            .Take(500)
            .ToArrayAsync(cancellationToken);
        return new TenantReconciliationItemList(items);
    }

    public async Task<TenantReconciliationIssueEventList> GetReconciliationIssueEventsAsync(
        CommerceAdminActor actor,
        Guid issueId,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var issueExists = await dbContext.CommerceReconciliationIssues.AnyAsync(
            item => item.TenantId == actor.TenantId && item.Id == issueId,
            cancellationToken);
        if (!issueExists)
        {
            throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found");
        }

        var events = await dbContext.CommerceReconciliationIssueEvents.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.IssueId == issueId)
            .OrderBy(item => item.CreatedAt)
            .ToArrayAsync(cancellationToken);
        return new TenantReconciliationIssueEventList(events);
    }

    public async Task<TenantCommerceAnomalySummary> GetAnomalySummaryAsync(
        CommerceAdminActor actor,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var openRefunds = await dbContext.CommerceRefundRequests.CountAsync(
            item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Requested,
            cancellationToken);
        var processingRefunds = await dbContext.CommerceRefundRequests.CountAsync(
            item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Processing,
            cancellationToken);
        var openIssues = await dbContext.CommerceReconciliationIssues.CountAsync(
            item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && item.Status != ReconciliationIssueStatus.Ignored,
            cancellationToken);
        var failedBatches = await dbContext.CommerceReconciliationBatches.CountAsync(
            item => item.TenantId == actor.TenantId && item.Status == ReconciliationBatchStatus.Failed,
            cancellationToken);
        var pendingPayments = await dbContext.Payments.CountAsync(
            item => item.TenantId == actor.TenantId && item.Status == PaymentStatus.Pending,
            cancellationToken);
        var mismatchCount = await dbContext.CommerceReconciliationItems.CountAsync(
            item => item.TenantId == actor.TenantId &&
                    (item.MatchStatus == ReconciliationMatchStatus.AmountMismatch ||
                     item.MatchStatus == ReconciliationMatchStatus.StatusMismatch),
            cancellationToken);

        return new TenantCommerceAnomalySummary(
            openRefunds,
            processingRefunds,
            openIssues,
            failedBatches,
            pendingPayments,
            mismatchCount);
    }

    public async Task<ReconciliationImportPreview> PreviewReconciliationImportAsync(
        CommerceAdminActor actor,
        PreviewReconciliationImportCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        return BuildImportPreview(command.Provider, command.Rows);
    }

    public async Task<CommerceReconciliationBatch> ImportReconciliationAsync(
        CommerceAdminActor actor,
        ImportReconciliationCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var preview = BuildImportPreview(command.Provider, command.Rows);
        var batch = new CommerceReconciliationBatch
        {
            TenantId = actor.TenantId,
            CreatedBy = actor.UserId,
            Provider = NormalizeProvider(command.Provider),
            BillDate = command.BillDate,
            BillType = command.BillType,
            Source = ReconciliationSource.ManualUpload,
            SourceName = command.SourceName.Trim(),
            SourceHash = preview.SourceHash,
            Status = preview.InvalidCount == 0
                ? ReconciliationBatchStatus.Completed
                : ReconciliationBatchStatus.CompletedWithIssues,
            TotalCount = preview.TotalCount,
            MatchedCount = preview.TotalCount - preview.InvalidCount,
            MismatchCount = preview.InvalidCount,
            AmountCents = preview.AmountCents,
            RefundAmountCents = preview.RefundAmountCents,
            CompletedAt = DateTimeOffset.UtcNow,
            Metadata = JsonSerializer.SerializeToElement(new
            {
                preview.PaymentCount,
                preview.RefundCount,
                preview.InvalidCount
            })
        };
        dbContext.CommerceReconciliationBatches.Add(batch);
        var rowNo = 0;
        foreach (var row in EnumerateImportRows(command.Rows))
        {
            rowNo++;
            var item = CreateReconciliationItem(actor.TenantId, batch.Id, rowNo, NormalizeProvider(command.Provider), row);
            dbContext.CommerceReconciliationItems.Add(item);
            if (item.MatchStatus != ReconciliationMatchStatus.Matched)
            {
                dbContext.CommerceReconciliationIssues.Add(new CommerceReconciliationIssue
                {
                    TenantId = actor.TenantId,
                    BatchId = batch.Id,
                    Provider = item.Provider,
                    TransactionType = item.TransactionType,
                    IssueNo = $"RC{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{rowNo:0000}",
                    MatchStatus = item.MatchStatus switch
                    {
                        ReconciliationMatchStatus.MissingLocal => ReconciliationIssueMatchStatus.MissingLocal,
                        ReconciliationMatchStatus.MissingProvider => ReconciliationIssueMatchStatus.MissingProvider,
                        ReconciliationMatchStatus.Duplicate => ReconciliationIssueMatchStatus.Duplicate,
                        ReconciliationMatchStatus.StatusMismatch => ReconciliationIssueMatchStatus.StatusMismatch,
                        _ => ReconciliationIssueMatchStatus.AmountMismatch
                    },
                    Severity = item.Severity,
                    Status = ReconciliationIssueStatus.Open,
                    OrderNo = item.OrderNo,
                    RefundNo = item.RefundNo,
                    ProviderTradeNo = item.ProviderTradeNo,
                    ProviderRefundNo = item.ProviderRefundNo,
                    AmountCents = item.AmountCents,
                    RefundAmountCents = item.RefundAmountCents,
                    Summary = item.IssueCode,
                    CreatedBy = actor.UserId,
                    Metadata = item.Details
                });
            }
        }

        await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return batch;
    }

    public async Task<BackgroundJobItem> RequestProviderBillJobAsync(
        CommerceAdminActor actor,
        RequestProviderBillJobCommand command,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        var job = await backgroundJobService.EnqueueAsync(
            new CreateBackgroundJobCommand(
                actor.TenantId,
                "commerce_reconciliation",
                JsonSerializer.SerializeToElement(new
                {
                    provider = NormalizeProvider(command.Provider),
                    command.BillDate,
                    billType = command.BillType.ToString()
                }),
                command.RunAfter,
                5),
            cancellationToken);
        await AddAuditAsync(actor, "commerce.reconciliation.provider_bill_requested", "background_jobs", job.Id, new { command.Provider, command.BillDate, command.BillType }, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return job;
    }

    public async Task<IReadOnlyCollection<BackgroundJobItem>> GetProviderBillJobsAsync(
        CommerceAdminActor actor,
        CommerceAdminQuery query,
        CancellationToken cancellationToken = default)
    {
        await AssertAdminAsync(actor, cancellationToken);
        return await backgroundJobService.ListAsync(actor.TenantId, "commerce_reconciliation", Math.Clamp(query.Limit ?? 50, 1, 200), cancellationToken);
    }

    public async Task<CommerceRefundRequest> ProcessRefundNotificationAsync(
        Guid tenantId,
        RefundNotificationCommand command,
        CancellationToken cancellationToken = default)
    {
        var provider = NormalizeProvider(command.Provider);
        var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync(
            item => item.TenantId == tenantId && item.RefundNo == command.RefundNo,
            cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found");
        var eventId = string.IsNullOrWhiteSpace(command.EventId)
            ? $"{provider}:{command.RefundNo}:{command.Status}"
            : command.EventId.Trim();
        var duplicate = await dbContext.PaymentEvents.AnyAsync(
            item => item.TenantId == tenantId &&
                    item.Provider == provider &&
                    item.EventType == "refund" &&
                    item.EventId == eventId,
            cancellationToken);
        if (duplicate)
        {
            return refund;
        }

        dbContext.PaymentEvents.Add(new PaymentEvent
        {
            TenantId = tenantId,
            Provider = provider,
            EventType = "refund",
            EventId = eventId,
            SignatureValid = true,
            Payload = JsonObjectOrDefault(command.Payload),
            ProcessedAt = DateTimeOffset.UtcNow
        });
        var fromStatus = refund.Status;
        if (fromStatus != command.Status && IsAllowedRefundTransition(fromStatus, command.Status))
        {
            refund.Status = command.Status;
            refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo)
                ? refund.ProviderRefundNo
                : command.ProviderRefundNo.Trim();
            if (command.Status == CommerceRefundStatus.Succeeded)
            {
                refund.SucceededAt = DateTimeOffset.UtcNow;
                await ApplyRefundToOrderAsync(refund, cancellationToken);
            }
            else if (command.Status == CommerceRefundStatus.Failed)
            {
                refund.FailedAt = DateTimeOffset.UtcNow;
            }

            dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent
            {
                TenantId = tenantId,
                RefundRequestId = refund.Id,
                FromStatus = fromStatus,
                ToStatus = command.Status,
                EventType = "provider_notify",
                Details = JsonObjectOrDefault(command.Payload)
            });
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        return refund;
    }

    private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
    {
        var access = await currentAccessContext.GetAsync(cancellationToken);
        if (!access.IsCurrentTenantMember ||
            access.UserId != actor.UserId ||
            access.TenantId != actor.TenantId ||
            !access.HasTenantPermission(BackendPermissions.TenantCommerceOperate))
        {
            throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied");
        }
    }

    private async Task<CurrentDataScope> RequireDataScopeAsync(
        CommerceAdminActor actor,
        CancellationToken cancellationToken)
    {
        await AssertAdminAsync(actor, cancellationToken);
        return (await currentAccessContext.GetAsync(cancellationToken)).DataScope;
    }

    private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) =>
        new(
            item.Id,
            item.Provider,
            GetJsonString(item.ConfigPublic, "mode") ?? "TenantCollect",
            item.DisplayName,
            item.Status,
            item.SecretRef,
            item.Priority,
            item.ConfigPublic,
            item.CreatedAt,
            item.UpdatedAt);

    private static TenantSecretItem ToSecretItem(TenantSecret item) =>
        new(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef, item.Status.ToString(), item.RotatedAt, item.ExpiresAt, item.UpdatedAt);

    private static CodeBatchItem ToCodeBatchItem(CodeBatch item) =>
        new(item.Id, item.Name, item.TotalCount, item.Days ?? 0, item.RegionId, item.SaleType, item.Channel, item.DefaultUnitPriceCents, item.CostPriceCents, item.IssuedAt, item.Remark, item.CreatedAt);

    private static ActivationCodeItem ToActivationCodeItem(ActivationCode item) =>
        new(item.Id, item.BatchId, item.Code, item.Days, item.IsUsed, item.UsedBy, item.UsedAt, item.SaleType, item.SoldTo, item.Remark, item.CreatedAt);

    private static CommerceOrderItem ToOrderItem(Order order) =>
        new(order.Id, order.OrderNo, order.Status.ToString(), order.PlanId, order.RegionId, order.ProductType, order.ProductName, order.AmountCents, FormatCny(order.AmountCents), order.PayMethod, order.PayProvider, order.TradeNo, order.Days, order.PaidAt, order.CreatedAt, order.RawPayload);

    private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo) =>
        new(payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method, payment.Status.ToString(), payment.AmountCents, FormatCny(payment.AmountCents), payment.ProviderTradeNo, payment.PaidAt, JsonSerializer.SerializeToElement(new { }), payment.RawPayload);

    private static OrderStatus ParseOrderStatus(string? status) =>
        Enum.TryParse<OrderStatus>(NormalizeEnum(status), true, out var parsed)
            ? parsed
            : throw new CommerceException("Order status is invalid.", "invalid_order_status");

    private static PaymentStatus ParsePaymentStatus(string? status) =>
        Enum.TryParse<PaymentStatus>(NormalizeEnum(status), true, out var parsed)
            ? parsed
            : throw new CommerceException("Payment status is invalid.", "invalid_payment_status");

    private static PointActivityTaskStatus ParsePointTaskStatus(string? status) =>
        Enum.TryParse<PointActivityTaskStatus>(NormalizeEnum(status), true, out var parsed)
            ? parsed
            : throw new CommerceException("Point task status is invalid.", "invalid_point_task_status");

    private static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status) =>
        Enum.TryParse<PointExchangeItemStatus>(NormalizeEnum(status), true, out var parsed)
            ? parsed
            : throw new CommerceException("Point exchange item status is invalid.", "invalid_point_exchange_item_status");

    private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status) =>
        Enum.TryParse<PointExchangeOrderStatus>(NormalizeEnum(status), true, out var parsed)
            ? parsed
            : throw new CommerceException("Point exchange order status is invalid.", "invalid_point_exchange_order_status");

    private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) =>
        Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
            ? parsed
            : throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status");

    private static CommerceRefundStatus ParseRefundStatus(string? status) =>
        Enum.TryParse<CommerceRefundStatus>(NormalizeEnum(status), true, out var parsed)
            ? parsed
            : throw new CommerceException("Refund status is invalid.", "invalid_refund_status");

    private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status) =>
        Enum.TryParse<ReconciliationBatchStatus>(NormalizeEnum(status), true, out var parsed)
            ? parsed
            : throw new CommerceException("Reconciliation batch status is invalid.", "invalid_reconciliation_batch_status");

    private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status) =>
        Enum.TryParse<ReconciliationIssueStatus>(NormalizeEnum(status), true, out var parsed)
            ? parsed
            : throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status");

    private static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status) =>
        Enum.TryParse<CommerceAdjustmentVoucherStatus>(NormalizeEnum(status), true, out var parsed)
            ? parsed
            : throw new CommerceException("Adjustment voucher status is invalid.", "invalid_adjustment_voucher_status");

    private async Task AssertOptionalReferenceAsync<TEntity>(
        DbSet<TEntity> set,
        Guid tenantId,
        Guid? id,
        string code,
        CancellationToken cancellationToken)
        where TEntity : class
    {
        if (!id.HasValue)
        {
            return;
        }

        var exists = await set.AnyAsync(
            item => EF.Property<Guid>(item, "TenantId") == tenantId && EF.Property<Guid>(item, "Id") == id.Value,
            cancellationToken);
        if (!exists)
        {
            throw new CommerceException("Referenced commerce entity was not found.", code);
        }
    }

    private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken)
    {
        var order = await dbContext.Orders.SingleAsync(
            item => item.TenantId == refund.TenantId && item.Id == refund.OrderId,
            cancellationToken);
        if (order.RefundedAmountCents < order.AmountCents)
        {
            order.RefundedAmountCents = Math.Min(order.AmountCents, order.RefundedAmountCents + refund.AmountCents);
            order.Status = order.RefundedAmountCents >= order.AmountCents
                ? OrderStatus.Refunded
                : OrderStatus.PartiallyRefunded;
        }

        if (refund.PaymentId.HasValue)
        {
            var payment = await dbContext.Payments.SingleOrDefaultAsync(
                item => item.TenantId == refund.TenantId && item.Id == refund.PaymentId.Value,
                cancellationToken);
            if (payment is not null)
            {
                payment.RefundedAmountCents = Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents);
                payment.Status = payment.RefundedAmountCents >= payment.AmountCents
                    ? PaymentStatus.Refunded
                    : PaymentStatus.PartiallyRefunded;
            }
        }
    }

    private static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to)
    {
        return from switch
        {
            CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected or CommerceRefundStatus.Cancelled,
            CommerceRefundStatus.Approved => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
            CommerceRefundStatus.Processing => to is CommerceRefundStatus.Succeeded or CommerceRefundStatus.Failed,
            CommerceRefundStatus.Failed => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
            _ => false
        };
    }

    private static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from, CommerceAdjustmentVoucherStatus to)
    {
        return from switch
        {
            CommerceAdjustmentVoucherStatus.Draft => to is CommerceAdjustmentVoucherStatus.PendingReview or CommerceAdjustmentVoucherStatus.Void,
            CommerceAdjustmentVoucherStatus.PendingReview => to is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected or CommerceAdjustmentVoucherStatus.Void,
            CommerceAdjustmentVoucherStatus.Approved => to is CommerceAdjustmentVoucherStatus.Closed,
            _ => false
        };
    }

    private void AddRefundEvent(
        CommerceRefundRequest refund,
        CommerceRefundStatus? fromStatus,
        CommerceRefundStatus toStatus,
        string eventType,
        Guid actorUserId,
        object details)
    {
        dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent
        {
            TenantId = refund.TenantId,
            RefundRequestId = refund.Id,
            FromStatus = fromStatus,
            ToStatus = toStatus,
            EventType = eventType,
            ActorUserId = actorUserId,
            Details = JsonSerializer.SerializeToElement(details)
        });
    }

    private Task AddAuditAsync(
        CommerceAdminActor actor,
        string action,
        string targetType,
        Guid targetId,
        object details,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
        {
            TenantId = actor.TenantId,
            ActorUserId = actor.UserId,
            Action = action,
            TargetType = targetType,
            TargetId = targetId.ToString(),
            Details = JsonSerializer.SerializeToElement(details)
        });
        return Task.CompletedTask;
    }

    private static string NormalizeEnum(string? value) =>
        string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));

    private static string NormalizeProvider(string? provider)
    {
        var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
        return normalized switch
        {
            "wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay,
            "ali_pay" => PaymentProviders.Alipay,
            "" => throw new CommerceException("Provider is required.", "provider_required"),
            _ => normalized
        };
    }

    private static JsonElement WithPaymentMode(JsonElement element, string? mode)
    {
        var values = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
        if (element.ValueKind == JsonValueKind.Object)
        {
            foreach (var property in element.EnumerateObject())
            {
                values[property.Name] = property.Value.Clone();
            }
        }

        values["mode"] = JsonSerializer.SerializeToElement(
            string.IsNullOrWhiteSpace(mode) ? "TenantCollect" : mode.Trim());
        return JsonSerializer.SerializeToElement(values);
    }

    private static string? GetJsonString(JsonElement element, params string[] keys)
    {
        if (element.ValueKind != JsonValueKind.Object)
        {
            return null;
        }

        foreach (var key in keys)
        {
            if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
            {
                return value.GetString();
            }
        }

        return null;
    }

    private static JsonElement JsonObjectOrDefault(JsonElement element) =>
        element.ValueKind == JsonValueKind.Object
            ? element.Clone()
            : JsonSerializer.SerializeToElement(new { });

    private static void AssertNoSecrets(JsonElement element, string path)
    {
        if (element.ValueKind != JsonValueKind.Object)
        {
            return;
        }

        foreach (var property in element.EnumerateObject())
        {
            var key = property.Name.ToLowerInvariant();
            if (key is "secretref" or "secret_ref")
            {
                continue;
            }

            if (key.Contains("secret", StringComparison.Ordinal) ||
                key.Contains("privatekey", StringComparison.Ordinal) ||
                key is "appsecret" or "apiv3key" or "api_v3_key" or "accesskeysecret")
            {
                throw new CommerceException($"{path} cannot contain secrets.", "public_config_contains_secret");
            }

            AssertNoSecrets(property.Value, $"{path}.{property.Name}");
        }
    }

    private static ReconciliationImportPreview BuildImportPreview(string provider, JsonElement rows)
    {
        var normalizedProvider = NormalizeProvider(provider);
        var parsedRows = EnumerateImportRows(rows).ToArray();
        var paymentCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Payment);
        var refundCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Refund);
        var invalidCount = parsedRows.Count(row => row.MatchStatus != ReconciliationMatchStatus.Matched);
        var amountCents = parsedRows.Sum(row => row.AmountCents);
        var refundAmountCents = parsedRows.Sum(row => row.RefundAmountCents);
        var sourceHash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}"))).ToLowerInvariant();
        return new ReconciliationImportPreview(
            parsedRows.Length,
            paymentCount,
            refundCount,
            invalidCount,
            amountCents,
            refundAmountCents,
            sourceHash);
    }

    private sealed record ReconciliationImportRow(
        ReconciliationTransactionType TransactionType,
        string? ProviderTradeNo,
        string? ProviderRefundNo,
        string? OrderNo,
        string? RefundNo,
        int AmountCents,
        int RefundAmountCents,
        string? ProviderStatus,
        string? LocalStatus,
        ReconciliationMatchStatus MatchStatus,
        string? IssueCode,
        JsonElement Details);

    private static IEnumerable<ReconciliationImportRow> EnumerateImportRows(JsonElement rows)
    {
        if (rows.ValueKind != JsonValueKind.Array)
        {
            throw new CommerceException("Reconciliation rows must be an array.", "invalid_reconciliation_rows");
        }

        foreach (var row in rows.EnumerateArray())
        {
            if (row.ValueKind != JsonValueKind.Object)
            {
                yield return InvalidImportRow("row_not_object", row);
                continue;
            }

            var transactionType = Enum.TryParse<ReconciliationTransactionType>(
                NormalizeEnum(GetJsonString(row, "transactionType", "transaction_type") ?? "payment"),
                true,
                out var parsedTransactionType)
                ? parsedTransactionType
                : ReconciliationTransactionType.Payment;
            var amountCents = GetJsonInt(row, "amountCents", "amount_cents", "amount");
            var refundAmountCents = GetJsonInt(row, "refundAmountCents", "refund_amount_cents", "refundAmount");
            var providerTradeNo = GetJsonString(row, "providerTradeNo", "provider_trade_no", "tradeNo");
            var providerRefundNo = GetJsonString(row, "providerRefundNo", "provider_refund_no");
            var orderNo = GetJsonString(row, "orderNo", "order_no");
            var refundNo = GetJsonString(row, "refundNo", "refund_no");
            var issueCode = GetJsonString(row, "issueCode", "issue_code");
            var matchStatus = Enum.TryParse<ReconciliationMatchStatus>(
                NormalizeEnum(GetJsonString(row, "matchStatus", "match_status") ?? "matched"),
                true,
                out var parsedMatchStatus)
                ? parsedMatchStatus
                : ReconciliationMatchStatus.AmountMismatch;
            if (string.IsNullOrWhiteSpace(providerTradeNo) &&
                string.IsNullOrWhiteSpace(providerRefundNo) &&
                string.IsNullOrWhiteSpace(orderNo) &&
                string.IsNullOrWhiteSpace(refundNo))
            {
                matchStatus = ReconciliationMatchStatus.MissingLocal;
                issueCode ??= "missing_business_identifier";
            }

            yield return new ReconciliationImportRow(
                transactionType,
                providerTradeNo,
                providerRefundNo,
                orderNo,
                refundNo,
                Math.Max(0, amountCents),
                Math.Max(0, refundAmountCents),
                GetJsonString(row, "providerStatus", "provider_status"),
                GetJsonString(row, "localStatus", "local_status"),
                matchStatus,
                issueCode,
                row.Clone());
        }
    }

    private static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row) =>
        new(
            ReconciliationTransactionType.Payment,
            null,
            null,
            null,
            null,
            0,
            0,
            null,
            null,
            ReconciliationMatchStatus.AmountMismatch,
            issueCode,
            row.Clone());

    private static CommerceReconciliationItem CreateReconciliationItem(
        Guid tenantId,
        Guid batchId,
        int rowNo,
        string provider,
        ReconciliationImportRow row) =>
        new()
        {
            TenantId = tenantId,
            BatchId = batchId,
            RowNo = rowNo,
            Provider = provider,
            TransactionType = row.TransactionType,
            ProviderTradeNo = row.ProviderTradeNo,
            ProviderRefundNo = row.ProviderRefundNo,
            OrderNo = row.OrderNo,
            RefundNo = row.RefundNo,
            AmountCents = row.AmountCents,
            RefundAmountCents = row.RefundAmountCents,
            ProviderStatus = row.ProviderStatus,
            LocalStatus = row.LocalStatus,
            MatchStatus = row.MatchStatus,
            Severity = row.MatchStatus == ReconciliationMatchStatus.Matched ? NotificationSeverity.Info : NotificationSeverity.Warning,
            IssueCode = row.IssueCode,
            Details = row.Details
        };

    private static int GetJsonInt(JsonElement element, params string[] keys)
    {
        foreach (var key in keys)
        {
            if (!element.TryGetProperty(key, out var value))
            {
                continue;
            }

            if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
            {
                return number;
            }

            if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), CultureInfo.InvariantCulture, out var parsed))
            {
                return parsed;
            }
        }

        return 0;
    }

    private static string GenerateActivationCode()
    {
        Span<byte> bytes = stackalloc byte[8];
        RandomNumberGenerator.Fill(bytes);
        return $"TKU{Convert.ToHexString(bytes)}";
    }

    private static string FormatCny(int cents) =>
        (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
}
