CatalogService.java

package com.ecommerce.catalog.application.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ecommerce.catalog.application.exception.CatalogError;
import com.ecommerce.catalog.application.exception.CatalogException;
import com.ecommerce.catalog.application.model.CatalogModels.BrandView;
import com.ecommerce.catalog.application.model.CatalogModels.CategoryView;
import com.ecommerce.catalog.application.model.CatalogModels.CreateProductCommand;
import com.ecommerce.catalog.application.model.CatalogModels.CreateSkuCommand;
import com.ecommerce.catalog.application.model.CatalogModels.MediaView;
import com.ecommerce.catalog.application.model.CatalogModels.ProductDetail;
import com.ecommerce.catalog.application.model.CatalogModels.ProductSummary;
import com.ecommerce.catalog.application.model.CatalogModels.SkuView;
import com.ecommerce.catalog.application.model.CatalogModels.UpdateProductCommand;
import com.ecommerce.catalog.application.model.CatalogModels.UpdateSkuCommand;
import com.ecommerce.catalog.application.model.CatalogModels.UploadIntent;
import com.ecommerce.catalog.application.port.ObjectStorage;
import com.ecommerce.catalog.application.port.ProductDetailCache;
import com.ecommerce.catalog.domain.ProductStatus;
import com.ecommerce.catalog.domain.RecordStatus;
import com.ecommerce.catalog.infrastructure.persistence.entity.BrandEntity;
import com.ecommerce.catalog.infrastructure.persistence.entity.CategoryEntity;
import com.ecommerce.catalog.infrastructure.persistence.entity.ProductMediaEntity;
import com.ecommerce.catalog.infrastructure.persistence.entity.ProductSkuEntity;
import com.ecommerce.catalog.infrastructure.persistence.entity.ProductSpuEntity;
import com.ecommerce.catalog.infrastructure.persistence.mapper.BrandMapper;
import com.ecommerce.catalog.infrastructure.persistence.mapper.CategoryMapper;
import com.ecommerce.catalog.infrastructure.persistence.mapper.ProductMediaMapper;
import com.ecommerce.catalog.infrastructure.persistence.mapper.ProductSkuMapper;
import com.ecommerce.catalog.infrastructure.persistence.mapper.ProductSpuMapper;
import com.ecommerce.catalog.infrastructure.storage.MediaStorageProperties;
import com.ecommerce.platform.common.api.CursorPageResponse;
import com.ecommerce.platform.common.api.KeysetCursor;
import com.ecommerce.platform.common.api.PageResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
public class CatalogService {

    private static final Logger log = LoggerFactory.getLogger(CatalogService.class);
    private static final Set<String> ALLOWED_MEDIA_TYPES = Set.of("image/jpeg", "image/png", "image/webp");

    private final CategoryMapper categoryMapper;
    private final BrandMapper brandMapper;
    private final ProductSpuMapper spuMapper;
    private final ProductSkuMapper skuMapper;
    private final ProductMediaMapper mediaMapper;
    private final ObjectStorage objectStorage;
    private final ProductDetailCache productDetailCache;
    private final CatalogSearchOutboxService searchOutboxService;
    private final MediaStorageProperties mediaProperties;
    private final TransactionTemplate transactionTemplate;

    public CatalogService(
            CategoryMapper categoryMapper,
            BrandMapper brandMapper,
            ProductSpuMapper spuMapper,
            ProductSkuMapper skuMapper,
            ProductMediaMapper mediaMapper,
            ObjectStorage objectStorage,
            ProductDetailCache productDetailCache,
            CatalogSearchOutboxService searchOutboxService,
            MediaStorageProperties mediaProperties,
            TransactionTemplate transactionTemplate) {
        this.categoryMapper = categoryMapper;
        this.brandMapper = brandMapper;
        this.spuMapper = spuMapper;
        this.skuMapper = skuMapper;
        this.mediaMapper = mediaMapper;
        this.objectStorage = objectStorage;
        this.productDetailCache = productDetailCache;
        this.searchOutboxService = searchOutboxService;
        this.mediaProperties = mediaProperties;
        this.transactionTemplate = transactionTemplate;
    }

