<?php
/**
 * For the full copyright and license information, please view the
 * docs/licenses/LICENSE.txt file that was distributed with this source code.
 */

declare(strict_types=1);

namespace PrestaShop\PrestaShop\Adapter\Product\Update;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Language;
use PrestaShop\PrestaShop\Adapter\Product\Combination\Repository\CombinationRepository;
use PrestaShop\PrestaShop\Adapter\Product\Combination\Update\CombinationStockProperties;
use PrestaShop\PrestaShop\Adapter\Product\Combination\Update\CombinationStockUpdater;
use PrestaShop\PrestaShop\Adapter\Product\Image\ProductImagePathFactory;
use PrestaShop\PrestaShop\Adapter\Product\Image\Repository\ProductImageRepository;
use PrestaShop\PrestaShop\Adapter\Product\Repository\ProductRepository;
use PrestaShop\PrestaShop\Adapter\Product\Repository\ProductSupplierRepository;
use PrestaShop\PrestaShop\Adapter\Product\SpecificPrice\Repository\SpecificPriceRepository;
use PrestaShop\PrestaShop\Adapter\Product\Stock\Repository\StockAvailableRepository;
use PrestaShop\PrestaShop\Adapter\Product\Stock\Update\ProductStockProperties;
use PrestaShop\PrestaShop\Adapter\Product\Stock\Update\ProductStockUpdater;
use PrestaShop\PrestaShop\Adapter\Tools;
use PrestaShop\PrestaShop\Core\Domain\Product\Combination\ValueObject\CombinationId;
use PrestaShop\PrestaShop\Core\Domain\Product\Exception\CannotDuplicateProductException;
use PrestaShop\PrestaShop\Core\Domain\Product\Exception\CannotUpdateProductException;
use PrestaShop\PrestaShop\Core\Domain\Product\Image\ValueObject\ImageId;
use PrestaShop\PrestaShop\Core\Domain\Product\ProductSettings;
use PrestaShop\PrestaShop\Core\Domain\Product\Stock\Exception\StockAvailableNotFoundException;
use PrestaShop\PrestaShop\Core\Domain\Product\Stock\ValueObject\OutOfStockType;
use PrestaShop\PrestaShop\Core\Domain\Product\Stock\ValueObject\StockModification;
use PrestaShop\PrestaShop\Core\Domain\Product\Supplier\ValueObject\ProductSupplierId;
use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductId;
use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductType;
use PrestaShop\PrestaShop\Core\Domain\Shop\Exception\ShopAssociationNotFound;
use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopCollection;
use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint;
use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopId;
use PrestaShop\PrestaShop\Core\Exception\CoreException;
use PrestaShop\PrestaShop\Core\Exception\InvalidArgumentException;
use PrestaShop\PrestaShop\Core\Hook\HookDispatcherInterface;
use PrestaShop\PrestaShop\Core\Repository\AbstractMultiShopObjectModelRepository;
use PrestaShop\PrestaShop\Core\Util\DateTime\DateTime;
use PrestaShop\PrestaShop\Core\Util\String\StringModifierInterface;
use PrestaShopException;
use Product;
use ProductDownload as VirtualProductFile;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
 * Duplicates product
 */
class ProductDuplicator extends AbstractMultiShopObjectModelRepository
{
    public function __construct(
        protected readonly ProductRepository $productRepository,
        protected readonly HookDispatcherInterface $hookDispatcher,
        protected readonly TranslatorInterface $translator,
        protected readonly StringModifierInterface $stringModifier,
        protected readonly Connection $connection,
        protected readonly string $dbPrefix,
        protected readonly CombinationRepository $combinationRepository,
        protected readonly ProductSupplierRepository $productSupplierRepository,
        protected readonly SpecificPriceRepository $specificPriceRepository,
        protected readonly StockAvailableRepository $stockAvailableRepository,
        protected readonly ProductStockUpdater $productStockUpdater,
        protected readonly CombinationStockUpdater $combinationStockUpdater,
        protected readonly ProductImageRepository $productImageRepository,
        protected readonly ProductImagePathFactory $productImageSystemPathFactory,
        protected readonly Tools $tools,
    ) {
    }

    /**
     * @param ProductId $productId
     * @param ShopConstraint $shopConstraint
     *
     * @return ProductId new product id
     *
     * @throws CannotDuplicateProductException
     * @throws CannotUpdateProductException
     * @throws CoreException
     */
    public function duplicate(ProductId $productId, ShopConstraint $shopConstraint): ProductId
    {
        // @todo: add database transaction. After/if PR #21740 gets merged
        $oldProductId = $productId->getValue();
        $this->hookDispatcher->dispatchWithParameters(
            'actionAdminDuplicateBefore',
            ['id_product' => $oldProductId]
        );
        $newProduct = $this->duplicateProduct($productId, $shopConstraint);
        $newProductId = (int) $newProduct->id;

        $this->duplicateRelations($oldProductId, $newProductId, $shopConstraint, $newProduct->getProductType());

        if ($newProduct->hasAttributes()) {
            $this->updateDefaultAttribute($newProductId, $oldProductId);
        }

        $this->hookDispatcher->dispatchWithParameters(
            'actionProductAdd',
            ['id_product_old' => $oldProductId, 'id_product' => $newProductId, 'product' => $newProduct]
        );

        $this->hookDispatcher->dispatchWithParameters(
            'actionAdminDuplicateAfter',
            ['id_product' => $oldProductId, 'id_product_new' => $newProductId]
        );

        // @todo: after ##21740 (transactions PR) is resolved.
        //  Based on if its accepted or not, we need to implement roll back if something went wrong.
        //  If transactions are accepted then we use it, else we manually rewind (delete the duplicate product)
        return new ProductId((int) $newProduct->id);
    }

