<?php

declare(strict_types=1);

/*
 * This file is part of the VITICORE2 package.
 *
 * (c) CP Creation <web@cpcreation.fr>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CpCreation\VitiCore\Commerce\Catalog;

use CpCreation\VitiCore\Bridge\Symfony\Bundle\Provider\LocaleProvider;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogBrand;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogCategory;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogCategoryLocalization;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogCompliance;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogCommerceConstraints;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogContainer;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogCountryRules;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogMedia;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogOption;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogOptionAvailability;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogOptionLocalization;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogOptionPriceAdjustment;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogPrice;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogProductLifecycle;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogProductLocalization;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogProductResource;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogProductReviews;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogProductSource;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogProductStatus;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogPublishability;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogQuantityRules;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogSeller;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogValidation;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogValidationIssue;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogVariant;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogVariantLocalization;
use CpCreation\VitiCore\Commerce\Catalog\Configuration\CatalogIntegrationConfiguration;
use CpCreation\VitiCore\Media\Model\Image;
use CpCreation\VitiCore\Product\Model\Product;
use CpCreation\VitiCore\Product\Model\ProductCategory;
use CpCreation\VitiCore\Product\Model\CommerceProductType;
use CpCreation\VitiCore\Product\Model\ProductPrice;
use CpCreation\VitiCore\Product\Model\ProductType;
use CpCreation\VitiCore\Product\Model\ProductVariant;
use CpCreation\VitiCore\Product\Model\ProductVariantOption;
use CpCreation\VitiCore\Product\Model\Translation\ProductCategoryTranslation;
use CpCreation\VitiCore\Product\Model\Translation\ProductTranslation;
use CpCreation\VitiCore\Product\Model\Translation\ProductTypeTranslation;
use CpCreation\VitiCore\Product\Model\Translation\ProductVariantOptionTranslation;
use CpCreation\VitiCore\Product\ProductVariantSkuResolver;
use CpCreation\VitiCore\Shop\Model\ShopConfig;
use CpCreation\VitiCore\Shop\Repository\ShopConfigRepository;
use CpCreation\VitiCore\Shop\ShopCatalogResolver;

/**
 * Projection canonique du domaine boutique. Aucune entite Doctrine n'est
 * exposee par l'API et aucune traduction de repli n'est creee en memoire.
 */