    @Transactional(readOnly = true)
    public List<CategoryView> listCategories() {
        return categoryMapper.selectList(new LambdaQueryWrapper<CategoryEntity>()
                        .eq(CategoryEntity::getStatus, RecordStatus.ACTIVE.name())
                        .orderByAsc(CategoryEntity::getSortOrder, CategoryEntity::getName))
                .stream()
                .map(this::categoryView)
                .toList();
    }

    @Transactional(readOnly = true)
    public List<BrandView> listBrands() {
        return brandMapper.selectList(new LambdaQueryWrapper<BrandEntity>()
                        .eq(BrandEntity::getStatus, RecordStatus.ACTIVE.name())
                        .orderByAsc(BrandEntity::getName))
                .stream()
                .map(this::brandView)
                .toList();
    }

    @Transactional
    public CategoryView createCategory(Long parentId, String name, String slug, int sortOrder) {
        if (parentId != null) {
            requireActiveCategory(parentId);
        }
        Instant now = spuMapper.currentTime();
        CategoryEntity entity = new CategoryEntity();
        entity.setParentId(parentId);
        entity.setName(name);
        entity.setSlug(slug);
        entity.setStatus(RecordStatus.ACTIVE.name());
        entity.setSortOrder(sortOrder);
        entity.setVersion(0);
        entity.setCreatedAt(now);
        entity.setUpdatedAt(now);
        categoryMapper.insert(entity);
        return categoryView(entity);
    }

    @Transactional
    public BrandView createBrand(String name, String slug) {
        Instant now = spuMapper.currentTime();
        BrandEntity entity = new BrandEntity();
        entity.setName(name);
        entity.setSlug(slug);
        entity.setStatus(RecordStatus.ACTIVE.name());
        entity.setVersion(0);
        entity.setCreatedAt(now);
        entity.setUpdatedAt(now);
        brandMapper.insert(entity);
        return brandView(entity);
    }

    @Transactional
    public ProductDetail createProduct(CreateProductCommand command) {
        CategoryEntity category = requireActiveCategory(command.categoryId());
        BrandEntity brand = requireActiveBrand(command.brandId());
        validateSkuCommands(command.skus());

        Instant now = spuMapper.currentTime();
        ProductSpuEntity spu = new ProductSpuEntity();
        spu.setCategoryId(command.categoryId());
        spu.setBrandId(command.brandId());
        spu.setTitle(command.title());
        spu.setSubtitle(command.subtitle());
        spu.setDescription(command.description());
        spu.setStatus(ProductStatus.DRAFT.name());
        spu.setVersion(0);
        spu.setSearchRevision(1L);
        spu.setCreatedAt(now);
        spu.setUpdatedAt(now);
        spuMapper.insert(spu);

        List<ProductSkuEntity> skus = new ArrayList<>(command.skus().size());
        for (CreateSkuCommand skuCommand : command.skus()) {
            validatePrices(skuCommand.salePrice(), skuCommand.marketPrice());
            ProductSkuEntity sku = new ProductSkuEntity();
            sku.setSpuId(spu.getId());
            sku.setSkuCode(skuCommand.skuCode());
            sku.setName(skuCommand.name());
            sku.setSpecJson(skuCommand.specJson());
            sku.setSalePrice(skuCommand.salePrice());
            sku.setMarketPrice(skuCommand.marketPrice());
            sku.setStatus(RecordStatus.ACTIVE.name());
            sku.setVersion(0);
            sku.setCreatedAt(now);
            sku.setUpdatedAt(now);
            skuMapper.insert(sku);
            skus.add(sku);
        }
        return productDetail(spu, category, brand, skus, List.of());
    }

