using System.Globalization;
using System.Text.Json;
using MassTransit;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Application.App.Products.Messaging;
using TakeoutSaaS.Application.App.Products.Commands;
using TakeoutSaaS.Application.App.Products.Dto;
using TakeoutSaaS.Application.App.Products.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Products.Entities;
using TakeoutSaaS.Domain.Products.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.Shared.Web.Security;
using TakeoutSaaS.TenantApi.Contracts.Product;
using TakeoutSaaS.TenantApi.Services;

namespace TakeoutSaaS.TenantApi.Controllers;

/// <summary>
/// 租户端商品主接口。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/product")]
public sealed class ProductController(
    IMediator mediator,
    TakeoutAppDbContext dbContext,
    StoreContextService storeContextService,
    ProductSkuSaveService productSkuSaveService,
    IIdGenerator idGenerator,
    IPublishEndpoint publishEndpoint) : BaseApiController
{
    /// <summary>
    /// 商品列表。
    /// </summary>
    [HttpGet("list")]
    [ProducesResponseType(typeof(ApiResponse<ProductListResultResponse>), StatusCodes.Status200OK)]
    public async Task<ApiResponse<ProductListResultResponse>> List(
        [FromQuery] ProductListRequest request,
        CancellationToken cancellationToken)
    {
        var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
        await EnsureStoreAccessibleAsync(storeId, cancellationToken);

        var (status, isSoldOut) = ParseUiStatusFilter(request.Status);
        var kind = ParseKindOrNull(request.Kind);

        var result = await mediator.Send(new SearchProductsQuery
        {
            StoreId = storeId,
            CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.CategoryId),
            Keyword = request.Keyword,
            Status = status,
            IsSoldOut = isSoldOut,
            Kind = kind,
            Page = Math.Max(1, request.Page),
            PageSize = Math.Clamp(request.PageSize, 1, 200)
        }, cancellationToken);

        var products = result.Items.ToList();
        var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
        var labelNameMap = await BuildProductLabelNameMapAsync(
            products.Select(item => item.Id),
            storeId,
            cancellationToken);

        return ApiResponse<ProductListResultResponse>.Ok(new ProductListResultResponse
        {
            Items = products
                .Select(item => MapListItem(item, categoryNameLookup, labelNameMap.GetValueOrDefault(item.Id, [])))
                .ToList(),
            Total = result.TotalCount,
            Page = result.Page,
            PageSize = result.PageSize
        });
    }

    /// <summary>
    /// 商品详情。
    /// </summary>
    [HttpGet("detail")]
    [ProducesResponseType(typeof(ApiResponse<ProductDetailResponse>), StatusCodes.Status200OK)]
    public async Task<ApiResponse<ProductDetailResponse>> Detail(
        [FromQuery] ProductDetailRequest request,
        CancellationToken cancellationToken)
    {
        var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
        await EnsureStoreAccessibleAsync(storeId, cancellationToken);

        var productId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId));
        var product = await mediator.Send(new GetProductByIdQuery
        {
            ProductId = productId
        }, cancellationToken);

        if (product is null || product.StoreId != storeId)
        {
            return ApiResponse<ProductDetailResponse>.Error(ErrorCodes.NotFound, "商品不存在");
        }

        var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
        var labelNameMap = await BuildProductLabelNameMapAsync([product.Id], storeId, cancellationToken);
        var comboGroups = await BuildComboGroupResponsesAsync(product.Id, storeId, cancellationToken);
        var relationState = await LoadProductRelationStateAsync(product.Id, storeId, cancellationToken);
        return ApiResponse<ProductDetailResponse>.Ok(MapDetailItem(
            product,
            categoryNameLookup,
            labelNameMap.GetValueOrDefault(product.Id, []),
            comboGroups,
            relationState.SpecTemplateIds,
            relationState.AddonGroupIds,
            relationState.LabelIds,
            relationState.Skus));
    }

    /// <summary>
    /// 新增或编辑商品。
    /// </summary>
    [HttpPost("save")]
    [ProducesResponseType(typeof(ApiResponse<ProductDetailResponse>), StatusCodes.Status200OK)]
    public async Task<ApiResponse<ProductDetailResponse>> Save(
        [FromBody] SaveProductRequest request,
        CancellationToken cancellationToken)
    {
        var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
        await EnsureStoreAccessibleAsync(storeId, cancellationToken);

        var categoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId));
        var productId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id);
        var timedOnShelfAt = ParseTimedOnShelfAt(request.ShelfMode, request.TimedOnShelfAt);
        var normalizedStatus = ResolveStatusByShelfMode(request.ShelfMode, request.Status);
        var imageUrls = NormalizeImageUrls(request.ImageUrls);

        ProductDto? existing = null;
        if (productId.HasValue)
        {
            existing = await mediator.Send(new GetProductByIdQuery
            {
                ProductId = productId.Value
            }, cancellationToken);

            if (existing is null || existing.StoreId != storeId)
            {
                return ApiResponse<ProductDetailResponse>.Error(ErrorCodes.NotFound, "商品不存在");
            }
        }

        var kind = ResolveKindForSave(request.Kind, existing);
        var comboGroups = NormalizeComboGroups(request.ComboGroups, kind);

        var shouldReplaceSpecAddon = existing is null || request.SpecTemplateIds is not null || request.AddonGroupIds is not null;
        var shouldReplaceLabels = existing is null || request.LabelIds is not null;
        var shouldReplaceSkus = existing is null || request.Skus is not null;

        var specTemplateIds = shouldReplaceSpecAddon
            ? ParseSnowflakeListStrict(request.SpecTemplateIds, nameof(request.SpecTemplateIds))
            : [];
        var addonGroupIds = shouldReplaceSpecAddon
            ? ParseSnowflakeListStrict(request.AddonGroupIds, nameof(request.AddonGroupIds))
            : [];

        if (existing is not null &&
            shouldReplaceSpecAddon &&
            (request.SpecTemplateIds is null || request.AddonGroupIds is null))
        {
            var currentRelationState = await LoadProductRelationStateAsync(existing.Id, storeId, cancellationToken);
            if (request.SpecTemplateIds is null)
            {
                specTemplateIds = currentRelationState.SpecTemplateIds.ToList();
            }

            if (request.AddonGroupIds is null)
            {
                addonGroupIds = currentRelationState.AddonGroupIds.ToList();
            }
        }

        var labelIds = shouldReplaceLabels
            ? ParseSnowflakeListStrict(request.LabelIds, nameof(request.LabelIds))
            : [];
        var normalizedSkus = shouldReplaceSkus
            ? NormalizeSkus(request.Skus, request.Price, request.OriginalPrice, request.Stock)
            : [];

        var tagsJson = SerializeTagsJson(request.Tags);
        var soldoutMode = ResolveSoldoutModeForSave(existing, normalizedStatus, request.Status);
        var recoverAt = soldoutMode == ProductSoldoutMode.Timed ? existing?.RecoverAt : null;
        int? remainStock = soldoutMode.HasValue ? Math.Max(0, existing?.RemainStock ?? request.Stock) : null;
        var soldoutReason = soldoutMode.HasValue ? existing?.SoldoutReason : null;

        ProductDto? saved = null;
        var notFoundInTransaction = false;
        var executionStrategy = dbContext.Database.CreateExecutionStrategy();
        await executionStrategy.ExecuteAsync(async () =>
        {
            saved = null;
            notFoundInTransaction = false;
            dbContext.ChangeTracker.Clear();

            await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
            if (existing is null)
            {
                saved = await mediator.Send(new CreateProductCommand
                {
                    StoreId = storeId,
                    CategoryId = categoryId,
                    SpuCode = string.IsNullOrWhiteSpace(request.SpuCode) ? GenerateSpuCode() : request.SpuCode.Trim(),
                    Name = request.Name.Trim(),
                    Subtitle = request.Subtitle?.Trim(),
                    Description = request.Description?.Trim(),
                    Price = request.Price,
                    OriginalPrice = request.OriginalPrice.HasValue && request.OriginalPrice.Value > 0 ? request.OriginalPrice : null,
                    StockQuantity = Math.Max(0, request.Stock),
                    Status = normalizedStatus,
                    Kind = kind,
                    TagsJson = tagsJson,
                    TimedOnShelfAt = timedOnShelfAt,
                    SoldoutMode = soldoutMode,
                    RecoverAt = recoverAt,
                    RemainStock = remainStock,
                    SoldoutReason = soldoutReason,
                    SyncToPlatform = true,
                    NotifyManager = false,
                    CoverImage = imageUrls.FirstOrDefault(),
                    GalleryImages = string.Join(',', imageUrls),
                    SortWeight = Math.Max(0, request.SortWeight ?? 0),
                    WarningStock = request.WarningStock.HasValue ? Math.Max(0, request.WarningStock.Value) : null,
                    PackingFee = NormalizePackingFee(request.PackingFee)
                }, cancellationToken);
            }
            else
            {
                saved = await mediator.Send(new UpdateProductCommand
                {
                    ProductId = existing.Id,
                    StoreId = storeId,
                    CategoryId = categoryId,
                    SpuCode = string.IsNullOrWhiteSpace(request.SpuCode) ? existing.SpuCode : request.SpuCode.Trim(),
                    Name = request.Name.Trim(),
                    Subtitle = request.Subtitle?.Trim(),
                    Unit = existing.Unit,
                    Price = request.Price,
                    OriginalPrice = request.OriginalPrice.HasValue && request.OriginalPrice.Value > 0 ? request.OriginalPrice : null,
                    StockQuantity = Math.Max(0, request.Stock),
                    MaxQuantityPerOrder = existing.MaxQuantityPerOrder,
                    Status = normalizedStatus,
                    Kind = kind,
                    SalesMonthly = existing.SalesMonthly,
                    TagsJson = tagsJson,
                    SoldoutMode = soldoutMode,
                    RecoverAt = recoverAt,
                    RemainStock = remainStock,
                    SoldoutReason = soldoutReason,
                    SyncToPlatform = existing.SyncToPlatform,
                    NotifyManager = existing.NotifyManager,
                    TimedOnShelfAt = timedOnShelfAt,
                    CoverImage = imageUrls.FirstOrDefault() ?? existing.CoverImage,
                    GalleryImages = imageUrls.Count > 0 ? string.Join(',', imageUrls) : existing.GalleryImages,
                    Description = request.Description?.Trim(),
                    EnableDineIn = existing.EnableDineIn,
                    EnablePickup = existing.EnablePickup,
                    EnableDelivery = existing.EnableDelivery,
                    IsFeatured = existing.IsFeatured,
                    SortWeight = Math.Max(0, request.SortWeight ?? 0),
                    WarningStock = request.WarningStock.HasValue ? Math.Max(0, request.WarningStock.Value) : null,
                    PackingFee = NormalizePackingFee(request.PackingFee)
                }, cancellationToken);
            }

            if (saved is null)
            {
                notFoundInTransaction = true;
                return;
            }

            if (shouldReplaceSpecAddon)
            {
                await ReplaceSpecTemplateRelationsAsync(saved.Id, storeId, specTemplateIds, addonGroupIds, cancellationToken);
            }

            if (shouldReplaceLabels)
            {
                await ReplaceLabelRelationsAsync(saved.Id, storeId, labelIds, cancellationToken);
            }

            if (shouldReplaceSkus)
            {
                await ReplaceSkusAsync(saved.Id, storeId, normalizedSkus, specTemplateIds, cancellationToken);
            }

            await ReplaceComboGroupsAsync(saved.Id, storeId, kind, comboGroups, cancellationToken);
            await transaction.CommitAsync(cancellationToken);
        });

        if (notFoundInTransaction || saved is null)
        {
            return ApiResponse<ProductDetailResponse>.Error(ErrorCodes.NotFound, "商品不存在");
        }

        var savedProduct = saved;
        var detailComboGroups = await BuildComboGroupResponsesAsync(savedProduct.Id, storeId, cancellationToken);
        var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
        var savedRelationState = await LoadProductRelationStateAsync(savedProduct.Id, storeId, cancellationToken);
        var labelNameMap = await BuildProductLabelNameMapAsync([savedProduct.Id], storeId, cancellationToken);
        return ApiResponse<ProductDetailResponse>.Ok(MapDetailItem(
            savedProduct,
            categoryNameLookup,
            labelNameMap.GetValueOrDefault(savedProduct.Id, []),
            detailComboGroups,
            savedRelationState.SpecTemplateIds,
            savedRelationState.AddonGroupIds,
            savedRelationState.LabelIds,
            savedRelationState.Skus));
    }

    /// <summary>
    /// 异步保存商品（基础信息立即落库，SKU 走异步任务）。
    /// </summary>
    [HttpPost("save-async")]
    [ProducesResponseType(typeof(ApiResponse<SaveProductAsyncResponse>), StatusCodes.Status200OK)]
    public async Task<ApiResponse<SaveProductAsyncResponse>> SaveAsync(
        [FromBody] SaveProductRequest request,
        CancellationToken cancellationToken)
    {
        var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
        await EnsureStoreAccessibleAsync(storeId, cancellationToken);

        var categoryId = StoreApiHelpers.ParseRequiredSnowflake(request.CategoryId, nameof(request.CategoryId));
        var productId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id);
        var timedOnShelfAt = ParseTimedOnShelfAt(request.ShelfMode, request.TimedOnShelfAt);
        var normalizedStatus = ResolveStatusByShelfMode(request.ShelfMode, request.Status);
        var imageUrls = NormalizeImageUrls(request.ImageUrls);

        ProductDto? existing = null;
        if (productId.HasValue)
        {
            existing = await mediator.Send(new GetProductByIdQuery
            {
                ProductId = productId.Value
            }, cancellationToken);

            if (existing is null || existing.StoreId != storeId)
            {
                return ApiResponse<SaveProductAsyncResponse>.Error(ErrorCodes.NotFound, "商品不存在");
            }
        }

        var kind = ResolveKindForSave(request.Kind, existing);
        var comboGroups = NormalizeComboGroups(request.ComboGroups, kind);
        var shouldReplaceSpecAddon = existing is null || request.SpecTemplateIds is not null || request.AddonGroupIds is not null;
        var shouldReplaceLabels = existing is null || request.LabelIds is not null;

        var specTemplateIds = shouldReplaceSpecAddon
            ? ParseSnowflakeListStrict(request.SpecTemplateIds, nameof(request.SpecTemplateIds))
            : [];
        var addonGroupIds = shouldReplaceSpecAddon
            ? ParseSnowflakeListStrict(request.AddonGroupIds, nameof(request.AddonGroupIds))
            : [];

        if (existing is not null &&
            shouldReplaceSpecAddon &&
            (request.SpecTemplateIds is null || request.AddonGroupIds is null))
        {
            var currentRelationState = await LoadProductRelationStateAsync(existing.Id, storeId, cancellationToken);
            if (request.SpecTemplateIds is null)
            {
                specTemplateIds = currentRelationState.SpecTemplateIds.ToList();
            }

            if (request.AddonGroupIds is null)
            {
                addonGroupIds = currentRelationState.AddonGroupIds.ToList();
            }
        }

        var labelIds = shouldReplaceLabels
            ? ParseSnowflakeListStrict(request.LabelIds, nameof(request.LabelIds))
            : [];
        var normalizedSkus = request.Skus is { Count: > 0 }
            ? NormalizeSkus(request.Skus, request.Price, request.OriginalPrice, request.Stock)
            : [];

        var tagsJson = SerializeTagsJson(request.Tags);
        var soldoutMode = ResolveSoldoutModeForSave(existing, normalizedStatus, request.Status);
        var recoverAt = soldoutMode == ProductSoldoutMode.Timed ? existing?.RecoverAt : null;
        int? remainStock = soldoutMode.HasValue ? Math.Max(0, existing?.RemainStock ?? request.Stock) : null;
        var soldoutReason = soldoutMode.HasValue ? existing?.SoldoutReason : null;

        ProductDto? saved = null;
        var notFoundInTransaction = false;
        var executionStrategy = dbContext.Database.CreateExecutionStrategy();
        await executionStrategy.ExecuteAsync(async () =>
        {
            saved = null;
            notFoundInTransaction = false;
            dbContext.ChangeTracker.Clear();

            await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
            if (existing is null)
            {
                saved = await mediator.Send(new CreateProductCommand
                {
                    StoreId = storeId,
                    CategoryId = categoryId,
                    SpuCode = string.IsNullOrWhiteSpace(request.SpuCode) ? GenerateSpuCode() : request.SpuCode.Trim(),
                    Name = request.Name.Trim(),
                    Subtitle = request.Subtitle?.Trim(),
                    Description = request.Description?.Trim(),
                    Price = request.Price,
                    OriginalPrice = request.OriginalPrice.HasValue && request.OriginalPrice.Value > 0 ? request.OriginalPrice : null,
                    StockQuantity = Math.Max(0, request.Stock),
                    Status = normalizedStatus,
                    Kind = kind,
                    TagsJson = tagsJson,
                    TimedOnShelfAt = timedOnShelfAt,
                    SoldoutMode = soldoutMode,
                    RecoverAt = recoverAt,
                    RemainStock = remainStock,
                    SoldoutReason = soldoutReason,
                    SyncToPlatform = true,
                    NotifyManager = false,
                    CoverImage = imageUrls.FirstOrDefault(),
                    GalleryImages = string.Join(',', imageUrls),
                    SortWeight = Math.Max(0, request.SortWeight ?? 0),
                    WarningStock = request.WarningStock.HasValue ? Math.Max(0, request.WarningStock.Value) : null,
                    PackingFee = NormalizePackingFee(request.PackingFee)
                }, cancellationToken);
            }
            else
            {
                saved = await mediator.Send(new UpdateProductCommand
                {
                    ProductId = existing.Id,
                    StoreId = storeId,
                    CategoryId = categoryId,
                    SpuCode = string.IsNullOrWhiteSpace(request.SpuCode) ? existing.SpuCode : request.SpuCode.Trim(),
                    Name = request.Name.Trim(),
                    Subtitle = request.Subtitle?.Trim(),
                    Unit = existing.Unit,
                    Price = request.Price,
                    OriginalPrice = request.OriginalPrice.HasValue && request.OriginalPrice.Value > 0 ? request.OriginalPrice : null,
                    StockQuantity = Math.Max(0, request.Stock),
                    MaxQuantityPerOrder = existing.MaxQuantityPerOrder,
                    Status = normalizedStatus,
                    Kind = kind,
                    SalesMonthly = existing.SalesMonthly,
                    TagsJson = tagsJson,
                    SoldoutMode = soldoutMode,
                    RecoverAt = recoverAt,
                    RemainStock = remainStock,
                    SoldoutReason = soldoutReason,
                    SyncToPlatform = existing.SyncToPlatform,
                    NotifyManager = existing.NotifyManager,
                    TimedOnShelfAt = timedOnShelfAt,
                    CoverImage = imageUrls.FirstOrDefault() ?? existing.CoverImage,
                    GalleryImages = imageUrls.Count > 0 ? string.Join(',', imageUrls) : existing.GalleryImages,
                    Description = request.Description?.Trim(),
                    EnableDineIn = existing.EnableDineIn,
                    EnablePickup = existing.EnablePickup,
                    EnableDelivery = existing.EnableDelivery,
                    IsFeatured = existing.IsFeatured,
                    SortWeight = Math.Max(0, request.SortWeight ?? 0),
                    WarningStock = request.WarningStock.HasValue ? Math.Max(0, request.WarningStock.Value) : null,
                    PackingFee = NormalizePackingFee(request.PackingFee)
                }, cancellationToken);
            }

            if (saved is null)
            {
                notFoundInTransaction = true;
                return;
            }

            if (shouldReplaceSpecAddon)
            {
                await ReplaceSpecTemplateRelationsAsync(saved.Id, storeId, specTemplateIds, addonGroupIds, cancellationToken);
            }

            if (shouldReplaceLabels)
            {
                await ReplaceLabelRelationsAsync(saved.Id, storeId, labelIds, cancellationToken);
            }

            await ReplaceComboGroupsAsync(saved.Id, storeId, kind, comboGroups, cancellationToken);
            await transaction.CommitAsync(cancellationToken);
        });

        if (notFoundInTransaction || saved is null)
        {
            return ApiResponse<SaveProductAsyncResponse>.Error(ErrorCodes.NotFound, "商品不存在");
        }

        var savedProduct = saved;
        string? skuJobId = null;
        var skuJobStatus = "not_required";
        var resultMessage = "商品基础信息已保存";
        if (normalizedSkus.Count > 0)
        {
            var processingJob = await dbContext.ProductSkuSaveJobs
                .AsNoTracking()
                .Where(item => item.StoreId == storeId &&
                               item.ProductId == savedProduct.Id &&
                               (item.Status == ProductSkuSaveJobStatus.Queued || item.Status == ProductSkuSaveJobStatus.Running))
                .OrderByDescending(item => item.CreatedAt)
                .FirstOrDefaultAsync(cancellationToken);
            if (processingJob is not null)
            {
                skuJobId = processingJob.Id.ToString();
                skuJobStatus = ToProductSkuSaveJobStatusText(processingJob.Status);
                resultMessage = "检测到进行中的 SKU 保存任务，已复用任务";
            }
            else
            {
                var skuSpecTemplateIds = shouldReplaceSpecAddon
                    ? specTemplateIds
                    : (await LoadProductRelationStateAsync(savedProduct.Id, storeId, cancellationToken)).SpecTemplateIds.ToList();
                var createdJob = await CreateSkuSaveJobAsync(
                    storeId,
                    savedProduct.Id,
                    normalizedSkus,
                    skuSpecTemplateIds,
                    cancellationToken);
                skuJobId = createdJob.Id.ToString();
                skuJobStatus = ToProductSkuSaveJobStatusText(createdJob.Status);
                resultMessage = createdJob.Status == ProductSkuSaveJobStatus.Failed
                    ? "商品基础信息已保存，但 SKU 异步任务创建失败"
                    : "商品基础信息已保存，SKU 异步任务已创建";
            }
        }

        return ApiResponse<SaveProductAsyncResponse>.Ok(new SaveProductAsyncResponse
        {
            StoreId = storeId.ToString(),
            ProductId = savedProduct.Id.ToString(),
            SkuJobId = skuJobId,
            SkuJobStatus = skuJobStatus,
            Message = resultMessage
        });
    }

    /// <summary>
    /// 创建 SKU 异步保存任务。
    /// </summary>
    [HttpPost("sku-save-jobs")]
    [ProducesResponseType(typeof(ApiResponse<ProductSkuSaveJobResponse>), StatusCodes.Status200OK)]
    public async Task<ApiResponse<ProductSkuSaveJobResponse>> CreateSkuSaveJob(
        [FromBody] CreateProductSkuSaveJobRequest request,
        CancellationToken cancellationToken)
    {
        var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
        await EnsureStoreAccessibleAsync(storeId, cancellationToken);

        var productId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId));
        var existingProduct = await mediator.Send(new GetProductByIdQuery
        {
            ProductId = productId
        }, cancellationToken);
        if (existingProduct is null || existingProduct.StoreId != storeId)
        {
            return ApiResponse<ProductSkuSaveJobResponse>.Error(ErrorCodes.NotFound, "商品不存在");
        }

        if (request.Skus is null || request.Skus.Count == 0)
        {
            return ApiResponse<ProductSkuSaveJobResponse>.Error(ErrorCodes.BadRequest, "skus 不能为空");
        }

        var hasProcessingJob = await dbContext.ProductSkuSaveJobs
            .AsNoTracking()
            .AnyAsync(
                item => item.StoreId == storeId &&
                        item.ProductId == productId &&
                        (item.Status == ProductSkuSaveJobStatus.Queued || item.Status == ProductSkuSaveJobStatus.Running),
                cancellationToken);
        if (hasProcessingJob)
        {
            return ApiResponse<ProductSkuSaveJobResponse>.Error(ErrorCodes.BadRequest, "该商品已有进行中的 SKU 保存任务");
        }

        var normalizedSkus = NormalizeSkus(
            request.Skus,
            existingProduct.Price,
            existingProduct.OriginalPrice,
            existingProduct.StockQuantity ?? 0);

        var specTemplateIds = request.SpecTemplateIds is { Count: > 0 }
            ? ParseSnowflakeListStrict(request.SpecTemplateIds, nameof(request.SpecTemplateIds))
            : (await LoadProductRelationStateAsync(productId, storeId, cancellationToken)).SpecTemplateIds.ToList();

        var entity = await CreateSkuSaveJobAsync(
            storeId,
            productId,
            normalizedSkus,
            specTemplateIds,
            cancellationToken);
        if (entity.Status == ProductSkuSaveJobStatus.Failed)
        {
            return ApiResponse<ProductSkuSaveJobResponse>.Error(ErrorCodes.InternalServerError, "SKU 保存任务创建失败");
        }

        return ApiResponse<ProductSkuSaveJobResponse>.Ok(MapProductSkuSaveJob(entity));
    }

    /// <summary>
    /// 查询 SKU 异步保存任务状态。
    /// </summary>
    [HttpGet("sku-save-jobs/{jobId}")]
    [ProducesResponseType(typeof(ApiResponse<ProductSkuSaveJobResponse>), StatusCodes.Status200OK)]
    public async Task<ApiResponse<ProductSkuSaveJobResponse>> GetSkuSaveJob(
        [FromRoute] string jobId,
        [FromQuery] ProductSkuSaveJobStatusRequest request,
        CancellationToken cancellationToken)
    {
        var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
        await EnsureStoreAccessibleAsync(storeId, cancellationToken);

        var parsedJobId = StoreApiHelpers.ParseRequiredSnowflake(jobId, nameof(jobId));
        var job = await dbContext.ProductSkuSaveJobs
            .AsNoTracking()
            .SingleOrDefaultAsync(item => item.Id == parsedJobId && item.StoreId == storeId, cancellationToken);
        if (job is null)
        {
            return ApiResponse<ProductSkuSaveJobResponse>.Error(ErrorCodes.NotFound, "任务不存在");
        }

        return ApiResponse<ProductSkuSaveJobResponse>.Ok(MapProductSkuSaveJob(job));
    }

    /// <summary>
    /// 删除商品。
    /// </summary>
    [HttpPost("delete")]
    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
    public async Task<ApiResponse<object>> Delete(
        [FromBody] DeleteProductRequest request,
        CancellationToken cancellationToken)
    {
        var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
        await EnsureStoreAccessibleAsync(storeId, cancellationToken);

        var productId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId));
        var existing = await mediator.Send(new GetProductByIdQuery
        {
            ProductId = productId
        }, cancellationToken);
        if (existing is null || existing.StoreId != storeId)
        {
            return ApiResponse<object>.Error(ErrorCodes.NotFound, "商品不存在");
        }

        await mediator.Send(new DeleteProductCommand
        {
            ProductId = productId
        }, cancellationToken);
        return ApiResponse<object>.Ok(null);
    }

    /// <summary>
    /// 变更商品状态。
    /// </summary>
    [HttpPost("status/change")]
    [ProducesResponseType(typeof(ApiResponse<ProductDetailResponse>), StatusCodes.Status200OK)]
    public async Task<ApiResponse<ProductDetailResponse>> ChangeStatus(
        [FromBody] ChangeProductStatusRequest request,
        CancellationToken cancellationToken)
    {
        var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
        await EnsureStoreAccessibleAsync(storeId, cancellationToken);

        var productId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId));
        var existing = await mediator.Send(new GetProductByIdQuery
        {
            ProductId = productId
        }, cancellationToken);
        if (existing is null || existing.StoreId != storeId)
        {
            return ApiResponse<ProductDetailResponse>.Error(ErrorCodes.NotFound, "商品不存在");
        }

        var status = ParseUiStatus(request.Status);
        ProductSoldoutMode? soldoutMode = status == ProductStatus.OffShelf && string.Equals(request.Status, "sold_out", StringComparison.OrdinalIgnoreCase)
            ? existing.SoldoutMode ?? ProductSoldoutMode.Today
            : null;

        var updated = await UpdateProductStatusAsync(
            existing,
            status,
            soldoutMode,
            soldoutMode.HasValue ? Math.Max(0, existing.RemainStock ?? existing.StockQuantity ?? 0) : null,
            soldoutMode.HasValue ? existing.SoldoutReason : null,
            soldoutMode == ProductSoldoutMode.Timed ? existing.RecoverAt : null,
            cancellationToken);

        var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
        var labelNameMap = await BuildProductLabelNameMapAsync([updated.Id], storeId, cancellationToken);
        var comboGroups = await BuildComboGroupResponsesAsync(updated.Id, storeId, cancellationToken);
        var relationState = await LoadProductRelationStateAsync(updated.Id, storeId, cancellationToken);
        return ApiResponse<ProductDetailResponse>.Ok(MapDetailItem(
            updated,
            categoryNameLookup,
            labelNameMap.GetValueOrDefault(updated.Id, []),
            comboGroups,
            relationState.SpecTemplateIds,
            relationState.AddonGroupIds,
            relationState.LabelIds,
            relationState.Skus));
    }

    /// <summary>
    /// 沽清商品。
    /// </summary>
    [HttpPost("soldout")]
    [ProducesResponseType(typeof(ApiResponse<ProductDetailResponse>), StatusCodes.Status200OK)]
    public async Task<ApiResponse<ProductDetailResponse>> Soldout(
        [FromBody] SoldoutProductRequest request,
        CancellationToken cancellationToken)
    {
        var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
        await EnsureStoreAccessibleAsync(storeId, cancellationToken);

        var productId = StoreApiHelpers.ParseRequiredSnowflake(request.ProductId, nameof(request.ProductId));
        var existing = await mediator.Send(new GetProductByIdQuery
        {
            ProductId = productId
        }, cancellationToken);
        if (existing is null || existing.StoreId != storeId)
        {
            return ApiResponse<ProductDetailResponse>.Error(ErrorCodes.NotFound, "商品不存在");
        }

        var soldoutMode = ParseSoldoutMode(request.Mode);
        var recoverAt = soldoutMode == ProductSoldoutMode.Timed ? ParseDateTimeOrNull(request.RecoverAt, nameof(request.RecoverAt)) : null;

        var result = await mediator.Send(new SoldoutProductCommand
        {
            ProductId = productId,
            Mode = soldoutMode,
            RemainStock = Math.Max(0, request.RemainStock),
            Reason = request.Reason,
            RecoverAt = recoverAt,
            SyncToPlatform = request.SyncToPlatform,
            NotifyManager = request.NotifyManager
        }, cancellationToken);

        if (result is null)
        {
            return ApiResponse<ProductDetailResponse>.Error(ErrorCodes.NotFound, "商品不存在");
        }

        var categoryNameLookup = await BuildCategoryNameLookupAsync(storeId, cancellationToken);
        var labelNameMap = await BuildProductLabelNameMapAsync([result.Id], storeId, cancellationToken);
        var comboGroups = await BuildComboGroupResponsesAsync(result.Id, storeId, cancellationToken);
        var relationState = await LoadProductRelationStateAsync(result.Id, storeId, cancellationToken);
        return ApiResponse<ProductDetailResponse>.Ok(MapDetailItem(
            result,
            categoryNameLookup,
            labelNameMap.GetValueOrDefault(result.Id, []),
            comboGroups,
            relationState.SpecTemplateIds,
            relationState.AddonGroupIds,
            relationState.LabelIds,
            relationState.Skus));
    }

    /// <summary>
    /// 批量操作商品。
    /// </summary>
    [HttpPost("batch")]
    [ProducesResponseType(typeof(ApiResponse<BatchProductActionResultResponse>), StatusCodes.Status200OK)]
    public async Task<ApiResponse<BatchProductActionResultResponse>> Batch(
        [FromBody] BatchProductActionRequest request,
        CancellationToken cancellationToken)
    {
        var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
        await EnsureStoreAccessibleAsync(storeId, cancellationToken);

        var productIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds);
        if (productIds.Count == 0)
        {
            throw new BusinessException(ErrorCodes.BadRequest, "productIds 不能为空");
        }

        var action = (request.Action ?? string.Empty).Trim().ToLowerInvariant();
        var totalCount = productIds.Count;
        var successCount = 0;

        foreach (var productId in productIds)
        {
            try
            {
                var existing = await mediator.Send(new GetProductByIdQuery
                {
                    ProductId = productId
                }, cancellationToken);
                if (existing is null || existing.StoreId != storeId)
                {
                    continue;
                }

                switch (action)
                {
                    case "batch_delete":
                    {
                        await mediator.Send(new DeleteProductCommand
                        {
                            ProductId = productId
                        }, cancellationToken);
                        successCount++;
                        break;
                    }
                    case "batch_on":
                    {
                        await UpdateProductStatusAsync(existing, ProductStatus.OnSale, null, null, null, null, cancellationToken);
                        successCount++;
                        break;
                    }
                    case "batch_off":
                    {
                        await UpdateProductStatusAsync(existing, ProductStatus.OffShelf, null, null, null, null, cancellationToken);
                        successCount++;
                        break;
                    }
                    case "batch_soldout":
                    {
                        var mode = ParseSoldoutMode("today");
                        var recoverAt = ParseDateTimeOrNull(request.RecoverAt, nameof(request.RecoverAt));
                        await mediator.Send(new SoldoutProductCommand
                        {
                            ProductId = productId,
                            Mode = mode,
                            RemainStock = Math.Max(0, request.RemainStock ?? 0),
                            Reason = request.Reason,
                            RecoverAt = recoverAt,
                            SyncToPlatform = request.SyncToPlatform ?? true,
                            NotifyManager = request.NotifyManager ?? false
                        }, cancellationToken);
                        successCount++;
                        break;
                    }
                    default:
                        throw new BusinessException(ErrorCodes.BadRequest, "不支持的批量操作");
                }
            }
            catch
            {
                // 批量接口按项容错，统一统计失败数。
            }
        }

        return ApiResponse<BatchProductActionResultResponse>.Ok(new BatchProductActionResultResponse
        {
            Action = action,
            TotalCount = totalCount,
            SuccessCount = successCount,
            FailedCount = Math.Max(0, totalCount - successCount)
        });
    }

    private async Task<ProductDto> UpdateProductStatusAsync(
        ProductDto existing,
        ProductStatus status,
        ProductSoldoutMode? soldoutMode,
        int? remainStock,
        string? soldoutReason,
        DateTime? recoverAt,
        CancellationToken cancellationToken)
    {
        var stock = existing.StockQuantity ?? 0;
        if (remainStock.HasValue)
        {
            stock = Math.Max(0, remainStock.Value);
        }

        var updated = await mediator.Send(new UpdateProductCommand
        {
            ProductId = existing.Id,
            StoreId = existing.StoreId,
            CategoryId = existing.CategoryId,
            SpuCode = existing.SpuCode,
            Name = existing.Name,
            Subtitle = existing.Subtitle,
            Unit = existing.Unit,
            Price = existing.Price,
            OriginalPrice = existing.OriginalPrice,
            StockQuantity = stock,
            MaxQuantityPerOrder = existing.MaxQuantityPerOrder,
            Status = status,
            Kind = existing.Kind,
            SalesMonthly = existing.SalesMonthly,
            TagsJson = existing.TagsJson,
            SoldoutMode = soldoutMode,
            RecoverAt = soldoutMode == ProductSoldoutMode.Timed ? recoverAt : null,
            RemainStock = soldoutMode.HasValue ? stock : null,
            SoldoutReason = soldoutMode.HasValue ? soldoutReason : null,
            SyncToPlatform = soldoutMode.HasValue ? existing.SyncToPlatform : true,
            NotifyManager = soldoutMode.HasValue ? existing.NotifyManager : false,
            TimedOnShelfAt = existing.TimedOnShelfAt,
            CoverImage = existing.CoverImage,
            GalleryImages = existing.GalleryImages,
            Description = existing.Description,
            EnableDineIn = existing.EnableDineIn,
            EnablePickup = existing.EnablePickup,
            EnableDelivery = existing.EnableDelivery,
            IsFeatured = existing.IsFeatured,
            SortWeight = existing.SortWeight,
            WarningStock = existing.WarningStock,
            PackingFee = existing.PackingFee
        }, cancellationToken);

        if (updated is null)
        {
            throw new BusinessException(ErrorCodes.NotFound, "商品不存在");
        }

        return updated;
    }

    private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
    {
        var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
        await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
    }

    private async Task<Dictionary<long, string>> BuildCategoryNameLookupAsync(long storeId, CancellationToken cancellationToken)
    {
        var categories = await mediator.Send(new GetProductCategoryListQuery
        {
            StoreId = storeId
        }, cancellationToken);
        return categories.ToDictionary(item => item.Id, item => item.Name);
    }

    private async Task<Dictionary<long, List<string>>> BuildProductLabelNameMapAsync(
        IEnumerable<long> productIds,
        long storeId,
        CancellationToken cancellationToken)
    {
        var normalizedProductIds = productIds
            .Where(item => item > 0)
            .Distinct()
            .ToList();

        if (normalizedProductIds.Count == 0)
        {
            return [];
        }

        var rows = await (
            from relation in dbContext.ProductLabelProducts.AsNoTracking()
            join label in dbContext.ProductLabels.AsNoTracking() on relation.LabelId equals label.Id
            where relation.StoreId == storeId &&
                  normalizedProductIds.Contains(relation.ProductId)
            orderby label.SortOrder, label.Id
            select new
            {
                relation.ProductId,
                LabelName = label.Name
            }).ToListAsync(cancellationToken);

        var result = normalizedProductIds.ToDictionary(item => item, _ => new List<string>());
        foreach (var group in rows.GroupBy(item => item.ProductId))
        {
            result[group.Key] = group
                .Select(item => item.LabelName?.Trim() ?? string.Empty)
                .Where(item => !string.IsNullOrWhiteSpace(item))
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .Take(8)
                .ToList();
        }

        return result;
    }

    private static ProductListItemResponse MapListItem(
        ProductDto source,
        IReadOnlyDictionary<long, string> categoryNameLookup,
        IReadOnlyList<string> tags)
    {
        var stock = source.StockQuantity ?? 0;
        return new ProductListItemResponse
        {
            Id = source.Id.ToString(),
            CategoryId = source.CategoryId.ToString(),
            CategoryName = categoryNameLookup.GetValueOrDefault(source.CategoryId, string.Empty),
            ImageUrl = source.CoverImage ?? string.Empty,
            Kind = ToKindText(source.Kind),
            Name = source.Name,
            OriginalPrice = source.OriginalPrice,
            Price = source.Price,
            SalesMonthly = Math.Max(0, source.SalesMonthly),
            SoldoutMode = ToSoldoutModeText(source.SoldoutMode),
            SpuCode = source.SpuCode,
            Status = ToUiStatus(source.Status, source.SoldoutMode),
            Stock = stock,
            Subtitle = source.Subtitle ?? string.Empty,
            Tags = tags.ToList()
        };
    }

    private static ProductDetailResponse MapDetailItem(
        ProductDto source,
        IReadOnlyDictionary<long, string> categoryNameLookup,
        IReadOnlyList<string> tags,
        IReadOnlyList<ProductComboGroupResponse>? comboGroups = null,
        IReadOnlyList<long>? specTemplateIds = null,
        IReadOnlyList<long>? addonGroupIds = null,
        IReadOnlyList<long>? labelIds = null,
        IReadOnlyList<ProductSkuResponse>? skus = null)
    {
        var listItem = MapListItem(source, categoryNameLookup, tags);
        return new ProductDetailResponse
        {
            Id = listItem.Id,
            CategoryId = listItem.CategoryId,
            CategoryName = listItem.CategoryName,
            ImageUrl = listItem.ImageUrl,
            Kind = listItem.Kind,
            Name = listItem.Name,
            OriginalPrice = listItem.OriginalPrice,
            Price = listItem.Price,
            SalesMonthly = listItem.SalesMonthly,
            SoldoutMode = listItem.SoldoutMode,
            SpuCode = listItem.SpuCode,
            Status = listItem.Status,
            Stock = listItem.Stock,
            Subtitle = listItem.Subtitle,
            Tags = listItem.Tags,
            SortWeight = source.SortWeight,
            WarningStock = source.WarningStock,
            PackingFee = source.PackingFee,
            ComboGroups = comboGroups?.ToList() ?? [],
            ImageUrls = BuildImageUrls(source),
            Description = source.Description ?? string.Empty,
            SpecTemplateIds = (specTemplateIds ?? []).Select(item => item.ToString()).ToList(),
            AddonGroupIds = (addonGroupIds ?? []).Select(item => item.ToString()).ToList(),
            LabelIds = (labelIds ?? []).Select(item => item.ToString()).ToList(),
            Skus = skus?.ToList() ?? [],
            TimedOnShelfAt = source.TimedOnShelfAt?.ToString("yyyy-MM-dd HH:mm:ss"),
            NotifyManager = source.NotifyManager,
            RecoverAt = source.RecoverAt?.ToString("yyyy-MM-dd HH:mm:ss"),
            RemainStock = Math.Max(0, source.RemainStock ?? source.StockQuantity ?? 0),
            SoldoutReason = source.SoldoutReason ?? string.Empty,
            SyncToPlatform = source.SyncToPlatform
        };
    }

    private static ProductKind ParseKind(string? kind)
    {
        return (kind ?? string.Empty).Trim().ToLowerInvariant() switch
        {
            "combo" => ProductKind.Combo,
            "" or "single" => ProductKind.Single,
            _ => throw new BusinessException(ErrorCodes.BadRequest, "kind 非法")
        };
    }

    private static ProductKind ResolveKindForSave(string? kind, ProductDto? existing)
    {
        if (string.IsNullOrWhiteSpace(kind))
        {
            return existing?.Kind ?? ProductKind.Single;
        }

        return ParseKind(kind);
    }

    private static ProductKind? ParseKindOrNull(string? kind)
    {
        if (string.IsNullOrWhiteSpace(kind))
        {
            return null;
        }

        return ParseKind(kind);
    }

    private static (ProductStatus? Status, bool? IsSoldOut) ParseUiStatusFilter(string? status)
    {
        if (string.IsNullOrWhiteSpace(status))
        {
            return (null, null);
        }

        return status.Trim().ToLowerInvariant() switch
        {
            "on_sale" => (ProductStatus.OnSale, false),
            "off_shelf" => (ProductStatus.OffShelf, false),
            "sold_out" => (null, true),
            _ => throw new BusinessException(ErrorCodes.BadRequest, "status 非法")
        };
    }

    private static ProductStatus ParseUiStatus(string? status)
    {
        return (status ?? string.Empty).Trim().ToLowerInvariant() switch
        {
            "on_sale" => ProductStatus.OnSale,
            "off_shelf" => ProductStatus.OffShelf,
            "sold_out" => ProductStatus.OffShelf,
            _ => throw new BusinessException(ErrorCodes.BadRequest, "status 非法")
        };
    }

    private static ProductStatus ResolveStatusByShelfMode(string? shelfMode, string? status)
    {
        return (shelfMode ?? string.Empty).Trim().ToLowerInvariant() switch
        {
            "now" => ProductStatus.OnSale,
            "draft" => ProductStatus.OffShelf,
            "scheduled" => ProductStatus.OffShelf,
            _ => ParseUiStatus(status)
        };
    }

    private static ProductSoldoutMode ParseSoldoutMode(string? mode)
    {
        return (mode ?? string.Empty).Trim().ToLowerInvariant() switch
        {
            "today" or "" => ProductSoldoutMode.Today,
            "timed" => ProductSoldoutMode.Timed,
            "permanent" => ProductSoldoutMode.Permanent,
            _ => throw new BusinessException(ErrorCodes.BadRequest, "mode 非法")
        };
    }

    private static ProductSoldoutMode? ResolveSoldoutModeForSave(ProductDto? existing, ProductStatus status, string? requestStatus)
    {
        if (status == ProductStatus.OnSale)
        {
            return null;
        }

        if (string.Equals(requestStatus, "sold_out", StringComparison.OrdinalIgnoreCase))
        {
            return existing?.SoldoutMode ?? ProductSoldoutMode.Today;
        }

        return null;
    }

    private static DateTime? ParseTimedOnShelfAt(string? shelfMode, string? timedOnShelfAt)
    {
        if (!string.Equals(shelfMode, "scheduled", StringComparison.OrdinalIgnoreCase))
        {
            return null;
        }

        var parsed = ParseDateTimeOrNull(timedOnShelfAt, nameof(timedOnShelfAt));
        if (!parsed.HasValue)
        {
            throw new BusinessException(ErrorCodes.BadRequest, "定时上架时间不能为空");
        }

        return parsed;
    }

    private static DateTime? ParseDateTimeOrNull(string? value, string fieldName)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return null;
        }

        if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed))
        {
            throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 格式非法");
        }

        return parsed.ToUniversalTime();
    }

    private static List<string> ParseTagsJson(string? tagsJson)
    {
        if (string.IsNullOrWhiteSpace(tagsJson))
        {
            return [];
        }

        try
        {
            var tags = JsonSerializer.Deserialize<List<string>>(tagsJson, StoreApiHelpers.JsonOptions) ?? [];
            return tags
                .Select(item => item?.Trim() ?? string.Empty)
                .Where(item => !string.IsNullOrWhiteSpace(item))
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .Take(8)
                .ToList();
        }
        catch
        {
            return [];
        }
    }

    private static string SerializeTagsJson(IEnumerable<string>? tags)
    {
        var normalized = (tags ?? [])
            .Select(item => item?.Trim() ?? string.Empty)
            .Where(item => !string.IsNullOrWhiteSpace(item))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .Take(8)
            .ToList();
        return JsonSerializer.Serialize(normalized);
    }

    private static List<string> BuildImageUrls(ProductDto product)
    {
        var urls = new List<string>();
        if (!string.IsNullOrWhiteSpace(product.CoverImage))
        {
            urls.Add(product.CoverImage.Trim());
        }

        if (!string.IsNullOrWhiteSpace(product.GalleryImages))
        {
            urls.AddRange(product.GalleryImages
                .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
                .Where(url => !string.IsNullOrWhiteSpace(url)));
        }

        return urls
            .Select(url => url.Trim())
            .Where(url => !string.IsNullOrWhiteSpace(url))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .Take(5)
            .ToList();
    }

    private static List<string> NormalizeImageUrls(IEnumerable<string>? imageUrls)
    {
        return (imageUrls ?? [])
            .Select(url => url?.Trim() ?? string.Empty)
            .Where(url => !string.IsNullOrWhiteSpace(url))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .Take(5)
            .ToList();
    }

    private static List<long> ParseSnowflakeListStrict(IEnumerable<string>? values, string fieldName)
    {
        if (values is null)
        {
            return [];
        }

        var result = new List<long>();
        var dedup = new HashSet<long>();
        var index = 0;
        foreach (var value in values)
        {
            var raw = (value ?? string.Empty).Trim();
            if (string.IsNullOrWhiteSpace(raw))
            {
                index++;
                continue;
            }

            var id = StoreApiHelpers.ParseRequiredSnowflake(raw, $"{fieldName}[{index}]");
            if (dedup.Add(id))
            {
                result.Add(id);
            }

            index++;
        }

        return result;
    }

    private static decimal? NormalizePackingFee(decimal? packingFee)
    {
        if (!packingFee.HasValue)
        {
            return null;
        }

        if (packingFee.Value < 0)
        {
            throw new BusinessException(ErrorCodes.BadRequest, "packingFee 不能小于 0");
        }

        return decimal.Round(packingFee.Value, 2, MidpointRounding.AwayFromZero);
    }

    private static IReadOnlyList<NormalizedSkuRequest> NormalizeSkus(
        IEnumerable<SaveProductSkuRequest>? source,
        decimal defaultPrice,
        decimal? defaultOriginalPrice,
        int defaultStock)
    {
        var skus = (source ?? []).ToList();
        if (skus.Count == 0)
        {
            return
            [
                new NormalizedSkuRequest(
                    null,
                    Math.Max(0, defaultPrice),
                    defaultOriginalPrice.HasValue && defaultOriginalPrice.Value > 0
                        ? defaultOriginalPrice.Value
                        : null,
                    Math.Max(0, defaultStock),
                    true,
                    1,
                    [])
            ];
        }

        var normalized = new List<NormalizedSkuRequest>(skus.Count);
        var combinationSet = new HashSet<string>(StringComparer.Ordinal);
        for (var skuIndex = 0; skuIndex < skus.Count; skuIndex++)
        {
            var sku = skus[skuIndex];
            if (sku.Price < 0)
            {
                throw new BusinessException(ErrorCodes.BadRequest, $"skus[{skuIndex}].price 不能小于 0");
            }

            if (sku.Stock < 0)
            {
                throw new BusinessException(ErrorCodes.BadRequest, $"skus[{skuIndex}].stock 不能小于 0");
            }

            var skuCode = (sku.SkuCode ?? string.Empty).Trim();
            if (skuCode.Length > 32)
            {
                throw new BusinessException(ErrorCodes.BadRequest, $"skus[{skuIndex}].skuCode 长度不能超过 32");
            }

            var attributes = new List<NormalizedSkuAttributeRequest>();
            var templateDedup = new HashSet<long>();
            var attrList = sku.Attributes ?? [];
            for (var attrIndex = 0; attrIndex < attrList.Count; attrIndex++)
            {
                var attr = attrList[attrIndex];
                var templateId = StoreApiHelpers.ParseRequiredSnowflake(
                    attr.TemplateId,
                    $"skus[{skuIndex}].attributes[{attrIndex}].templateId");
                var optionId = StoreApiHelpers.ParseRequiredSnowflake(
                    attr.OptionId,
                    $"skus[{skuIndex}].attributes[{attrIndex}].optionId");

                if (!templateDedup.Add(templateId))
                {
                    throw new BusinessException(
                        ErrorCodes.BadRequest,
                        $"skus[{skuIndex}] 存在重复模板属性");
                }

                attributes.Add(new NormalizedSkuAttributeRequest(templateId, optionId));
            }

            var combinationKey = BuildSkuAttributeKey(attributes);
            if (!combinationSet.Add(combinationKey))
            {
                throw new BusinessException(ErrorCodes.BadRequest, "SKU 规格组合重复");
            }

            normalized.Add(new NormalizedSkuRequest(
                string.IsNullOrWhiteSpace(skuCode) ? null : skuCode,
                decimal.Round(sku.Price, 2, MidpointRounding.AwayFromZero),
                sku.OriginalPrice.HasValue && sku.OriginalPrice.Value > 0
                    ? decimal.Round(sku.OriginalPrice.Value, 2, MidpointRounding.AwayFromZero)
                    : null,
                sku.Stock,
                sku.IsEnabled,
                sku.SortOrder > 0 ? sku.SortOrder : skuIndex + 1,
                attributes
                    .OrderBy(item => item.TemplateId)
                    .ThenBy(item => item.OptionId)
                    .ToList()));
        }

        return normalized;
    }

    private static string BuildSkuAttributeKey(IReadOnlyList<NormalizedSkuAttributeRequest> attributes)
    {
        if (attributes.Count == 0)
        {
            return "default";
        }

        return string.Join('|', attributes
            .OrderBy(item => item.TemplateId)
            .ThenBy(item => item.OptionId)
            .Select(item => $"{item.TemplateId}:{item.OptionId}"));
    }

    private async Task ReplaceSpecTemplateRelationsAsync(
        long productId,
        long storeId,
        IReadOnlyCollection<long> specTemplateIds,
        IReadOnlyCollection<long> addonGroupIds,
        CancellationToken cancellationToken)
    {
        var relationTemplateIds = specTemplateIds
            .Concat(addonGroupIds)
            .Distinct()
            .ToList();

        var templateLookup = relationTemplateIds.Count == 0
            ? new Dictionary<long, ProductSpecTemplateType>()
            : await dbContext.ProductSpecTemplates
                .AsNoTracking()
                .Where(item => item.StoreId == storeId && relationTemplateIds.Contains(item.Id))
                .ToDictionaryAsync(item => item.Id, item => item.TemplateType, cancellationToken);

        var missingTemplateId = relationTemplateIds.FirstOrDefault(item => !templateLookup.ContainsKey(item));
        if (missingTemplateId > 0)
        {
            throw new BusinessException(ErrorCodes.BadRequest, $"规格模板不存在: {missingTemplateId}");
        }

        var invalidSpecTemplateId = specTemplateIds.FirstOrDefault(item => templateLookup[item] == ProductSpecTemplateType.Addon);
        if (invalidSpecTemplateId > 0)
        {
            throw new BusinessException(ErrorCodes.BadRequest, $"规格模板类型错误: {invalidSpecTemplateId}");
        }

        var invalidAddonTemplateId = addonGroupIds.FirstOrDefault(item => templateLookup[item] != ProductSpecTemplateType.Addon);
        if (invalidAddonTemplateId > 0)
        {
            throw new BusinessException(ErrorCodes.BadRequest, $"加料模板类型错误: {invalidAddonTemplateId}");
        }

        await dbContext.ProductSpecTemplateProducts
            .Where(item => item.StoreId == storeId && item.ProductId == productId)
            .ExecuteDeleteAsync(cancellationToken);

        if (relationTemplateIds.Count == 0)
        {
            return;
        }

        var entities = relationTemplateIds
            .Select(item => new ProductSpecTemplateProduct
            {
                StoreId = storeId,
                TemplateId = item,
                ProductId = productId
            })
            .ToList();

        await dbContext.ProductSpecTemplateProducts.AddRangeAsync(entities, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
    }

    private async Task ReplaceLabelRelationsAsync(
        long productId,
        long storeId,
        IReadOnlyCollection<long> labelIds,
        CancellationToken cancellationToken)
    {
        var existingLabelIds = labelIds.Count == 0
            ? []
            : await dbContext.ProductLabels
                .AsNoTracking()
                .Where(item => item.StoreId == storeId && labelIds.Contains(item.Id))
                .Select(item => item.Id)
                .ToListAsync(cancellationToken);

        var missingLabelId = labelIds.FirstOrDefault(item => !existingLabelIds.Contains(item));
        if (missingLabelId > 0)
        {
            throw new BusinessException(ErrorCodes.BadRequest, $"标签不存在: {missingLabelId}");
        }

        await dbContext.ProductLabelProducts
            .Where(item => item.StoreId == storeId && item.ProductId == productId)
            .ExecuteDeleteAsync(cancellationToken);

        if (labelIds.Count == 0)
        {
            return;
        }

        var entities = labelIds
            .Select(item => new ProductLabelProduct
            {
                StoreId = storeId,
                LabelId = item,
                ProductId = productId
            })
            .ToList();

        await dbContext.ProductLabelProducts.AddRangeAsync(entities, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
    }

    private async Task ReplaceSkusAsync(
        long productId,
        long storeId,
        IReadOnlyList<NormalizedSkuRequest> skus,
        IReadOnlyCollection<long> specTemplateIds,
        CancellationToken cancellationToken)
    {
        await productSkuSaveService.ReplaceSkusAsync(
            productId,
            storeId,
            ToProductSkuUpsertInputs(skus),
            specTemplateIds,
            cancellationToken);
    }

    private static List<ProductSkuUpsertInput> ToProductSkuUpsertInputs(IReadOnlyList<NormalizedSkuRequest> skus)
    {
        return skus
            .Select(item => new ProductSkuUpsertInput
            {
                SkuCode = item.SkuCode,
                Price = item.Price,
                OriginalPrice = item.OriginalPrice,
                Stock = item.Stock,
                IsEnabled = item.IsEnabled,
                SortOrder = item.SortOrder,
                Attributes = item.Attributes
                    .Select(attr => new ProductSkuUpsertAttributeInput
                    {
                        TemplateId = attr.TemplateId,
                        OptionId = attr.OptionId
                    })
                    .ToList()
            })
            .ToList();
    }

    private async Task<ProductSkuSaveJob> CreateSkuSaveJobAsync(
        long storeId,
        long productId,
        IReadOnlyList<NormalizedSkuRequest> normalizedSkus,
        IReadOnlyCollection<long> specTemplateIds,
        CancellationToken cancellationToken)
    {
        var payload = new ProductSkuSaveJobPayload
        {
            Skus = ToProductSkuUpsertInputs(normalizedSkus),
            SpecTemplateIds = specTemplateIds.Distinct().ToList()
        };

        var entity = new ProductSkuSaveJob
        {
            Id = idGenerator.NextId(),
            StoreId = storeId,
            ProductId = productId,
            Status = ProductSkuSaveJobStatus.Queued,
            Mode = "replace",
            PayloadJson = JsonSerializer.Serialize(payload, StoreApiHelpers.JsonOptions),
            ProgressTotal = payload.Skus.Count,
            ProgressProcessed = 0,
            FailedCount = 0
        };
        var tenantId = HttpContext.GetTenantContext()?.TenantId ?? 0;
        if (tenantId > 0)
        {
            entity.TenantId = tenantId;
        }

        var requestId = HttpContext.TraceIdentifier;

        await dbContext.ProductSkuSaveJobs.AddAsync(entity, cancellationToken);
        try
        {
            await publishEndpoint.Publish(
                new ProductSkuSaveJobRequestedEvent
                {
                    JobId = entity.Id,
                    TenantId = tenantId,
                    StoreId = entity.StoreId,
                    ProductId = entity.ProductId,
                    RequestId = string.IsNullOrWhiteSpace(requestId) ? Guid.NewGuid().ToString("N") : requestId
                },
                cancellationToken);

            await dbContext.SaveChangesAsync(cancellationToken);
        }
        catch (Exception ex)
        {
            entity.Status = ProductSkuSaveJobStatus.Failed;
            entity.FailedCount = entity.ProgressTotal;
            entity.ErrorMessage = Truncate(BuildDetailedErrorMessage(ex), 2000);
            entity.FinishedAt = DateTime.UtcNow;
            await dbContext.SaveChangesAsync(cancellationToken);
        }

        return entity;
    }

    private static ProductSkuSaveJobResponse MapProductSkuSaveJob(ProductSkuSaveJob source)
    {
        return new ProductSkuSaveJobResponse
        {
            JobId = source.Id.ToString(),
            StoreId = source.StoreId.ToString(),
            ProductId = source.ProductId.ToString(),
            Status = ToProductSkuSaveJobStatusText(source.Status),
            ProgressTotal = Math.Max(0, source.ProgressTotal),
            ProgressProcessed = Math.Max(0, source.ProgressProcessed),
            FailedCount = Math.Max(0, source.FailedCount),
            ErrorMessage = source.ErrorMessage,
            CreatedAt = source.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"),
            StartedAt = source.StartedAt?.ToString("yyyy-MM-dd HH:mm:ss"),
            FinishedAt = source.FinishedAt?.ToString("yyyy-MM-dd HH:mm:ss")
        };
    }

    private static string ToProductSkuSaveJobStatusText(ProductSkuSaveJobStatus status)
    {
        return status switch
        {
            ProductSkuSaveJobStatus.Queued => "queued",
            ProductSkuSaveJobStatus.Running => "running",
            ProductSkuSaveJobStatus.Succeeded => "succeeded",
            ProductSkuSaveJobStatus.Failed => "failed",
            ProductSkuSaveJobStatus.Canceled => "canceled",
            _ => "queued"
        };
    }

    private static string Truncate(string? value, int maxLength)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return string.Empty;
        }

        return value.Length <= maxLength ? value : value[..maxLength];
    }

    private static string BuildDetailedErrorMessage(Exception ex)
    {
        var lines = new List<string>();
        var current = ex;
        var depth = 0;
        const int maxDepth = 8;

        while (current is not null && depth < maxDepth)
        {
            var prefix = depth == 0 ? "Exception" : $"Inner[{depth}]";
            var message = string.IsNullOrWhiteSpace(current.Message)
                ? "(no message)"
                : current.Message.Trim();
            lines.Add($"{prefix} {current.GetType().Name}: {message}");
            current = current.InnerException;
            depth++;
        }

        if (current is not null)
        {
            lines.Add($"Inner[{depth}] ...");
        }

        return string.Join(Environment.NewLine, lines);
    }

    private async Task<ProductRelationState> LoadProductRelationStateAsync(
        long productId,
        long storeId,
        CancellationToken cancellationToken)
    {
        var templateIds = await dbContext.ProductSpecTemplateProducts
            .AsNoTracking()
            .Where(item => item.StoreId == storeId && item.ProductId == productId)
            .Select(item => item.TemplateId)
            .Distinct()
            .ToListAsync(cancellationToken);

        var specTemplateIds = new List<long>();
        var addonGroupIds = new List<long>();
        if (templateIds.Count > 0)
        {
            var templates = await dbContext.ProductSpecTemplates
                .AsNoTracking()
                .Where(item => item.StoreId == storeId && templateIds.Contains(item.Id))
                .Select(item => new { item.Id, item.TemplateType })
                .ToListAsync(cancellationToken);

            specTemplateIds = templates
                .Where(item => item.TemplateType != ProductSpecTemplateType.Addon)
                .Select(item => item.Id)
                .Distinct()
                .OrderBy(item => item)
                .ToList();

            addonGroupIds = templates
                .Where(item => item.TemplateType == ProductSpecTemplateType.Addon)
                .Select(item => item.Id)
                .Distinct()
                .OrderBy(item => item)
                .ToList();
        }

        var labelIds = await dbContext.ProductLabelProducts
            .AsNoTracking()
            .Where(item => item.StoreId == storeId && item.ProductId == productId)
            .Select(item => item.LabelId)
            .Distinct()
            .OrderBy(item => item)
            .ToListAsync(cancellationToken);

        var skus = await BuildProductSkuResponsesAsync(productId, cancellationToken);
        return new ProductRelationState(specTemplateIds, addonGroupIds, labelIds, skus);
    }

    private async Task<List<ProductSkuResponse>> BuildProductSkuResponsesAsync(long productId, CancellationToken cancellationToken)
    {
        var skus = await dbContext.ProductSkus
            .AsNoTracking()
            .Where(item => item.ProductId == productId)
            .OrderBy(item => item.SortOrder)
            .ThenBy(item => item.Id)
            .ToListAsync(cancellationToken);

        return skus.Select(item => new ProductSkuResponse
        {
            Id = item.Id.ToString(),
            SkuCode = item.SkuCode,
            Price = item.Price,
            OriginalPrice = item.OriginalPrice,
            Stock = Math.Max(0, item.StockQuantity ?? 0),
            IsEnabled = item.IsEnabled,
            SortOrder = item.SortOrder,
            Attributes = ParseSkuAttributes(item.AttributesJson)
        }).ToList();
    }

    private static List<ProductSkuAttributeResponse> ParseSkuAttributes(string? attributesJson)
    {
        if (string.IsNullOrWhiteSpace(attributesJson))
        {
            return [];
        }

        try
        {
            var parsed = JsonSerializer.Deserialize<List<SkuAttributePayload>>(attributesJson, StoreApiHelpers.JsonOptions) ?? [];
            var dedup = new HashSet<string>(StringComparer.Ordinal);
            var result = new List<ProductSkuAttributeResponse>();
            foreach (var item in parsed)
            {
                if (item.TemplateId <= 0 || item.OptionId <= 0)
                {
                    continue;
                }

                var key = $"{item.TemplateId}:{item.OptionId}";
                if (!dedup.Add(key))
                {
                    continue;
                }

                result.Add(new ProductSkuAttributeResponse
                {
                    TemplateId = item.TemplateId.ToString(),
                    OptionId = item.OptionId.ToString()
                });
            }

            return result;
        }
        catch
        {
            return [];
        }
    }

    private async Task<List<ProductComboGroupResponse>> BuildComboGroupResponsesAsync(long productId, long storeId, CancellationToken cancellationToken)
    {
        var groups = await dbContext.ProductComboGroups
            .AsNoTracking()
            .Where(x => x.ProductId == productId)
            .OrderBy(x => x.SortOrder)
            .ThenBy(x => x.Id)
            .ToListAsync(cancellationToken);

        if (groups.Count == 0)
        {
            return [];
        }

        var groupIds = groups.Select(x => x.Id).ToList();
        var items = await dbContext.ProductComboGroupItems
            .AsNoTracking()
            .Where(x => groupIds.Contains(x.ComboGroupId))
            .OrderBy(x => x.SortOrder)
            .ThenBy(x => x.Id)
            .ToListAsync(cancellationToken);

        var productIds = items.Select(x => x.ProductId).Distinct().ToList();
        var productNameLookup = productIds.Count == 0
            ? new Dictionary<long, string>()
            : await dbContext.Products
                .AsNoTracking()
                .Where(x => x.StoreId == storeId && productIds.Contains(x.Id))
                .ToDictionaryAsync(x => x.Id, x => x.Name, cancellationToken);

        var itemsLookup = items.ToLookup(x => x.ComboGroupId);
        return groups.Select(group => new ProductComboGroupResponse
        {
            Id = group.Id.ToString(),
            Name = group.Name,
            MinSelect = group.MinSelect,
            MaxSelect = group.MaxSelect,
            SortOrder = group.SortOrder,
            Items = itemsLookup[group.Id]
                .Select(item => new ProductComboGroupItemResponse
                {
                    ProductId = item.ProductId.ToString(),
                    ProductName = productNameLookup.GetValueOrDefault(item.ProductId, string.Empty),
                    Quantity = item.Quantity,
                    SortOrder = item.SortOrder
                })
                .ToList()
        }).ToList();
    }

    private async Task ReplaceComboGroupsAsync(
        long productId,
        long storeId,
        ProductKind kind,
        IReadOnlyList<NormalizedComboGroupRequest> comboGroups,
        CancellationToken cancellationToken)
    {
        await RemoveComboGroupsAsync(productId, cancellationToken);
        if (kind != ProductKind.Combo)
        {
            return;
        }

        var selectedProductIds = comboGroups
            .SelectMany(group => group.Items)
            .Select(item => item.ProductId)
            .Distinct()
            .ToList();

        if (selectedProductIds.Contains(productId))
        {
            throw new BusinessException(ErrorCodes.BadRequest, "套餐分组商品不能包含当前套餐");
        }

        var existingProductIds = selectedProductIds.Count == 0
            ? []
            : await dbContext.Products
                .AsNoTracking()
                .Where(x => x.StoreId == storeId && selectedProductIds.Contains(x.Id))
                .Select(x => x.Id)
                .ToListAsync(cancellationToken);

        var missingProductId = selectedProductIds
            .Except(existingProductIds)
            .FirstOrDefault();
        if (missingProductId > 0)
        {
            throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组商品不存在: {missingProductId}");
        }

        var groupEntities = comboGroups
            .Select(group => new ProductComboGroup
            {
                ProductId = productId,
                Name = group.Name,
                MinSelect = group.MinSelect,
                MaxSelect = group.MaxSelect,
                SortOrder = group.SortOrder
            })
            .ToList();
        await dbContext.ProductComboGroups.AddRangeAsync(groupEntities, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);

        var itemEntities = comboGroups
            .SelectMany((group, groupIndex) => group.Items.Select(item => new ProductComboGroupItem
            {
                ComboGroupId = groupEntities[groupIndex].Id,
                ProductId = item.ProductId,
                Quantity = item.Quantity,
                SortOrder = item.SortOrder
            }))
            .ToList();
        await dbContext.ProductComboGroupItems.AddRangeAsync(itemEntities, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
    }

    private async Task RemoveComboGroupsAsync(long productId, CancellationToken cancellationToken)
    {
        var groupIds = await dbContext.ProductComboGroups
            .Where(x => x.ProductId == productId)
            .Select(x => x.Id)
            .ToListAsync(cancellationToken);

        if (groupIds.Count == 0)
        {
            return;
        }

        await dbContext.ProductComboGroupItems
            .Where(x => groupIds.Contains(x.ComboGroupId))
            .ExecuteDeleteAsync(cancellationToken);

        await dbContext.ProductComboGroups
            .Where(x => x.ProductId == productId)
            .ExecuteDeleteAsync(cancellationToken);
    }

    private static IReadOnlyList<NormalizedComboGroupRequest> NormalizeComboGroups(
        IEnumerable<SaveProductComboGroupRequest>? groups,
        ProductKind kind)
    {
        if (kind != ProductKind.Combo)
        {
            return [];
        }

        var source = (groups ?? []).ToList();
        if (source.Count == 0)
        {
            throw new BusinessException(ErrorCodes.BadRequest, "套餐商品必须至少配置一个套餐分组");
        }

        var groupNameSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var normalized = new List<NormalizedComboGroupRequest>(source.Count);
        for (var groupIndex = 0; groupIndex < source.Count; groupIndex++)
        {
            var group = source[groupIndex];
            var name = (group.Name ?? string.Empty).Trim();
            if (string.IsNullOrWhiteSpace(name))
            {
                throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组第 {groupIndex + 1} 项名称不能为空");
            }

            if (!groupNameSet.Add(name))
            {
                throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组名称重复: {name}");
            }

            if (group.MinSelect <= 0 || group.MaxSelect <= 0)
            {
                throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组 {name} 的最小/最大选择数必须大于 0");
            }

            if (group.MaxSelect < group.MinSelect)
            {
                throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组 {name} 的最大选择数不能小于最小选择数");
            }

            var sourceItems = group.Items ?? [];
            if (sourceItems.Count == 0)
            {
                throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组 {name} 至少需要一个商品");
            }

            var itemProductSet = new HashSet<long>();
            var normalizedItems = new List<NormalizedComboGroupItemRequest>(sourceItems.Count);
            for (var itemIndex = 0; itemIndex < sourceItems.Count; itemIndex++)
            {
                var item = sourceItems[itemIndex];
                var productId = StoreApiHelpers.ParseRequiredSnowflake(
                    item.ProductId,
                    $"comboGroups[{groupIndex}].items[{itemIndex}].productId");

                if (!itemProductSet.Add(productId))
                {
                    throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组 {name} 存在重复商品");
                }

                if (item.Quantity <= 0)
                {
                    throw new BusinessException(ErrorCodes.BadRequest, $"套餐分组 {name} 的商品数量必须大于 0");
                }

                normalizedItems.Add(new NormalizedComboGroupItemRequest(
                    productId,
                    item.Quantity,
                    item.SortOrder > 0 ? item.SortOrder : itemIndex + 1));
            }

            normalized.Add(new NormalizedComboGroupRequest(
                name,
                group.MinSelect,
                group.MaxSelect,
                group.SortOrder > 0 ? group.SortOrder : groupIndex + 1,
                normalizedItems));
        }

        return normalized;
    }

    private sealed record NormalizedComboGroupRequest(
        string Name,
        int MinSelect,
        int MaxSelect,
        int SortOrder,
        IReadOnlyList<NormalizedComboGroupItemRequest> Items);

    private sealed record NormalizedComboGroupItemRequest(long ProductId, int Quantity, int SortOrder);

    private sealed record NormalizedSkuRequest(
        string? SkuCode,
        decimal Price,
        decimal? OriginalPrice,
        int Stock,
        bool IsEnabled,
        int SortOrder,
        IReadOnlyList<NormalizedSkuAttributeRequest> Attributes);

    private sealed record NormalizedSkuAttributeRequest(long TemplateId, long OptionId);

    private sealed record ProductRelationState(
        IReadOnlyList<long> SpecTemplateIds,
        IReadOnlyList<long> AddonGroupIds,
        IReadOnlyList<long> LabelIds,
        IReadOnlyList<ProductSkuResponse> Skus);

    private sealed record SkuAttributePayload(long TemplateId, long OptionId);

    private static string ToKindText(ProductKind kind)
    {
        return kind == ProductKind.Combo ? "combo" : "single";
    }

    private static string? ToSoldoutModeText(ProductSoldoutMode? mode)
    {
        return mode switch
        {
            ProductSoldoutMode.Today => "today",
            ProductSoldoutMode.Timed => "timed",
            ProductSoldoutMode.Permanent => "permanent",
            _ => null
        };
    }

    private static string ToUiStatus(ProductStatus status, ProductSoldoutMode? soldoutMode)
    {
        if (soldoutMode.HasValue)
        {
            return "sold_out";
        }

        return status switch
        {
            ProductStatus.OnSale => "on_sale",
            _ => "off_shelf"
        };
    }

    private static string GenerateSpuCode()
    {
        var now = DateTime.UtcNow;
        var seed = (int)(now.Ticks % 10_000);
        return $"SPU{now:yyyyMMddHHmmss}{seed:D4}";
    }
}