final readonly class CatalogProductProjector
{
    public function __construct(
        private CatalogIdentity $identity,
        private CatalogIntegrationConfiguration $configuration,
        private CatalogSellerProjector $sellerProjector,
        private CatalogMediaProjector $mediaProjector,
        private CatalogInventoryResolver $inventoryResolver,
        private CatalogStorefrontUrlGenerator $urlGenerator,
        private CatalogSnapshotHasher $snapshotHasher,
        private CatalogPublishabilityResolver $publishabilityResolver,
        private LocaleProvider $localeProvider,
        private ShopConfigRepository $shopConfigRepository,
        private ShopCatalogResolver $shopCatalogResolver,
        private ProductVariantSkuResolver $variantSkuResolver,
        private CatalogProductReviewsProjector $reviewsProjector,
    ) {}

    public function project(Product $product, ?\DateTimeImmutable $now = null): CatalogProductResource
    {
        $now ??= new \DateTimeImmutable();

        return $this->projectWithContext(
            $product,
            $this->shopConfigRepository->findOne(),
            $this->sellerProjector->project(),
            $this->reviewsProjector->project($product, $now),
            $now,
        );
    }

    /**
     * @param iterable<Product> $products
     *
     * @return list<CatalogProductResource>
     */
    public function projectCollection(iterable $products, ?\DateTimeImmutable $now = null): array
    {
        $products = \is_array($products) ? array_values($products) : iterator_to_array($products, false);
        $shopConfig = $this->shopConfigRepository->findOne();
        $seller = $this->sellerProjector->project();
        $now ??= new \DateTimeImmutable();
        $reviewsByProduct = $this->reviewsProjector->projectCollection($products, $now);
        $resources = [];

        foreach ($products as $product) {
            $resources[] = $this->projectWithContext(
                $product,
                $shopConfig,
                $seller,
                $reviewsByProduct[$product->getId()->toString()],
                $now,
            );
        }

        return $resources;
    }

    private function projectWithContext(
        Product $product,
        ?ShopConfig $shopConfig,
        CatalogSeller $seller,
        CatalogProductReviews $reviews,
        \DateTimeImmutable $now,
    ): CatalogProductResource {
        $issues = [];
        $snapshotAt = $this->timestamp($now);
        $locales = $this->locales();
        $published = $this->shopCatalogResolver->isProductVisibleInShop($product);
        $productTranslations = $this->productTranslations($product);
        $category = $product->getCategory();
        $categoryTranslations = $category instanceof ProductCategory
            ? $this->categoryTranslations($category)
            : [];
        $localizations = $this->productLocalizations($productTranslations, $locales, $issues);
        $catalogCategory = $this->category($category, $categoryTranslations, $locales, $issues);
        $compliance = $this->compliance($category, $issues);
        $purchaseUrls = $this->purchaseUrls(
            $product,
            $category,
            $productTranslations,
            $categoryTranslations,
            $locales,
            $shopConfig,
            $issues,
        );
        $media = $this->productMedia($product, $localizations);
        $this->validateMedia($media, 'media', $issues);
        $variants = $this->variants(
            $product,
            $productTranslations,
            $purchaseUrls,
            $shopConfig,
            $seller,
            $locales,
            $published,
            $now,
            $issues,
        );
        $this->validateProduct($product, $seller, $variants, $issues);

        $priceVisible = true !== $shopConfig?->isHidePriceDefault();
        $paused = true === $shopConfig?->isOrderingDisabledForVacation($now)
            || true === $shopConfig?->isShopDisabledForVacation($now);
        $validation = new CatalogValidation(
            valid: !$this->hasErrors($issues),
            issues: array_values($issues),
        );
        $hasOrderableVariant = $this->hasOrderableVariant($variants);
        $orderable = $published && $validation->valid && $hasOrderableVariant;
        $statusReasons = $this->statusReasons(
            $product,
            $published,
            $hasOrderableVariant,
            $validation->valid,
            $shopConfig,
            $now,
        );
        $source = new CatalogProductSource(
            instance_id: $this->identity->instanceId(),
            product_id: $product->getId()->toString(),
            product_updated_at: $this->updatedAt($product, $now),
            snapshot_hash: '',
        );
        $status = new CatalogProductStatus(
            published: $published,
            orderable: $orderable,
            price_visible: $priceVisible,
            temporarily_paused: $paused,
            reasons: $statusReasons,
        );
        $lifecycle = new CatalogProductLifecycle(CatalogProductLifecycle::ACTIVE);
        $publishability = $this->publishabilityResolver->resolve($product, $category, $status, $validation);
        $brand = new CatalogBrand($seller->display_name);
        $sku = $this->nullableText($product->getSku());
        $commerceConstraints = $this->commerceConstraints($shopConfig);
        $id = $this->identity->productId($product->getId());

        $draft = $this->resource(
            $snapshotAt,
            $id,
            $source,
            $lifecycle,
            $publishability,
            $status,
            $sku,
            $brand,
            $seller,
            $catalogCategory,
            $compliance,
            $localizations,
            $media,
            $variants,
            $reviews,
            $commerceConstraints,
            $validation,
        );
        $source = new CatalogProductSource(
            instance_id: $source->instance_id,
            product_id: $source->product_id,
            product_updated_at: $source->product_updated_at,
            snapshot_hash: $this->snapshotHasher->hash($draft),
        );

        return $this->resource(
            $snapshotAt,
            $id,
            $source,
            $lifecycle,
            $publishability,
            $status,
            $sku,
            $brand,
            $seller,
            $catalogCategory,
            $compliance,
            $localizations,
            $media,
            $variants,
            $reviews,
            $commerceConstraints,
            $validation,
        );
    }

    /**
     * @param array<string, CatalogProductLocalization> $localizations
     * @param list<CatalogMedia>                         $media
     * @param list<CatalogVariant>                       $variants
     */
    private function resource(
        string $snapshotAt,
        string $id,
        CatalogProductSource $source,
        CatalogProductLifecycle $lifecycle,
        CatalogPublishability $publishability,
        CatalogProductStatus $status,
        ?string $sku,
        CatalogBrand $brand,
        CatalogSeller $seller,
        ?CatalogCategory $category,
        CatalogCompliance $compliance,
        array $localizations,
        array $media,
        array $variants,
        CatalogProductReviews $reviews,
        CatalogCommerceConstraints $commerceConstraints,
        CatalogValidation $validation,
    ): CatalogProductResource {
        return new CatalogProductResource(
            schema_version: CatalogContract::SCHEMA_VERSION,
            snapshot_at: $snapshotAt,
            id: $id,
            source: $source,
            lifecycle: $lifecycle,
            publishability: $publishability,
            status: $status,
            sku: $sku,
            brand: $brand,
            seller: $seller,
            category: $category,
            compliance: $compliance,
            localizations: $localizations,
            media: $media,
            variants: $variants,
            reviews: $reviews,
            commerce_constraints: $commerceConstraints,
            validation: $validation,
        );
    }

    /**
     * @param array<string, ProductTranslation> $translations
     * @param list<string>                      $locales
     * @param array<string, CatalogValidationIssue> $issues
     *
     * @return array<string, CatalogProductLocalization>
     */
    private function productLocalizations(array $translations, array $locales, array &$issues): array
    {
        $localizations = [];
        $defaultLocale = $this->localeProvider->getDefaultLocale();

        foreach ($locales as $locale) {
            $translation = $translations[$locale] ?? null;
            if (!$translation instanceof ProductTranslation) {
                $this->issue(
                    $issues,
                    'product_translation_missing',
                    'localizations.' . $locale,
                    $locale === $defaultLocale ? 'error' : 'warning',
                );
                continue;
            }

            $title = CatalogText::plain($translation->getFullName());
            $name = CatalogText::plain($translation->getName());
            $slug = $this->nullableText($translation->getSlug());
            $description = CatalogText::rich($translation->getDescription());
            if ('' === $title) {
                $this->issue($issues, 'product_title_missing', 'localizations.' . $locale . '.title');
            }
            if (null === $slug) {
                $this->issue($issues, 'product_slug_missing', 'localizations.' . $locale . '.slug');
            }
            if (null === $description->plain) {
                $this->issue($issues, 'product_description_missing', 'localizations.' . $locale . '.description');
            }

            $localizations[$locale] = new CatalogProductLocalization(
                title: $title,
                name: $name,
                header: $this->nullablePlain($translation->getHeader()),
                quality: $this->nullablePlain($translation->getQuality()),
                slug: $slug,
                description: $description,
                introduction: CatalogText::rich($translation->getIntroText()),
                shop_information: CatalogText::rich($translation->getInfoShop()),
                dosage: $this->nullablePlain($translation->getDosage()),
                tasting: CatalogText::rich($translation->getTasting()),
                assemblages: $this->stringList($translation->getAssemblages()),
                winemaking: $this->stringList($translation->getWinemaking()),
            );
        }

        return $localizations;
    }

    /**
     * @param array<string, ProductCategoryTranslation> $translations
     * @param list<string>                               $locales
     * @param array<string, CatalogValidationIssue>      $issues
     */
    private function category(
        ?ProductCategory $category,
        array $translations,
        array $locales,
        array &$issues,
    ): ?CatalogCategory {
        if (!$category instanceof ProductCategory) {
            $this->issue($issues, 'product_category_missing', 'category');

            return null;
        }

        $localizations = [];
        $defaultLocale = $this->localeProvider->getDefaultLocale();
        foreach ($locales as $locale) {
            $translation = $translations[$locale] ?? null;
            if (!$translation instanceof ProductCategoryTranslation) {
                $this->issue(
                    $issues,
                    'category_translation_missing',
                    'category.localizations.' . $locale,
                    $locale === $defaultLocale ? 'error' : 'warning',
                );
                continue;
            }

            $name = CatalogText::plain($translation->getName());
            $slug = $this->nullableText($translation->getSlug());
            if ('' === $name) {
                $this->issue($issues, 'category_name_missing', 'category.localizations.' . $locale . '.name');
            }
            if (null === $slug) {
                $this->issue($issues, 'category_slug_missing', 'category.localizations.' . $locale . '.slug');
            }

            $localizations[$locale] = new CatalogCategoryLocalization($name, $slug);
        }

        return new CatalogCategory(
            id: $this->identity->categoryId($category->getId()),
            source_id: $category->getId()->toString(),
            localizations: $localizations,
        );
    }

    /**
     * @param array<string, CatalogValidationIssue> $issues
     */
    private function compliance(?ProductCategory $category, array &$issues): CatalogCompliance
    {
        if (!$category instanceof ProductCategory) {
            return new CatalogCompliance(
                catalog_export_enabled: false,
                product_type: CommerceProductType::UNKNOWN->value,
                age_restriction: null,
                regulated: false,
            );
        }

        $productType = $category->getCommerceProductType();
        $ageRestriction = $category->getMinimumPurchaseAge();
        if ($category->isCommerceExportEnabled() && CommerceProductType::UNKNOWN === $productType) {
            $this->issue($issues, 'commerce_product_type_missing', 'compliance.product_type');
        }
        if (CommerceProductType::ALCOHOL === $productType && null === $ageRestriction) {
            $this->issue($issues, 'age_restriction_missing', 'compliance.age_restriction');
        }

        return new CatalogCompliance(
            catalog_export_enabled: $category->isCommerceExportEnabled(),
            product_type: $productType->value,
            age_restriction: $ageRestriction,
            regulated: $productType->isRegulated() || null !== $ageRestriction,
        );
    }

    /**
     * @param array<string, ProductTranslation>         $productTranslations
     * @param array<string, ProductCategoryTranslation> $categoryTranslations
     * @param list<string>                               $locales
     * @param array<string, CatalogValidationIssue>      $issues
     *
     * @return array<string, string>
     */
    private function purchaseUrls(
        Product $product,
        ?ProductCategory $category,
        array $productTranslations,
        array $categoryTranslations,
        array $locales,
        ?ShopConfig $shopConfig,
        array &$issues,
    ): array {
        if (!$category instanceof ProductCategory) {
            return [];
        }

        $urls = [];
        foreach ($locales as $locale) {
            $productTranslation = $productTranslations[$locale] ?? null;
            $categoryTranslation = $categoryTranslations[$locale] ?? null;
            if (!$productTranslation instanceof ProductTranslation || !$categoryTranslation instanceof ProductCategoryTranslation) {
                continue;
            }

            $url = $this->urlGenerator->product(
                $product,
                $productTranslation,
                $categoryTranslation,
                $locale,
                $shopConfig,
            );
            if (null !== $url) {
                $urls[$locale] = $url;
            } else {
                $this->issue(
                    $issues,
                    'purchase_url_missing',
                    'purchase_urls.' . $locale,
                    $locale === $this->localeProvider->getDefaultLocale() ? 'error' : 'warning',
                );
            }
        }

        return $urls;
    }

    /**
     * @param array<string, ProductTranslation>    $productTranslations
     * @param array<string, string>                $purchaseUrls
     * @param list<string>                         $locales
     * @param array<string, CatalogValidationIssue> $issues
     *
     * @return list<CatalogVariant>
     */
    private function variants(
        Product $product,
        array $productTranslations,
        array $purchaseUrls,
        ?ShopConfig $shopConfig,
        CatalogSeller $seller,
        array $locales,
        bool $productPublished,
        \DateTimeImmutable $now,
        array &$issues,
    ): array {
        $eligibleVariants = [];
        foreach ($product->getVariants() as $variant) {
            if (!$variant->isDeleted() && !$variant->isUnPublished()) {
                $eligibleVariants[] = $variant;
            }
        }

        $variants = [];
        foreach ($eligibleVariants as $variant) {
            $path = 'variants.' . $this->identity->variantId($variant->getId());
            $type = $variant->getType();
            $typeTranslations = $type instanceof ProductType ? $this->typeTranslations($type) : [];
            if (!$type instanceof ProductType) {
                $this->issue($issues, 'variant_type_missing', $path . '.type');
            }

            $localizations = $this->variantLocalizations(
                $productTranslations,
                $typeTranslations,
                $type,
                $locales,
                $path,
                $issues,
            );
            $publicPrice = $this->defaultPrice($variant);
            if (!$publicPrice instanceof ProductPrice || null === $publicPrice->getPrice() || $publicPrice->getPrice() <= 0) {
                $this->issue($issues, 'public_price_missing', $path . '.price');
            }
            $hasPublicPrice = $publicPrice instanceof ProductPrice
                && null !== $publicPrice->getPrice()
                && $publicPrice->getPrice() > 0;
            $availability = $this->inventoryResolver->resolve(
                $product,
                $variant,
                $shopConfig,
                $hasPublicPrice,
                $productPublished,
                isset($purchaseUrls[$this->localeProvider->getDefaultLocale()]),
                $now,
            );
            $media = $this->variantMedia($variant, $localizations);
            $this->validateMedia($media, $path . '.media', $issues, false);
            $options = $this->options($variant, $availability->orderable, $locales, $issues);
            $lot = max(1, $variant->getLot() ?? 1);

            $variants[] = new CatalogVariant(
                id: $this->identity->variantId($variant->getId()),
                source_id: $variant->getId()->toString(),
                seller_id: $seller->id,
                sku: $this->variantSkuResolver->resolve($variant),
                default: $variant->isByDefault(),
                position: $variant->getPosition(),
                localizations: $localizations,
                container: new CatalogContainer(
                    volume: $type?->getVolume(),
                    unit: $this->nullableText($type?->getUnity()),
                ),
                price: $this->price($publicPrice, true !== $shopConfig?->isHidePriceDefault()),
                availability: $availability,
                quantity_rules: new CatalogQuantityRules(
                    minimum: $lot,
                    increment: $lot,
                    maximum: $type?->getMaximumQuantityCart(),
                ),
                purchase_urls: $purchaseUrls,
                media: $media,
                options: $options,
            );
        }

        if ([] === $variants) {
            $this->issue($issues, 'product_variant_missing', 'variants');
        } elseif (!$this->hasDefaultVariant($variants)) {
            $this->issue($issues, 'default_variant_missing', 'variants', 'warning');
        }

        return $variants;
    }

    /**
     * @param array<string, ProductTranslation>     $productTranslations
     * @param array<string, ProductTypeTranslation> $typeTranslations
     * @param list<string>                          $locales
     * @param array<string, CatalogValidationIssue> $issues
     *
     * @return array<string, CatalogVariantLocalization>
     */
    private function variantLocalizations(
        array $productTranslations,
        array $typeTranslations,
        ?ProductType $type,
        array $locales,
        string $path,
        array &$issues,
    ): array {
        $localizations = [];
        foreach ($locales as $locale) {
            $productTranslation = $productTranslations[$locale] ?? null;
            $typeTranslation = $typeTranslations[$locale] ?? null;
            if (!$productTranslation instanceof ProductTranslation || !$typeTranslation instanceof ProductTypeTranslation) {
                if ($type instanceof ProductType && !$typeTranslation instanceof ProductTypeTranslation) {
                    $this->issue(
                        $issues,
                        'variant_type_translation_missing',
                        $path . '.localizations.' . $locale,
                        $locale === $this->localeProvider->getDefaultLocale() ? 'error' : 'warning',
                    );
                }
                continue;
            }

            $productTitle = CatalogText::plain($productTranslation->getFullName());
            $typeName = CatalogText::plain($typeTranslation->getName());
            $title = mb_trim(implode(' - ', array_filter([$productTitle, $typeName])));
            $container = mb_trim(implode(' ', array_filter([
                null !== $type?->getVolume() ? (string) $type->getVolume() : null,
                $this->nullablePlain($type?->getUnity()),
            ])));
            $label = mb_trim(implode(' ', array_filter([$typeName, $container])));

            $localizations[$locale] = new CatalogVariantLocalization($title, $typeName, $label);
        }

        return $localizations;
    }

    /**
     * @param list<string>                          $locales
     * @param array<string, CatalogValidationIssue> $issues
     *
     * @return list<CatalogOption>
     */
    private function options(
        ProductVariant $variant,
        bool $variantOrderable,
        array $locales,
        array &$issues,
    ): array {
        $options = [];
        foreach ($variant->getOptions() as $option) {
            $path = 'variants.' . $this->identity->variantId($variant->getId())
                . '.options.' . $this->identity->optionId($option->getId());
            $translations = $this->optionTranslations($option);
            $localizations = [];
            foreach ($locales as $locale) {
                $translation = $translations[$locale] ?? null;
                if (!$translation instanceof ProductVariantOptionTranslation) {
                    $this->issue(
                        $issues,
                        'option_translation_missing',
                        $path . '.localizations.' . $locale,
                        $locale === $this->localeProvider->getDefaultLocale() ? 'error' : 'warning',
                    );
                    continue;
                }

                $name = CatalogText::plain($translation->getName());
                if ('' === $name) {
                    $this->issue($issues, 'option_name_missing', $path . '.localizations.' . $locale . '.name');
                }
                $localizations[$locale] = new CatalogOptionLocalization($name);
            }

            $optionAvailable = $option->isAvailable();
            $orderable = $variantOrderable && $optionAvailable;
            $media = $this->optionMedia($option, $localizations);
            $this->validateMedia($media, $path . '.media', $issues, false);
            $options[] = new CatalogOption(
                id: $this->identity->optionId($option->getId()),
                source_id: $option->getId()->toString(),
                position: $option->getPosition(),
                localizations: $localizations,
                price_adjustment: new CatalogOptionPriceAdjustment(
                    amount_minor: $option->getPrice(),
                    currency: $this->configuration->currency(),
                    minor_unit_exponent: $this->configuration->minorUnitExponent(),
                    mode: $option->isFlatFee() ? 'flat' : 'per_unit',
                ),
                availability: new CatalogOptionAvailability(
                    status: $optionAvailable ? 'in_stock' : 'out_of_stock',
                    orderable: $orderable,
                ),
                media: $media,
            );
        }

        return $options;
    }

    private function price(?ProductPrice $price, bool $publiclyVisible): CatalogPrice
    {
        $regularAmount = $price?->getPrice();
        $discountAmount = $price?->getPriceDiscount();
        if (null === $regularAmount || $regularAmount <= 0) {
            $regularAmount = null;
            $discountAmount = null;
        } elseif (null === $discountAmount || $discountAmount <= 0 || $discountAmount >= $regularAmount) {
            $discountAmount = null;
        }

        $amount = $discountAmount ?? $regularAmount;
        if (!$publiclyVisible) {
            $amount = null;
            $regularAmount = null;
            $discountAmount = null;
        }

        return new CatalogPrice(
            amount_minor: $amount,
            regular_amount_minor: $regularAmount,
            discount_amount_minor: $discountAmount,
            currency: $this->configuration->currency(),
            minor_unit_exponent: $this->configuration->minorUnitExponent(),
            tax_included: true,
            publicly_visible: $publiclyVisible,
        );
    }

    /**
     * @param array<string, CatalogProductLocalization> $localizations
     *
     * @return list<CatalogMedia>
     */
    private function productMedia(Product $product, array $localizations): array
    {
        $media = [];
        $seenIds = [];
        $seenPaths = [];
        $fallbacks = [];
        foreach ($localizations as $locale => $localization) {
            $fallbacks[$locale] = $localization->title;
        }
        foreach ([$product->getSecondImage(), $product->getImage()] as $primary) {
            if (!$primary instanceof Image) {
                continue;
            }

            if ($this->appendMedia(
                $media,
                $seenIds,
                $seenPaths,
                $primary,
                'product',
                'primary',
                $this->mediaAlt($primary, $fallbacks),
                true,
            )) {
                break;
            }
        }

        foreach ($product->getImages() as $productImage) {
            $image = $productImage->getImage();
            if ($image instanceof Image) {
                $this->appendMedia(
                    $media,
                    $seenIds,
                    $seenPaths,
                    $image,
                    'product',
                    'gallery',
                    $this->mediaAlt($image, $fallbacks),
                );
            }
        }

        return $media;
    }

    /**
     * @param array<string, CatalogVariantLocalization> $localizations
     *
     * @return list<CatalogMedia>
     */
    private function variantMedia(ProductVariant $variant, array $localizations): array
    {
        $media = [];
        $seenIds = [];
        $seenPaths = [];
        $fallbacks = [];
        foreach ($localizations as $locale => $localization) {
            $fallbacks[$locale] = $localization->title;
        }
        foreach ($variant->getImages() as $variantImage) {
            $image = $variantImage->getImage();
            if ($image instanceof Image) {
                $this->appendMedia(
                    $media,
                    $seenIds,
                    $seenPaths,
                    $image,
                    'variant',
                    [] === $media ? 'primary' : 'gallery',
                    $this->mediaAlt($image, $fallbacks),
                );
            }
        }

        return $media;
    }

    /**
     * @param array<string, CatalogOptionLocalization> $localizations
     *
     * @return list<CatalogMedia>
     */
    private function optionMedia(ProductVariantOption $option, array $localizations): array
    {
        $media = [];
        $seenIds = [];
        $seenPaths = [];
        $fallbacks = [];
        foreach ($localizations as $locale => $localization) {
            $fallbacks[$locale] = $localization->name;
        }
        foreach ($option->getImages() as $optionImage) {
            $image = $optionImage->getImage();
            if ($image instanceof Image) {
                $this->appendMedia(
                    $media,
                    $seenIds,
                    $seenPaths,
                    $image,
                    'option',
                    [] === $media ? 'primary' : 'gallery',
                    $this->mediaAlt($image, $fallbacks),
                );
            }
        }

        return $media;
    }

    /**
     * @param list<CatalogMedia>        $media
     * @param array<string, true>       $seenIds
     * @param array<string, true>       $seenPaths
     * @param array<string, string>     $altByLocale
     */
    private function appendMedia(
        array &$media,
        array &$seenIds,
        array &$seenPaths,
        Image $image,
        string $scope,
        string $role,
        array $altByLocale,
        bool $requireRendition = false,
    ): bool {
        $id = $image->getId()->toString();
        $path = mb_trim((string) $image->getPath());
        if (isset($seenIds[$id]) || ('' !== $path && isset($seenPaths[$path]))) {
            return false;
        }

        $projected = $this->mediaProjector->project(
            $image,
            $scope,
            $role,
            \count($media),
            $altByLocale,
        );
        if (!$projected instanceof CatalogMedia || ($requireRendition && [] === $projected->renditions)) {
            return false;
        }

        $seenIds[$id] = true;
        if ('' !== $path) {
            $seenPaths[$path] = true;
        }
        $media[] = $projected;

        return true;
    }

    /**
     * @param array<string, string> $fallbacks
     *
     * @return array<string, string>
     */
    private function mediaAlt(Image $image, array $fallbacks): array
    {
        $alt = [];
        $title = $this->nullablePlain($image->getTitle());
        foreach ($fallbacks as $locale => $fallback) {
            $alt[$locale] = $title ?? $fallback;
        }

        return $alt;
    }

    /**
     * @param list<CatalogMedia>                    $media
     * @param array<string, CatalogValidationIssue> $issues
     */
    private function validateMedia(array $media, string $path, array &$issues, bool $required = true): void
    {
        if ($required && [] === $media) {
            $this->issue($issues, 'product_media_missing', $path);

            return;
        }

        foreach ($media as $index => $item) {
            if ([] === $item->renditions) {
                $this->issue($issues, 'media_rendition_missing', $path . '.' . $index . '.renditions');
            }
        }
    }

    /**
     * @param list<CatalogVariant>                   $variants
     * @param array<string, CatalogValidationIssue>  $issues
     */
    private function validateProduct(Product $product, CatalogSeller $seller, array $variants, array &$issues): void
    {
        if ('' === $seller->source_contact_id) {
            $this->issue($issues, 'seller_missing', 'seller.source_contact_id');
        }
        if ('' === $seller->display_name) {
            $this->issue($issues, 'seller_name_missing', 'seller.display_name');
        }
        if (null === $seller->legal_name) {
            $this->issue($issues, 'seller_legal_name_missing', 'seller.legal_name');
        }
        if (null === $seller->website_url) {
            $this->issue($issues, 'seller_website_missing', 'seller.website_url');
        }
        if (1 !== preg_match('/^[A-Z]{2}$/', $seller->store_country)) {
            $this->issue($issues, 'seller_country_invalid', 'seller.store_country');
        }
        $linkTypes = [];
        foreach ($seller->links as $link) {
            $linkTypes[$link->type] = isset($link->urls[$this->localeProvider->getDefaultLocale()]);
        }
        foreach (['privacy_policy', 'terms_of_service', 'refund_policy'] as $requiredLinkType) {
            if (!isset($linkTypes[$requiredLinkType])) {
                $this->issue($issues, 'seller_policy_link_missing', 'seller.links.' . $requiredLinkType);
            }
        }
        if (null === $this->nullableText($product->getSku()) && \count($variants) > 1) {
            $this->issue($issues, 'product_sku_missing', 'sku', 'warning');
        }
    }

    private function defaultPrice(ProductVariant $variant): ?ProductPrice
    {
        $prices = [];
        foreach ($variant->getPrices() as $price) {
            $group = $price->getGroup();
            if (!$price->isDeleted() && null !== $group && !$group->isDeleted() && $group->isByDefault()) {
                $prices[$price->getId()->toString()] = $price;
            }
        }

        ksort($prices);

        return [] !== $prices ? reset($prices) : null;
    }

    /**
     * @return array<string, ProductTranslation>
     */
    private function productTranslations(Product $product): array
    {
        $translations = [];
        foreach ($product->getTranslations() as $translation) {
            if ($translation instanceof ProductTranslation) {
                $translations[$translation->getLocale()] = $translation;
            }
        }

        ksort($translations);

        return $translations;
    }

    /**
     * @return array<string, ProductCategoryTranslation>
     */
    private function categoryTranslations(ProductCategory $category): array
    {
        $translations = [];
        foreach ($category->getTranslations() as $translation) {
            if ($translation instanceof ProductCategoryTranslation) {
                $translations[$translation->getLocale()] = $translation;
            }
        }

        ksort($translations);

        return $translations;
    }

    /**
     * @return array<string, ProductTypeTranslation>
     */
    private function typeTranslations(ProductType $type): array
    {
        $translations = [];
        foreach ($type->getTranslations() as $translation) {
            if ($translation instanceof ProductTypeTranslation) {
                $translations[$translation->getLocale()] = $translation;
            }
        }

        ksort($translations);

        return $translations;
    }

    /**
     * @return array<string, ProductVariantOptionTranslation>
     */
    private function optionTranslations(ProductVariantOption $option): array
    {
        $translations = [];
        foreach ($option->getTranslations() as $translation) {
            if ($translation instanceof ProductVariantOptionTranslation) {
                $translations[$translation->getLocale()] = $translation;
            }
        }

        ksort($translations);

        return $translations;
    }

    /**
     * @return list<string>
     */
    private function locales(): array
    {
        $defaultLocale = mb_trim($this->localeProvider->getDefaultLocale());
        $locales = [];
        foreach ($this->localeProvider->getLocales() as $locale) {
            if (!\is_string($locale)) {
                continue;
            }

            $locale = mb_trim($locale);
            if ('' !== $locale && $locale !== $defaultLocale) {
                $locales[$locale] = true;
            }
        }
        $locales = array_keys($locales);
        sort($locales);

        return '' !== $defaultLocale ? [$defaultLocale, ...$locales] : $locales;
    }

    private function commerceConstraints(?ShopConfig $shopConfig): CatalogCommerceConstraints
    {
        $allowed = $shopConfig?->getEnableCountriesForShop() ?? [];
        $allowed = array_values(array_unique(array_filter(array_map(
            static fn(mixed $country): string => mb_strtoupper(mb_trim(\is_string($country) ? $country : '')),
            $allowed,
        ))));
        sort($allowed);

        $denied = [];
        foreach ($shopConfig?->getDisableCountries() ?? [] as $disabledCountry) {
            $country = mb_strtoupper(mb_trim($disabledCountry->getCountry()));
            if ('' !== $country) {
                $denied[] = $country;
            }
        }
        $denied = array_values(array_unique($denied));
        sort($denied);

        return new CatalogCommerceConstraints(
            currency: $this->configuration->currency(),
            country_rules: new CatalogCountryRules(
                allowed: [] !== $allowed ? $allowed : null,
                denied: $denied,
            ),
        );
    }

    /**
     * @param list<CatalogVariant> $variants
     */
    private function hasOrderableVariant(array $variants): bool
    {
        foreach ($variants as $variant) {
            if ($variant->availability->orderable) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param list<CatalogVariant> $variants
     */
    private function hasDefaultVariant(array $variants): bool
    {
        foreach ($variants as $variant) {
            if ($variant->default) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return list<string>
     */
    private function statusReasons(
        Product $product,
        bool $published,
        bool $hasOrderableVariant,
        bool $valid,
        ?ShopConfig $shopConfig,
        \DateTimeImmutable $now,
    ): array {
        $reasons = [];
        if (!$published) {
            $reasons[] = 'not_published';
        }
        if ($product->isSoldOut()) {
            $reasons[] = 'product_sold_out';
        }
        if (!$valid) {
            $reasons[] = 'catalog_validation_failed';
        }
        if (true === $shopConfig?->isHidePriceDefault()) {
            $reasons[] = 'price_hidden';
        }
        if (true === $shopConfig?->isOrderingDisabledForVacation($now)) {
            $reasons[] = 'ordering_paused';
        }
        if (true === $shopConfig?->isShopDisabledForVacation($now)) {
            $reasons[] = 'shop_paused';
        }
        if (!$hasOrderableVariant) {
            $reasons[] = 'no_orderable_variant';
        }

        return array_values(array_unique($reasons));
    }

    /**
     * @param array<string, CatalogValidationIssue> $issues
     */
    private function issue(
        array &$issues,
        string $code,
        string $path,
        string $severity = 'error',
    ): void {
        $key = $code . '|' . $path . '|' . $severity;
        $issues[$key] = new CatalogValidationIssue($code, $path, $severity);
    }

    /**
     * @param array<string, CatalogValidationIssue> $issues
     */
    private function hasErrors(array $issues): bool
    {
        foreach ($issues as $issue) {
            if ('error' === $issue->severity) {
                return true;
            }
        }

        return false;
    }

    private function updatedAt(Product $product, \DateTimeImmutable $fallback): string
    {
        try {
            return $this->timestamp($product->getUpdatedAt());
        } catch (\Error) {
            return $this->timestamp($fallback);
        }
    }

    private function timestamp(\DateTimeInterface $date): string
    {
        return \DateTimeImmutable::createFromInterface($date)
            ->setTimezone(new \DateTimeZone('UTC'))
            ->format(\DATE_ATOM);
    }

    private function nullableText(?string $value): ?string
    {
        if (null === $value) {
            return null;
        }

        $value = mb_trim($value);

        return '' !== $value ? $value : null;
    }

    private function nullablePlain(?string $value): ?string
    {
        $value = CatalogText::plain($value);

        return '' !== $value ? $value : null;
    }

    /**
     * @param array<mixed>|null $values
     *
     * @return list<string>
     */
    private function stringList(?array $values): array
    {
        return array_values(array_filter(
            $values ?? [],
            static fn(mixed $value): bool => \is_string($value) && '' !== mb_trim($value),
        ));
    }
}