    public PageResponse<ProductSummary> listProducts(long page, long size, Long categoryId, String keyword) {
        PageResponse<ProductSummarySnapshot> snapshot = Objects.requireNonNull(
                transactionTemplate.execute(ignored -> {
                    LambdaQueryWrapper<ProductSpuEntity> query = new LambdaQueryWrapper<ProductSpuEntity>()
                            .eq(ProductSpuEntity::getStatus, ProductStatus.ACTIVE.name())
                            .eq(categoryId != null, ProductSpuEntity::getCategoryId, categoryId)
                            .like(StringUtils.hasText(keyword), ProductSpuEntity::getTitle, keyword)
                            .orderByDesc(ProductSpuEntity::getCreatedAt)
                            .orderByDesc(ProductSpuEntity::getId);
                    Page<ProductSpuEntity> result = spuMapper.selectPage(Page.of(page, size), query);
                    return new PageResponse<>(
                            productSummarySnapshots(result.getRecords()),
                            page,
                            size,
                            result.getTotal());
                }));
        return new PageResponse<>(
                signProductSummaries(snapshot.items()),
                snapshot.page(),
                snapshot.size(),
                snapshot.total());
    }

    public CursorPageResponse<ProductSummary> listProductsByCursor(
            int size,
            Long categoryId,
            String keyword,
            String encodedCursor) {
        CursorPageResponse<ProductSummarySnapshot> snapshot = Objects.requireNonNull(
                transactionTemplate.execute(ignored -> {
                    KeysetCursor cursor = decodeCursor(encodedCursor);
                    String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null;
                    List<ProductSpuEntity> fetched = spuMapper.selectPublicCursorPage(
                            categoryId,
                            normalizedKeyword,
                            cursor == null ? null : cursor.createdAt(),
                            cursor == null ? null : cursor.id(),
                            size + 1);
                    boolean hasMore = fetched.size() > size;
                    List<ProductSpuEntity> products = hasMore
                            ? List.copyOf(fetched.subList(0, size))
                            : List.copyOf(fetched);
                    String nextCursor = hasMore
                            ? new KeysetCursor(
                                    products.get(products.size() - 1).getCreatedAt(),
                                    products.get(products.size() - 1).getId()).encode()
                            : null;
                    return new CursorPageResponse<>(
                            productSummarySnapshots(products),
                            nextCursor,
                            hasMore);
                }));
        return new CursorPageResponse<>(
                signProductSummaries(snapshot.items()),
                snapshot.nextCursor(),
                snapshot.hasMore());
    }

    private List<ProductSummarySnapshot> productSummarySnapshots(List<ProductSpuEntity> products) {
        if (products.isEmpty()) {
            return List.of();
        }
        Set<Long> productIds = ids(products, ProductSpuEntity::getId);
        Map<Long, CategoryEntity> categories = entitiesById(
                categoryMapper.selectByIds(ids(products, ProductSpuEntity::getCategoryId)), CategoryEntity::getId);
        Map<Long, BrandEntity> brands = entitiesById(
                brandMapper.selectByIds(ids(products, ProductSpuEntity::getBrandId)), BrandEntity::getId);
        Map<Long, List<ProductSkuEntity>> skusByProduct = skuMapper.selectList(
                        new LambdaQueryWrapper<ProductSkuEntity>()
                                .in(ProductSkuEntity::getSpuId, productIds)
                                .eq(ProductSkuEntity::getStatus, RecordStatus.ACTIVE.name()))
                .stream().collect(Collectors.groupingBy(ProductSkuEntity::getSpuId));
        Map<Long, List<ProductMediaEntity>> mediaByProduct = mediaMapper.selectList(
                        new LambdaQueryWrapper<ProductMediaEntity>()
                                .in(ProductMediaEntity::getSpuId, productIds)
                                .orderByAsc(ProductMediaEntity::getSortOrder, ProductMediaEntity::getId))
                .stream().collect(Collectors.groupingBy(ProductMediaEntity::getSpuId, LinkedHashMap::new, Collectors.toList()));

        return products.stream().map(product -> {
            List<ProductSkuEntity> skus = skusByProduct.getOrDefault(product.getId(), List.of());
            BigDecimal minimumPrice = skus.stream()
                    .map(ProductSkuEntity::getSalePrice)
                    .min(Comparator.naturalOrder())
                    .orElse(null);
            String coverObjectKey = mediaByProduct.getOrDefault(product.getId(), List.of()).stream()
                    .findFirst()
                    .map(ProductMediaEntity::getObjectKey)
                    .orElse(null);
            return new ProductSummarySnapshot(
                    product.getId(),
                    product.getTitle(),
                    product.getSubtitle(),
                    categoryView(categories.get(product.getCategoryId())),
                    brandView(brands.get(product.getBrandId())),
                    minimumPrice,
                    coverObjectKey
            );
        }).toList();
    }