    /**
     * @param ProductId $sourceProductId
     * @param ShopConstraint $shopConstraint
     *
     * @return Product
     */
    private function duplicateProduct(ProductId $sourceProductId, ShopConstraint $shopConstraint): Product
    {
        $sourceDefaultShopId = $this->productRepository->getProductDefaultShopId($sourceProductId);
        $shopIds = $this->productRepository->getShopIdsByConstraint($sourceProductId, $shopConstraint);

        if (empty($shopIds)) {
            throw new ShopAssociationNotFound(
                sprintf(
                    'No shops associated with product %d by shop constraint %s',
                    $sourceProductId->getValue(),
                    var_export($shopConstraint, true)
                )
            );
        }

        if ($shopConstraint->getShopId()) {
            $targetDefaultShopId = $shopConstraint->getShopId();
        } elseif ($shopConstraint->getShopGroupId() || ($shopConstraint instanceof ShopCollection && $shopConstraint->hasShopIds())) {
            // If source default shop is in the group use it as new default, if not use the first shop from group
            $targetDefaultShopId = null;
            foreach ($shopIds as $groupShopId) {
                if ($groupShopId->getValue() === $sourceDefaultShopId->getValue()) {
                    $targetDefaultShopId = $sourceDefaultShopId;
                }
            }
            if ($targetDefaultShopId === null) {
                $targetDefaultShopId = reset($shopIds);
            }
        } else {
            $targetDefaultShopId = $sourceDefaultShopId;
        }

        // First add the product to its default shop
        $sourceProduct = $this->productRepository->get($sourceProductId, $targetDefaultShopId);
        $duplicatedProduct = $this->duplicateObjectModelToShop($sourceProduct, $targetDefaultShopId);

        // Then associate it to other shops and copy its values
        $newProductId = new ProductId((int) $duplicatedProduct->id);
        foreach ($shopIds as $shopId) {
            $shopProduct = $this->productRepository->get($sourceProductId, $shopId);
            // The duplicated product is disabled and not indexed by default
            $shopProduct->indexed = false;
            $shopProduct->active = false;
            $shopProduct->date_add = date('Y-m-d H:i:s');
            // Force a copy name to tell the two products apart (for each shop since name can be different on each shop)
            $shopProduct->name = $this->getNewProductName($shopProduct->name);
            $shopProduct->link_rewrite = $this->getNewProductLinkRewrite($shopProduct->link_rewrite);
            // Force ID to update the new product
            $shopProduct->id = $shopProduct->id_product = $newProductId->getValue();
            // Force the desired default shop so that it doesn't switch back to the source one
            $shopProduct->id_shop_default = $targetDefaultShopId->getValue();
            $this->productRepository->update(
                $shopProduct,
                ShopConstraint::shop($shopId->getValue()),
                CannotUpdateProductException::FAILED_DUPLICATION
            );
        }

        return $duplicatedProduct;
    }

    /**
     * @template T
     *
     * @psalm-param T $sourceObjectModel
     *
     * @return T
     */
    private function duplicateObjectModelToShop($sourceObjectModel, ShopId $targetDefaultShopId)
    {
        $duplicatedObject = clone $sourceObjectModel;
        unset($duplicatedObject->id);

        $objectDefinition = $sourceObjectModel::$definition;
        $idTable = 'id_' . $objectDefinition['table'];
        if (property_exists($duplicatedObject, $idTable)) {
            unset($duplicatedObject->$idTable);
        }

        $this->addObjectModelToShops($duplicatedObject, [$targetDefaultShopId], CannotDuplicateProductException::class);

        return $duplicatedObject;
    }

    /**
     * Provides duplicated product name
     *
     * @param array<int, string> $oldProductLocalizedNames
     *
     * @return array<int, string>
     */
    private function getNewProductName(array $oldProductLocalizedNames): array
    {
        $newProductLocalizedNames = [];
        foreach ($oldProductLocalizedNames as $langId => $oldName) {
            $langId = (int) $langId;
            $namePattern = $this->translator->trans('copy of %s', [], 'Admin.Catalog.Feature', Language::getLocaleById($langId));
            $newName = sprintf($namePattern, $oldName);
            $newProductLocalizedNames[$langId] = $this->stringModifier->cutEnd($newName, ProductSettings::MAX_NAME_LENGTH);
        }

        return $newProductLocalizedNames;
    }