    public List<ProductSummary> listActiveProductSummaries(List<Long> productIds) {
        if (productIds.isEmpty()) {
            return List.of();
        }
        List<ProductSummarySnapshot> snapshot = Objects.requireNonNull(
                transactionTemplate.execute(ignored -> {
                    Map<Long, ProductSpuEntity> activeById = spuMapper.selectByIds(productIds).stream()
                            .filter(product -> ProductStatus.ACTIVE.name().equals(product.getStatus()))
                            .collect(Collectors.toMap(ProductSpuEntity::getId, Function.identity()));
                    List<ProductSpuEntity> ordered = productIds.stream()
                            .map(activeById::get)
                            .filter(Objects::nonNull)
                            .toList();
                    return productSummarySnapshots(ordered);
                }));
        return signProductSummaries(snapshot);
    }

    private KeysetCursor decodeCursor(String encodedCursor) {
        if (!StringUtils.hasText(encodedCursor)) {
            return null;
        }
        try {
            return KeysetCursor.decode(encodedCursor);
        } catch (IllegalArgumentException exception) {
            throw new CatalogException(CatalogError.INVALID_CURSOR, exception);
        }
    }

    public ProductDetail getProduct(Long productId) {
        ProductDetail product = productDetailCache.get(
                        productId,
                        () -> loadActiveProductDetail(productId))
                .orElseThrow(() -> new CatalogException(CatalogError.RESOURCE_NOT_FOUND));
        return signMediaUrls(product);
    }

    public ProductDetail updateProduct(Long productId, UpdateProductCommand command) {
        ProductDetail product = Objects.requireNonNull(transactionTemplate.execute(ignored -> {
            ProductSpuEntity spu = requireProduct(productId);
            requireVersion(spu.getVersion(), command.expectedVersion());
            requireActiveCategory(command.categoryId());
            requireActiveBrand(command.brandId());
            spu.setCategoryId(command.categoryId());
            spu.setBrandId(command.brandId());
            spu.setTitle(command.title());
            spu.setSubtitle(command.subtitle());
            spu.setDescription(command.description());
            spu.setUpdatedAt(spuMapper.currentTime());
            requireUpdated(spuMapper.updateById(spu));
            searchOutboxService.recordProductChanged(productId);
            ProductDetail detail = loadProductDetail(spuMapper.selectById(productId));
            productDetailCache.invalidateAfterCommit(productId);
            return detail;
        }));
        return signMediaUrls(product);
    }

    public ProductDetail publishProduct(Long productId, int expectedVersion) {
        ProductDetail product = Objects.requireNonNull(transactionTemplate.execute(ignored -> {
            ProductSpuEntity spu = requireProduct(productId);
            requireVersion(spu.getVersion(), expectedVersion);
            if (ProductStatus.ACTIVE.name().equals(spu.getStatus())) {
                throw new CatalogException(CatalogError.INVALID_STATE);
            }
            Long activeSkuCount = skuMapper.selectCount(new LambdaQueryWrapper<ProductSkuEntity>()
                    .eq(ProductSkuEntity::getSpuId, productId)
                    .eq(ProductSkuEntity::getStatus, RecordStatus.ACTIVE.name()));
            if (activeSkuCount == 0) {
                throw new CatalogException(CatalogError.INVALID_STATE);
            }
            spu.setStatus(ProductStatus.ACTIVE.name());
            spu.setUpdatedAt(spuMapper.currentTime());
            requireUpdated(spuMapper.updateById(spu));
            searchOutboxService.recordProductChanged(productId);
            ProductDetail detail = loadProductDetail(spuMapper.selectById(productId));
            productDetailCache.invalidateAfterCommit(productId);
            return detail;
        }));
        return signMediaUrls(product);
    }

    public ProductDetail unpublishProduct(Long productId, int expectedVersion) {
        ProductDetail product = Objects.requireNonNull(transactionTemplate.execute(ignored -> {
            ProductSpuEntity spu = requireProduct(productId);
            requireVersion(spu.getVersion(), expectedVersion);
            if (!ProductStatus.ACTIVE.name().equals(spu.getStatus())) {
                throw new CatalogException(CatalogError.INVALID_STATE);
            }
            spu.setStatus(ProductStatus.INACTIVE.name());
            spu.setUpdatedAt(spuMapper.currentTime());
            requireUpdated(spuMapper.updateById(spu));
            searchOutboxService.recordProductChanged(productId);
            ProductDetail detail = loadProductDetail(spuMapper.selectById(productId));
            productDetailCache.invalidateAfterCommit(productId);
            return detail;
        }));
        return signMediaUrls(product);
    }

    @Transactional
    public SkuView updateSku(Long productId, Long skuId, UpdateSkuCommand command) {
        requireProduct(productId);
        validatePrices(command.salePrice(), command.marketPrice());
        RecordStatus status;
        try {
            status = RecordStatus.valueOf(command.status());
        } catch (IllegalArgumentException exception) {
            throw new CatalogException(CatalogError.INVALID_STATE);
        }
        ProductSkuEntity sku = skuMapper.selectById(skuId);
        if (sku == null || !productId.equals(sku.getSpuId())) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        requireVersion(sku.getVersion(), command.expectedVersion());
        sku.setName(command.name());
        sku.setSpecJson(command.specJson());
        sku.setSalePrice(command.salePrice());
        sku.setMarketPrice(command.marketPrice());
        sku.setStatus(status.name());
        sku.setUpdatedAt(spuMapper.currentTime());
        requireUpdated(skuMapper.updateById(sku));
        searchOutboxService.recordProductChanged(productId);
        SkuView view = skuView(skuMapper.selectById(skuId));
        productDetailCache.invalidateAfterCommit(productId);
        return view;
    }

    public UploadIntent createUploadIntent(Long productId, String contentType, long sizeBytes) {
        requireProduct(productId);
        String normalizedType = normalizeMediaType(contentType);
        validateMedia(normalizedType, sizeBytes);
        String objectKey = "products/%d/%s.%s".formatted(
                productId,
                UUID.randomUUID().toString().replace("-", ""),
                extensionFor(normalizedType));
        String uploadUrl = objectStorage.createUploadUrl(
                mediaProperties.bucket(), objectKey, mediaProperties.uploadExpiry());
        return new UploadIntent(objectKey, uploadUrl, mediaProperties.uploadExpiry().toSeconds());
    }

    public MediaView confirmMedia(Long productId, Long skuId, String objectKey, int sortOrder) {
        if (!objectKey.startsWith("products/" + productId + "/")) {
            throw new CatalogException(CatalogError.INVALID_MEDIA);
        }
        requireMediaOwner(productId, skuId);
        ObjectStorage.StoredObject storedObject = objectStorage.stat(mediaProperties.bucket(), objectKey);
        String contentType = normalizeMediaType(storedObject.contentType());
        validateMedia(contentType, storedObject.sizeBytes());

        ProductMediaEntity media = Objects.requireNonNull(transactionTemplate.execute(ignored -> {
            requireMediaOwner(productId, skuId);
            ProductMediaEntity candidate = new ProductMediaEntity();
            candidate.setSpuId(productId);
            candidate.setSkuId(skuId);
            candidate.setObjectKey(objectKey);
            candidate.setMimeType(contentType);
            candidate.setSizeBytes(storedObject.sizeBytes());
            candidate.setSortOrder(sortOrder);
            candidate.setCreatedAt(spuMapper.currentTime());
            mediaMapper.insert(candidate);
            searchOutboxService.recordProductChanged(productId);
            return candidate;
        }));
        productDetailCache.invalidateAfterCommit(productId);
        return signMediaUrl(mediaView(media));
    }