    /**
     * Provides duplicated product name
     *
     * @param array<int, string> $oldProductLocalizedLinkRewrites
     *
     * @return array<int, string>
     */
    private function getNewProductLinkRewrite(array $oldProductLocalizedLinkRewrites): array
    {
        $newProductLocalizedLinkRewrites = [];
        foreach ($oldProductLocalizedLinkRewrites as $langId => $oldName) {
            $langId = (int) $langId;
            $namePattern = $this->translator->trans('copy of %s', [], 'Admin.Catalog.Feature', Language::getLocaleById($langId));
            $newName = sprintf($namePattern, $oldName);
            $newProductLocalizedLinkRewrites[$langId] = $this->tools->linkRewrite($this->stringModifier->cutEnd($newName, ProductSettings::MAX_NAME_LENGTH));
        }

        return $newProductLocalizedLinkRewrites;
    }

    /**
     * Duplicates related product entities & associations
     *
     * @param int $oldProductId
     * @param int $newProductId
     * @param ShopConstraint $shopConstraint
     *
     * @throws CannotDuplicateProductException
     * @throws CoreException
     */
    private function duplicateRelations(int $oldProductId, int $newProductId, ShopConstraint $shopConstraint, string $productType): void
    {
        $shopIds = array_map(static function (ShopId $shopId) {
            return $shopId->getValue();
        }, $this->productRepository->getShopIdsByConstraint(new ProductId($oldProductId), $shopConstraint));

        $this->duplicateCategories($oldProductId, $newProductId);
        $combinationMatching = $this->duplicateCombinations($oldProductId, $newProductId, $shopIds);
        $this->duplicateSuppliers($oldProductId, $newProductId, $combinationMatching);
        $this->duplicateGroupReduction($oldProductId, $newProductId);
        $this->duplicateRelatedProducts($oldProductId, $newProductId);
        $this->duplicateFeatures($oldProductId, $newProductId);
        $this->duplicateSpecificPrices($oldProductId, $newProductId, $combinationMatching);
        $this->duplicatePackedProducts($oldProductId, $newProductId);
        $this->duplicateCustomizationFields($oldProductId, $newProductId);
        $this->duplicateTags($oldProductId, $newProductId);
        $this->duplicateVirtualProductFiles($oldProductId, $newProductId);
        $this->duplicateImages($oldProductId, $newProductId, $combinationMatching, $shopConstraint);
        $this->duplicateCarriers($oldProductId, $newProductId, $shopIds);
        $this->duplicateAttachmentAssociation($oldProductId, $newProductId);
        $this->duplicateStock($oldProductId, $newProductId, $shopIds, $productType, $combinationMatching);
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     * @param int[] $shopIds
     * @param string $productType
     * @param array $combinationMatching
     */
    private function duplicateStock(int $oldProductId, int $newProductId, array $shopIds, string $productType, array $combinationMatching): void
    {
        $targetProductId = new ProductId($newProductId);
        foreach ($shopIds as $shopId) {
            $targetShopId = new ShopId($shopId);
            try {
                $this->stockAvailableRepository->getForProduct($targetProductId, $targetShopId);
            } catch (StockAvailableNotFoundException) {
                // We create the new StockAvailable for this product and shop, it will then be updated via stock modification
                $this->stockAvailableRepository->createStockAvailable($targetProductId, $targetShopId);
            }

            try {
                $sourceStock = $this->stockAvailableRepository->getForProduct(new ProductId($oldProductId), $targetShopId);
                $outOfStock = new OutOfStockType((int) $sourceStock->out_of_stock);
                $productQuantity = (int) $sourceStock->quantity;
                $location = $sourceStock->location;
            } catch (StockAvailableNotFoundException) {
                // The source product may not have any associated StockAvailable (this happens with product created with old versions)
                $outOfStock = new OutOfStockType(OutOfStockType::OUT_OF_STOCK_DEFAULT);
                $productQuantity = 0;
                $location = '';
            }

            $stockModification = StockModification::buildFixedQuantity($productQuantity);
            $stockProperties = new ProductStockProperties(
                $stockModification,
                $outOfStock,
                $location
            );
            $this->productStockUpdater->update($targetProductId, $stockProperties, ShopConstraint::shop($targetShopId->getValue()));

            if ($productType === ProductType::TYPE_COMBINATIONS) {
                $this->duplicateCombinationsStock($oldProductId, $newProductId, $targetShopId, $combinationMatching);
            }
        }
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     * @param ShopId $targetShopId
     * @param array<int, int> $combinationMatching
     */
    private function duplicateCombinationsStock(int $oldProductId, int $newProductId, ShopId $targetShopId, array $combinationMatching): void
    {
        $targetProductId = new ProductId($newProductId);
        $sourceCombinations = $this->combinationRepository->getCombinationIds(
            new ProductId($oldProductId),
            ShopConstraint::shop($targetShopId->getValue())
        );
        $targetConstraint = ShopConstraint::shop($targetShopId->getValue());

        foreach ($sourceCombinations as $oldCombinationId) {
            $newCombinationId = new CombinationId($combinationMatching[$oldCombinationId->getValue()]);
            try {
                $this->stockAvailableRepository->getForCombination($newCombinationId, $targetShopId);
            } catch (StockAvailableNotFoundException) {
                $this->stockAvailableRepository->createStockAvailable($targetProductId, $targetShopId, $newCombinationId);
            }

            // Get the source stock
            try {
                $sourceStock = $this->stockAvailableRepository->getForCombination($oldCombinationId, $targetShopId);
                $combinationQuantity = (int) $sourceStock->quantity;
                $location = $sourceStock->location;
            } catch (StockAvailableNotFoundException) {
                // The source combination may not have any associated StockAvailable (this happens with combinations created with old versions)
                $combinationQuantity = 0;
                $location = '';
            }

            $stockModification = StockModification::buildFixedQuantity($combinationQuantity);
            $stockProperties = new CombinationStockProperties(
                $stockModification,
                $location
            );
            $this->combinationStockUpdater->update($newCombinationId, $stockProperties, $targetConstraint);
        }
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     */
    private function duplicateCategories(int $oldProductId, int $newProductId): void
    {
        $oldRows = $this->getRows('category_product', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_CATEGORIES);
        $newRows = [];
        $lastCategoriesPosition = [];
        foreach ($oldRows as $oldRow) {
            $categoryId = (int) $oldRow['id_category'];
            if (isset($lastCategoriesPosition[$categoryId])) {
                $lastCategoryPosition = $lastCategoriesPosition[$categoryId];
            } else {
                $lastCategoryPosition = (int) $this->connection->createQueryBuilder()
                    ->select('cp.position')
                    ->from($this->dbPrefix . 'category_product', 'cp')
                    ->where('cp.id_category = :categoryId')
                    ->setParameter('categoryId', $categoryId)
                    ->addOrderBy('position', 'DESC')
                    ->executeQuery()
                    ->fetchOne()
                ;
            }

            $newRows[] = [
                'id_product' => $newProductId,
                'id_category' => $categoryId,
                'position' => ++$lastCategoryPosition,
            ];
            $lastCategoriesPosition[$categoryId] = $lastCategoryPosition;
        }
        $this->bulkInsert('category_product', $newRows, CannotDuplicateProductException::FAILED_DUPLICATE_CATEGORIES);
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     *
     * @throws CannotDuplicateProductException
     * @throws CoreException
     */
    private function duplicateSuppliers(int $oldProductId, int $newProductId, array $combinationMatching): void
    {
        $oldSuppliers = $this->getRows('product_supplier', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_SUPPLIERS);

        foreach ($oldSuppliers as $oldSupplier) {
            $newProductSupplier = $this->productSupplierRepository->get(new ProductSupplierId((int) $oldSupplier['id_product_supplier']));
            $newProductSupplier->id_product = $newProductId;
            $newProductSupplier->id_product_attribute = $combinationMatching[(int) $oldSupplier['id_product_attribute']] ?? 0;
            $this->productSupplierRepository->add($newProductSupplier);
        }
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     * @param int[] $shopIds
     *
     * @return array<int, int> Combination matching (key is the old ID, value is the new one)
     *
     * @throws CannotDuplicateProductException
     * @throws CoreException
     */
    private function duplicateCombinations(int $oldProductId, int $newProductId, array $shopIds): array
    {
        $oldCombinationsShop = $this->getRows(
            'product_attribute_shop',
            [
                'id_product' => $oldProductId,
                'id_shop' => $shopIds,
            ],
            CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS,
            [
                'id_product_attribute' => 'ASC',
                'id_shop' => 'ASC',
            ]
        );

        // First create new combinations which are copies of the old ones
        $combinationMatching = [];
        $newShopAssociations = [];
        foreach ($oldCombinationsShop as $oldCombinationShop) {
            $oldCombinationId = (int) $oldCombinationShop['id_product_attribute'];

            if (!isset($combinationMatching[$oldCombinationId])) {
                // New combination to create, copy the old combination and associate to appropriate attributes, store the new ID for matching
                $oldCombinations = $this->getRows(
                    'product_attribute',
                    [
                        'id_product' => $oldProductId,
                        'id_product_attribute' => $oldCombinationId,
                    ],
                    CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS
                );
                $newCombination = array_merge(reset($oldCombinations), [
                    'id_product' => $newProductId,
                    'id_product_attribute' => null,
                ]);
                $newCombinationId = $this->insertRow('product_attribute', $newCombination, CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS);
                if (empty($newCombinationId)) {
                    throw new CannotDuplicateProductException('Could not duplicate combination', CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS);
                }
                $combinationMatching[$oldCombinationId] = $newCombinationId;

                // Associate attributes to combination
                $oldAttributes = $this->getRows(
                    'product_attribute_combination',
                    ['id_product_attribute' => $oldCombinationId],
                    CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS
                );
                $newAttributes = $this->replaceInRows($oldAttributes, ['id_product_attribute' => $newCombinationId]);
                $this->bulkInsert('product_attribute_combination', $newAttributes, CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS);
            }

            // Add new shop association
            $newCombinationId = $combinationMatching[$oldCombinationId];
            $newCombinationShop = array_merge($oldCombinationShop, [
                'id_product_attribute' => $newCombinationId,
                'id_product' => $newProductId,
            ]);
            $newShopAssociations[] = $newCombinationShop;
        }

        // Insert all shop associations
        $this->bulkInsert('product_attribute_shop', $newShopAssociations, CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS);

        // Finally copy all combination multi lang fields
        $oldCombinationsLang = $this->getRows(
            'product_attribute_lang',
            [
                'id_product_attribute' => array_keys($combinationMatching),
            ],
            CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS
        );
        $newCombinationsLang = [];
        foreach ($oldCombinationsLang as $oldLang) {
            $newCombinationsLang[] = array_merge($oldLang, [
                'id_product_attribute' => $combinationMatching[(int) $oldLang['id_product_attribute']],
            ]);
        }
        $this->bulkInsert('product_attribute_lang', $newCombinationsLang, CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS);

        return $combinationMatching;
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     *
     * @throws CannotDuplicateProductException
     * @throws CoreException
     */
    private function duplicateGroupReduction(int $oldProductId, int $newProductId): void
    {
        $this->duplicateProductTable('product_group_reduction_cache', $oldProductId, $newProductId, CannotDuplicateProductException::FAILED_DUPLICATE_GROUP_REDUCTION);
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     *
     * @throws CannotDuplicateProductException
     * @throws CoreException
     */
    private function duplicateRelatedProducts(int $oldProductId, int $newProductId): void
    {
        $oldRows = $this->getRows(
            'accessory',
            ['id_product_1' => $oldProductId],
            CannotDuplicateProductException::FAILED_DUPLICATE_RELATED_PRODUCTS
        );

        if (empty($oldRows)) {
            return;
        }
        $newRows = $this->replaceInRows($oldRows, ['id_product_1' => $newProductId]);
        $this->bulkInsert(
            'accessory',
            $newRows,
            CannotDuplicateProductException::FAILED_DUPLICATE_RELATED_PRODUCTS
        );
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     *
     * @throws CannotDuplicateProductException
     * @throws CoreException
     */
    private function duplicateFeatures(int $oldProductId, int $newProductId): void
    {
        $oldProductFeatures = $this->getRows('feature_product', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_FEATURES);

        // Custom values need to be copied and assigned to new products
        $featureValuesIds = array_map(static function (array $oldProductFeature) {
            return (int) $oldProductFeature['id_feature_value'];
        }, $oldProductFeatures);
        $customFeatureValues = $this->getRows('feature_value', ['id_feature_value' => $featureValuesIds, 'custom' => 1], CannotDuplicateProductException::FAILED_DUPLICATE_FEATURES);
        $customValuesMapping = [];
        if (!empty($customFeatureValues)) {
            $lastFeatureValueId = (int) $this->connection->createQueryBuilder()
                ->from($this->dbPrefix . 'feature_value')
                ->select('id_feature_value')
                ->addOrderBy('id_feature_value', 'DESC')
                ->executeQuery()
                ->fetchOne()
            ;

            $newCustomFeatureValues = [];
            $newCustomFeatureValuesLang = [];
            foreach ($customFeatureValues as $customFeatureValue) {
                $newCustomFeatureValueId = ++$lastFeatureValueId;
                $oldCustomFeatureValueId = (int) $customFeatureValue['id_feature_value'];
                $customValuesMapping[$oldCustomFeatureValueId] = $newCustomFeatureValueId;
                $newCustomFeatureValues[] = array_merge($customFeatureValue, [
                    'id_feature_value' => $newCustomFeatureValueId,
                ]);

                $langData = $this->getRows('feature_value_lang', ['id_feature_value' => $oldCustomFeatureValueId], CannotDuplicateProductException::FAILED_DUPLICATE_FEATURES);
                $langData = $this->replaceInRows($langData, ['id_feature_value' => $newCustomFeatureValueId]);
                $newCustomFeatureValuesLang = array_merge($newCustomFeatureValuesLang, $langData);
            }
            $this->bulkInsert('feature_value', $newCustomFeatureValues, CannotDuplicateProductException::FAILED_DUPLICATE_FEATURES);
            $this->bulkInsert('feature_value_lang', $newCustomFeatureValuesLang, CannotDuplicateProductException::FAILED_DUPLICATE_FEATURES);
        }

        // Now we can duplicate relations (and replace custom ones with newly copied feature values)
        $newProductFeatures = [];
        foreach ($oldProductFeatures as $oldProductFeature) {
            $oldCustomFeatureValueId = (int) $oldProductFeature['id_feature_value'];
            if (!isset($customValuesMapping[$oldCustomFeatureValueId])) {
                $newProductFeatures[] = [
                    'id_product' => $newProductId,
                    'id_feature' => $oldProductFeature['id_feature'],
                    'id_feature_value' => $oldProductFeature['id_feature_value'],
                ];
            } else {
                $newProductFeatures[] = [
                    'id_product' => $newProductId,
                    'id_feature' => $oldProductFeature['id_feature'],
                    'id_feature_value' => $customValuesMapping[$oldCustomFeatureValueId],
                ];
            }
        }
        $this->bulkInsert('feature_product', $newProductFeatures, CannotDuplicateProductException::FAILED_DUPLICATE_FEATURES);
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     * @param array $combinationMatching
     *
     * @throws CannotDuplicateProductException
     * @throws CoreException
     */
    private function duplicateSpecificPrices(int $oldProductId, int $newProductId, array $combinationMatching): void
    {
        $specificPriceIds = $this->specificPriceRepository->getProductSpecificPricesIds(new ProductId($oldProductId));
        foreach ($specificPriceIds as $specificPriceId) {
            $specificPrice = $this->specificPriceRepository->get($specificPriceId);
            $specificPrice->id_product = $newProductId;
            $specificPrice->id_product_attribute = $combinationMatching[(int) $specificPrice->id_product_attribute] ?? 0;
            $this->specificPriceRepository->add($specificPrice);
        }

        // Duplicate priorities
        $oldPriorities = $this->getRows('specific_price_priority', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_SPECIFIC_PRICES);
        $newPriorities = $this->replaceInRows($oldPriorities, ['id_product' => $newProductId, 'id_specific_price_priority' => null]);
        $this->bulkInsert('specific_price_priority', $newPriorities, CannotDuplicateProductException::FAILED_DUPLICATE_SPECIFIC_PRICES);
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     *
     * @throws CannotDuplicateProductException
     * @throws CoreException
     */
    private function duplicatePackedProducts(int $oldProductId, int $newProductId): void
    {
        $oldPackContent = $this->getRows('pack', ['id_product_pack' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_PACKED_PRODUCTS);
        $newPackContent = $this->replaceInRows($oldPackContent, ['id_product_pack' => $newProductId]);
        $this->bulkInsert('pack', $newPackContent, CannotDuplicateProductException::FAILED_DUPLICATE_PACKED_PRODUCTS);
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     *
     * @throws CannotDuplicateProductException
     */
    private function duplicateCustomizationFields(int $oldProductId, int $newProductId): void
    {
        $oldCustomizationFields = $this->getRows('customization_field', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_CUSTOMIZATION_FIELDS);
        $lastCustomizationFieldId = (int) $this->connection->createQueryBuilder()
            ->from($this->dbPrefix . 'customization_field')
            ->select('id_customization_field')
            ->addOrderBy('id_customization_field', 'DESC')
            ->executeQuery()
            ->fetchOne()
        ;

        $newCustomizationFields = [];
        $newCustomizationFieldsLang = [];
        foreach ($oldCustomizationFields as $oldCustomizationField) {
            $oldCustomizationFieldId = (int) $oldCustomizationField['id_customization_field'];
            $newCustomizationFieldId = ++$lastCustomizationFieldId;

            $newCustomizationFields[] = array_merge($oldCustomizationField, [
                'id_product' => $newProductId,
                'id_customization_field' => $newCustomizationFieldId,
            ]);

            $oldCustomizationFieldsLang = $this->getRows('customization_field_lang', ['id_customization_field' => $oldCustomizationFieldId], CannotDuplicateProductException::FAILED_DUPLICATE_CUSTOMIZATION_FIELDS);
            foreach ($oldCustomizationFieldsLang as $oldCustomizationFieldLang) {
                $newCustomizationFieldsLang[] = array_merge($oldCustomizationFieldLang, [
                    'id_customization_field' => $newCustomizationFieldId,
                ]);
            }
        }

        $this->bulkInsert('customization_field', $newCustomizationFields, CannotDuplicateProductException::FAILED_DUPLICATE_CUSTOMIZATION_FIELDS);
        $this->bulkInsert('customization_field_lang', $newCustomizationFieldsLang, CannotDuplicateProductException::FAILED_DUPLICATE_CUSTOMIZATION_FIELDS);
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     */
    private function duplicateTags(int $oldProductId, int $newProductId): void
    {
        $this->duplicateProductTable(
            'product_tag',
            $oldProductId,
            $newProductId,
            CannotDuplicateProductException::FAILED_DUPLICATE_TAGS
        );
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     *
     * @throws CannotDuplicateProductException
     * @throws CoreException
     */
    private function duplicateVirtualProductFiles(int $oldProductId, int $newProductId): void
    {
        $oldVirtualProductFiles = $this->getRows('product_download', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_DOWNLOADS);

        $newVirtualProductFiles = [];
        foreach ($oldVirtualProductFiles as $oldVirtualProductFile) {
            $newFilename = VirtualProductFile::getNewFilename();
            copy(_PS_DOWNLOAD_DIR_ . $oldVirtualProductFile['filename'], _PS_DOWNLOAD_DIR_ . $newFilename);
            $newVirtualProductFiles[] = array_merge($oldVirtualProductFile, [
                'id_product_download' => null,
                'id_product' => $newProductId,
                'filename' => $newFilename,
                'date_add' => date('Y-m-d H:i:s'),
            ]);
        }
        $this->bulkInsert('product_download', $newVirtualProductFiles, CannotDuplicateProductException::FAILED_DUPLICATE_DOWNLOADS);
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     * @param array $combinationMatching
     * @param ShopConstraint $shopConstraint
     *
     * @throws CannotDuplicateProductException
     * @throws CoreException
     */
    private function duplicateImages(int $oldProductId, int $newProductId, array $combinationMatching, ShopConstraint $shopConstraint): void
    {
        $oldImages = $this->getRows('image', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_IMAGES);

        $imagesMapping = [];
        $fs = new Filesystem();
        foreach ($oldImages as $oldImage) {
            $oldImageId = new ImageId((int) $oldImage['id_image']);
            $newImage = $this->productImageRepository->duplicate($oldImageId, new ProductId($newProductId), $shopConstraint);
            if (null === $newImage) {
                continue;
            }

            $newImageId = new ImageId((int) $newImage->id);
            $imageTypes = $this->productImageRepository->getProductImageTypes();

            // Copy the generated images instead of generating them is more performant
            foreach ($imageTypes as $imageType) {
                $fs->copy(
                    $this->productImageSystemPathFactory->getPathByType($oldImageId, $imageType->name),
                    $this->productImageSystemPathFactory->getPathByType($newImageId, $imageType->name)
                );
            }

            // Also copy original
            $oldOriginalPath = $this->productImageSystemPathFactory->getPath($oldImageId);
            $newOriginalPath = $this->productImageSystemPathFactory->getPath($newImageId);
            $fs->copy(
                $oldOriginalPath,
                $newOriginalPath
            );

            $imagesMapping[$oldImageId->getValue()] = $newImageId->getValue();
        }

        $oldCombinationImages = $this->getRows('product_attribute_image', ['id_image' => array_keys($imagesMapping)], CannotDuplicateProductException::FAILED_DUPLICATE_IMAGES);
        $newCombinationImages = [];
        foreach ($oldCombinationImages as $oldCombinationImage) {
            $newCombinationImages[] = [
                'id_image' => $imagesMapping[(int) $oldCombinationImage['id_image']],
                'id_product_attribute' => $combinationMatching[(int) $oldCombinationImage['id_product_attribute']],
            ];
        }
        $this->bulkInsert('product_attribute_image', $newCombinationImages, CannotDuplicateProductException::FAILED_DUPLICATE_IMAGES);
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     * @param int[] $shopIds
     *
     * @throws CannotDuplicateProductException
     * @throws CoreException
     */
    private function duplicateCarriers(int $oldProductId, int $newProductId, array $shopIds): void
    {
        $this->duplicateProductTableForShops(
            'product_carrier',
            $oldProductId,
            $newProductId,
            $shopIds,
            CannotDuplicateProductException::FAILED_DUPLICATE_CARRIERS
        );
    }

    /**
     * @param int $oldProductId
     * @param int $newProductId
     *
     * @throws CannotDuplicateProductException
     * @throws CoreException
     */
    private function duplicateAttachmentAssociation(int $oldProductId, int $newProductId): void
    {
        $this->duplicateProductTable('product_attachment', $oldProductId, $newProductId, CannotDuplicateProductException::FAILED_DUPLICATE_ATTACHMENT_ASSOCIATION);
    }

    /**
     * @param int $newProductId
     * @param int $oldProductId
     *
     * @throws CannotUpdateProductException
     * @throws CoreException
     */
    private function updateDefaultAttribute(int $newProductId, int $oldProductId): void
    {
        try {
            if (!Product::updateDefaultAttribute($newProductId)) {
                throw new CannotUpdateProductException(
                    sprintf('Failed to update default attribute when duplicating product %d', $oldProductId),
                    CannotUpdateProductException::FAILED_UPDATE_DEFAULT_ATTRIBUTE
                );
            }
        } catch (PrestaShopException $e) {
            throw new CoreException(
                sprintf('Error occurred when trying to duplicate product #%d. Failed to update default attribute', $oldProductId),
                0,
                $e
            );
        }
    }

    /**
     * Fetch all rows related to a product on a specific table and duplicate it by replacing only the column id_product.
     *
     * @param string $table
     * @param int $oldProductId
     * @param int $newProductId
     * @param int $errorCode
     *
     * @throws InvalidArgumentException
     * @throws CannotDuplicateProductException
     */
    private function duplicateProductTable(string $table, int $oldProductId, int $newProductId, int $errorCode): void
    {
        $oldRows = $this->getRows($table, ['id_product' => $oldProductId], $errorCode);
        if (empty($oldRows)) {
            return;
        }
        $newRows = $this->replaceInRows($oldRows, ['id_product' => $newProductId]);
        $this->bulkInsert($table, $newRows, $errorCode);
    }

    /**
     * Fetch all rows related to a product on a specific table for a set of shop IDs and duplicate it by replacing only the column id_product.
     *
     * @param string $table
     * @param int $oldProductId
     * @param int $newProductId
     * @param int[] $shopIds
     * @param int $errorCode
     *
     * @throws InvalidArgumentException
     * @throws CannotDuplicateProductException
     */
    private function duplicateProductTableForShops(string $table, int $oldProductId, int $newProductId, array $shopIds, int $errorCode): void
    {
        $oldRows = $this->getRows($table, [
            'id_product' => $oldProductId,
            'id_shop' => $shopIds,
        ], $errorCode);
        if (empty($oldRows)) {
            return;
        }
        $newRows = $this->replaceInRows($oldRows, ['id_product' => $newProductId]);
        $this->bulkInsert($table, $newRows, $errorCode);
    }

    /**
     * Bulk insert one row, the values is an associative array defining each column in the row.
     *
     * @param string $table
     * @param array $rowValues
     * @param int $errorCode
     *
     * @return int
     */
    private function insertRow(string $table, array $rowValues, int $errorCode): int
    {
        $this->bulkInsert($table, [$rowValues], $errorCode);

        return (int) $this->connection->lastInsertId();
    }

    /**
     * Bulk insert some row values, all row must be formatted with the exact same keys and in the same order
     * so that the defined column match the values for each row.
     *
     * @param string $table
     * @param array $multipleRowValues
     * @param int $errorCode
     */
    private function bulkInsert(string $table, array $multipleRowValues, int $errorCode): void
    {
        if (empty($multipleRowValues)) {
            return;
        }

        $insertKeys = array_keys(reset($multipleRowValues));
        $bulkInsertSql = 'INSERT INTO ' . $this->dbPrefix . $table . ' (' . implode(',', $insertKeys) . ') VALUES ';
        foreach ($multipleRowValues as $i => $rowValue) {
            if (array_keys($rowValue) !== $insertKeys) {
                throw new InvalidArgumentException('The provided data has different keys in some rows');
            }

            $bulkInsertSql .= '(' . implode(',', array_map(static function ($columnValue): string {
                if ($columnValue === null) {
                    return 'null';
                } elseif (!empty($columnValue) && DateTime::isNull($columnValue)) {
                    // We can't use 0000-00-00 as a value it's not valid in Mysql, so we use null instead
                    return 'null';
                }

                if (is_string($columnValue)) {
                    $columnValue = str_replace("'", "''", $columnValue);
                }

                // We stringify values to avoid SQL syntax error, the float and integers will correctly casted in the DB anyway
                // however string values and date time need to be quoted
                return "'$columnValue'";
            }, $rowValue)) . ')';
            if ($i < count($multipleRowValues) - 1) {
                $bulkInsertSql .= ',';
            } else {
                $bulkInsertSql .= ';';
            }
        }

        try {
            $this->connection->executeStatement($bulkInsertSql);
        } catch (Exception) {
            throw new CannotDuplicateProductException(
                sprintf('Cannot bulk insert into table %s failed', $table),
                $errorCode
            );
        }
    }

    /**
     * Replace columns values in every row.
     *
     * @param array $rows
     * @param array $replacements
     *
     * @return array
     */
    private function replaceInRows(array $rows, array $replacements): array
    {
        $replacedRows = [];
        foreach ($rows as $key => $row) {
            $replacedRows[$key] = array_merge($row, $replacements);
        }

        return $replacedRows;
    }

    /**
     * Returns all the columns of a specific table, you can add criteria to filter, prefix is automatically added.
     *
     * @param string $table
     * @param array $criteria
     * @param int $errorCode
     * @param array<string, string|array<string|int>> $orderBy
     *
     * @return array
     */
    private function getRows(string $table, array $criteria, int $errorCode, array $orderBy = []): array
    {
        $qb = $this->connection
            ->createQueryBuilder()
            ->from($this->dbPrefix . $table)
            ->select('*')
        ;

        foreach ($criteria as $column => $value) {
            if (is_array($value)) {
                $arrayType = is_int(reset($value)) ? Connection::PARAM_INT_ARRAY : Connection::PARAM_STR_ARRAY;
                $qb
                    ->andWhere("$column IN (:$column)")
                    ->setParameter($column, $value, $arrayType)
                ;
            } else {
                $qb
                    ->andWhere("$column = :$column")
                    ->setParameter($column, $value)
                ;
            }
        }

        foreach ($orderBy as $orderKey => $orderWay) {
            $qb->addOrderBy($orderKey, $orderWay);
        }

        try {
            $rows = $qb->executeQuery()->fetchAllAssociative();
        } catch (Exception) {
            throw new CannotDuplicateProductException(
                sprintf('Cannot select rows from table %s', $this->dbPrefix . $table),
                $errorCode
            );
        }

        return $rows;
    }
}