    private void requireMediaOwner(Long productId, Long skuId) {
        requireProduct(productId);
        if (skuId == null) {
            return;
        }
        ProductSkuEntity sku = skuMapper.selectById(skuId);
        if (sku == null || !productId.equals(sku.getSpuId())) {
            throw new CatalogException(CatalogError.INVALID_MEDIA);
        }
    }

    private Optional<ProductDetail> loadActiveProductDetail(Long productId) {
        ProductSpuEntity spu = spuMapper.selectById(productId);
        if (spu == null || !ProductStatus.ACTIVE.name().equals(spu.getStatus())) {
            return Optional.empty();
        }
        return Optional.of(loadProductDetail(spu));
    }

    private ProductDetail loadProductDetail(ProductSpuEntity spu) {
        CategoryEntity category = categoryMapper.selectById(spu.getCategoryId());
        BrandEntity brand = brandMapper.selectById(spu.getBrandId());
        List<ProductSkuEntity> skus = skuMapper.selectList(new LambdaQueryWrapper<ProductSkuEntity>()
                .eq(ProductSkuEntity::getSpuId, spu.getId())
                .orderByAsc(ProductSkuEntity::getCreatedAt));
        List<ProductMediaEntity> media = mediaMapper.selectList(new LambdaQueryWrapper<ProductMediaEntity>()
                .eq(ProductMediaEntity::getSpuId, spu.getId())
                .orderByAsc(ProductMediaEntity::getSortOrder, ProductMediaEntity::getId));
        return productDetail(spu, category, brand, skus, media);
    }

    private ProductDetail productDetail(
            ProductSpuEntity spu,
            CategoryEntity category,
            BrandEntity brand,
            List<ProductSkuEntity> skus,
            List<ProductMediaEntity> media) {
        return new ProductDetail(
                spu.getId(),
                spu.getTitle(),
                spu.getSubtitle(),
                spu.getDescription(),
                spu.getStatus(),
                spu.getVersion(),
                categoryView(category),
                brandView(brand),
                skus.stream().map(this::skuView).toList(),
                media.stream().map(this::mediaView).toList()
        );
    }

    private CategoryEntity requireActiveCategory(Long id) {
        CategoryEntity category = categoryMapper.selectById(id);
        if (category == null || !RecordStatus.ACTIVE.name().equals(category.getStatus())) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        return category;
    }

    private BrandEntity requireActiveBrand(Long id) {
        BrandEntity brand = brandMapper.selectById(id);
        if (brand == null || !RecordStatus.ACTIVE.name().equals(brand.getStatus())) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        return brand;
    }

    private ProductSpuEntity requireProduct(Long id) {
        ProductSpuEntity product = spuMapper.selectById(id);
        if (product == null) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        return product;
    }

    private void validateSkuCommands(List<CreateSkuCommand> skus) {
        if (skus.isEmpty()) {
            throw new CatalogException(CatalogError.INVALID_STATE);
        }
        Set<String> codes = new HashSet<>();
        for (CreateSkuCommand sku : skus) {
            if (!codes.add(sku.skuCode())) {
                throw new CatalogException(CatalogError.DUPLICATE_RESOURCE);
            }
        }
    }

    private void validatePrices(BigDecimal salePrice, BigDecimal marketPrice) {
        if (salePrice == null || salePrice.signum() <= 0 ||
                hasMoreThanTwoFractionDigits(salePrice) ||
                (marketPrice != null && (marketPrice.signum() <= 0
                        || hasMoreThanTwoFractionDigits(marketPrice)
                        || marketPrice.compareTo(salePrice) < 0))) {
            throw new CatalogException(CatalogError.INVALID_STATE);
        }
    }

    private boolean hasMoreThanTwoFractionDigits(BigDecimal amount) {
        return amount.stripTrailingZeros().scale() > 2;
    }

    private void validateMedia(String contentType, long sizeBytes) {
        if (!ALLOWED_MEDIA_TYPES.contains(contentType) || sizeBytes <= 0 ||
                sizeBytes > mediaProperties.maximumSize().toBytes()) {
            throw new CatalogException(CatalogError.INVALID_MEDIA);
        }
    }

    private String normalizeMediaType(String contentType) {
        if (contentType == null) {
            return "";
        }
        int separator = contentType.indexOf(';');
        String value = separator >= 0 ? contentType.substring(0, separator) : contentType;
        return value.trim().toLowerCase(Locale.ROOT);
    }

    private String extensionFor(String contentType) {
        return switch (contentType) {
            case "image/jpeg" -> "jpg";
            case "image/png" -> "png";
            case "image/webp" -> "webp";
            default -> throw new CatalogException(CatalogError.INVALID_MEDIA);
        };
    }

    private String safeDownloadUrl(String objectKey) {
        try {
            return objectStorage.createDownloadUrl(
                    mediaProperties.bucket(), objectKey, mediaProperties.downloadExpiry());
        } catch (RuntimeException exception) {
            log.warn("Product media URL signing failed; returning catalog data without media URL: objectKey={}", objectKey);
            return null;
        }
    }

    private ProductDetail signMediaUrls(ProductDetail product) {
        return new ProductDetail(
                product.id(),
                product.title(),
                product.subtitle(),
                product.description(),
                product.status(),
                product.version(),
                product.category(),
                product.brand(),
                product.skus(),
                product.media().stream().map(this::signMediaUrl).toList());
    }

    private List<ProductSummary> signProductSummaries(List<ProductSummarySnapshot> products) {
        return products.stream().map(product -> new ProductSummary(
                product.id(),
                product.title(),
                product.subtitle(),
                product.category(),
                product.brand(),
                product.minimumPrice(),
                product.coverObjectKey() == null
                        ? null
                        : safeDownloadUrl(product.coverObjectKey())))
                .toList();
    }

    private MediaView signMediaUrl(MediaView media) {
        return new MediaView(
                media.id(),
                media.skuId(),
                media.objectKey(),
                media.mimeType(),
                media.sizeBytes(),
                media.sortOrder(),
                safeDownloadUrl(media.objectKey()));
    }

    private void requireVersion(Integer actualVersion, int expectedVersion) {
        if (actualVersion == null || actualVersion != expectedVersion) {
            throw new CatalogException(CatalogError.CONCURRENT_MODIFICATION);
        }
    }

    private void requireUpdated(int updatedRows) {
        if (updatedRows != 1) {
            throw new CatalogException(CatalogError.CONCURRENT_MODIFICATION);
        }
    }

    private CategoryView categoryView(CategoryEntity category) {
        if (category == null) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        return new CategoryView(category.getId(), category.getParentId(), category.getName(),
                category.getSlug(), category.getSortOrder());
    }

    private BrandView brandView(BrandEntity brand) {
        if (brand == null) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        return new BrandView(brand.getId(), brand.getName(), brand.getSlug());
    }

    private SkuView skuView(ProductSkuEntity sku) {
        return new SkuView(sku.getId(), sku.getSkuCode(), sku.getName(), sku.getSpecJson(),
                sku.getSalePrice(), sku.getMarketPrice(), sku.getStatus(), sku.getVersion());
    }

    private MediaView mediaView(ProductMediaEntity media) {
        return new MediaView(media.getId(), media.getSkuId(), media.getObjectKey(), media.getMimeType(),
                media.getSizeBytes(), media.getSortOrder(), null);
    }

    private <T> Set<Long> ids(List<T> entities, Function<T, Long> idExtractor) {
        return entities.stream().map(idExtractor).collect(Collectors.toSet());
    }

    private <T> Map<Long, T> entitiesById(List<T> entities, Function<T, Long> idExtractor) {
        Map<Long, T> result = new HashMap<>();
        for (T entity : entities) {
            result.put(idExtractor.apply(entity), entity);
        }
        return result;
    }

    private record ProductSummarySnapshot(
            Long id,
            String title,
            String subtitle,
            CategoryView category,
            BrandView brand,
            BigDecimal minimumPrice,
            String coverObjectKey) {
    }
}