<?php
namespace PrestaShop\Module\PolarisPrestaConnector;

use Combination;
use Exception;
use PolarisPrestaConnector;
use StockAvailable;

/**
 * Synchronisation de produits
 */
class SyncerProducts
{
    /**
     * Indicateur de progression
     */
    private SyncProgress $progress;

    /**
     * Initialisé ?
     */
    private bool $inited = false;

    /**
     * Synchroniseur
     * 
     */
    private Syncer $syncer;

    /**
     * Cache des emplacements de stock
     * 
     */
    private ?array $warehouseLocationsCache = null;     

    /**
     * Gestionnaire de références fournisseurs
     */
    private ?SupplierRefManager $supplierRefMngr = null;

    /**
     * Gestionnaire de caractéristiques
     */
    private ?FeaturesManager $productFeaturesMngr = null;

    /**
     * Gestionnaire d'attributs de produits
     */
    private ?AttributesManager $productAttributesMngr = null;

    /**
     * Données produit à synchronier
     */
    private ?array $dataSyncCfg;

    /**
     * Gestionnaire de photos
     */
    private ?ProductPhotosManager $photosMngr = null;

    /**
     * Gestionnaire de marques
     */
    private ?ManufacturersManager $manufacturersMngr = null;

    /**
     * Gestionnaire de catégories
     */
    private ?CategoriesManager $categoriesMngr = null;

    /**
     * Règle de taxes 
     */
    private $taxRules;

    /**
     * Mappage des attributs de produits
     */
    private ?array $productMappingCfg;

    /**
     * Mappage des grilles de tailles
     */
    private ?array $sizeGridsMapping;

    /**
     * Mappage des caractéristiques
     */
    private ?array $featureMapping;

    /**
     * Mappage des références de produits
     */
    private ?array $productRefMapping;

    /**
     * Mappage des déclinaisons
     */
    private ?array $productAttrMapping;

    /**
     * Mappage des couleurs
     */
    private ?array $productColorMapping;

    /**
     * Constructeur
     * 
     * @param Syncer $syncer Synchroniseur
     * @param SyncProgress|null $progress Indicateur de progression
     */
    public function __construct(Syncer $syncer, SyncProgress|null $progress = null)
    {
        $this->syncer = $syncer;
        $this->progress = $progress ?? new SyncProgress($syncer);
    }

    /**
     * Obtient le cache des emplacements de stock
     * 
     */
    private function & getWarehouseLocationsCache() : array
    {
        if (!$this->warehouseLocationsCache)
        {
            $this->warehouseLocationsCache = [];
            $sql = 'SELECT `id_product`, `id_product_attribute`, `location` FROM `' . _DB_PREFIX_ . 'stock_available`';
            $res = \Db::getInstance()->executeS($sql);
            if ($res === FALSE)
                throw new Exception('Erreur lors de la récupération des emplacements de stock : '.\Db::getInstance()->getMsgError());

            foreach ($res as $row)
                $this->warehouseLocationsCache[$row['id_product'] . '-' . $row['id_product_attribute']] = $row['location'];
        }

        return $this->warehouseLocationsCache;
    }
    
    /**
     * Initialisation
     * 
     */
    private function init()
    {
        $cfg = $this->syncer->module->getCfg();        

        // Cache des emplacements de stock
        $this->warehouseLocationsCache = null;

        // Pré-chargement de la configuration si ça n'a pas déjà été fait
        if (!$cfg->hasBeenPreloaded())
            $cfg->preload();

        // Données à synchroniser
        $this->dataSyncCfg = $cfg->get(SyncConfiguration::CFG_DATA_SYNC);        

        // Obtenir le pays dans lequel est basé le site
        $countryId = \Configuration::get('PS_COUNTRY_DEFAULT');

        // TaxRules pour l'auto-détection automatique !
        // Création d'un associative array avec en clé le taux de la taxe et en valeur l'id de la règle de taxe
        $this->taxRules = [];
        foreach (\TaxRulesGroup::getAssociatedTaxRatesByIdCountry($countryId) as $taxGroup => $rate)
        {                    
            if (!isset($this->taxRules[$rate]))
                $this->taxRules[$rate] = $taxGroup;
        }

        // On se construit un tableau de configuration rapide pour savoir où prélever les références de produits
        $this->productMappingCfg = $cfg->get(SyncConfiguration::CFG_PR_ATTRIBUTES_MAPPING);                

        // On s'assure que le mapping est TOUJOURS dans le même ordre
        if (isset($this->productMappingCfg))
        {            
            foreach (array_keys($this->productMappingCfg) as $attr)
            if (!empty($attr))
                ksort($this->productMappingCfg[$attr]);
        }
        else
            $this->productMappingCfg = [];               

        /*
            Mappage des références de produits
        */
        $this->productRefMapping = [];
        if (isset($this->productMappingCfg[SyncConfiguration::PRODUCT_ATTR_PRODUCT]))
            foreach ($this->productMappingCfg[SyncConfiguration::PRODUCT_ATTR_PRODUCT] as $attr => $a)
                if ($a)
                    $this->productRefMapping[] = $attr;

        /**
         * Mappage des déclinaisons
         */
        $existingsAttrGroup = AttributesManager::getExistingAttributeGroups();

        $this->productAttrMapping = [];
        if (isset($this->productMappingCfg[SyncConfiguration::PRODUCT_ATTR_VARIANT]))
            foreach ($this->productMappingCfg[SyncConfiguration::PRODUCT_ATTR_VARIANT] as $attr => $id_attribute_group)
                if ($id_attribute_group && isset($existingsAttrGroup[$id_attribute_group]))  // Le groupe existe toujours, on l'utilise
                    $this->productAttrMapping[$attr] = $id_attribute_group;

        // On ajoute le mapping couleur
        $this->productColorMapping = [];
        foreach ($cfg->get(SyncConfiguration::CFG_MAP_COLOR_ATTRS) ?? [] as $attr => $id_attribute_group)
            if ($id_attribute_group && isset($existingsAttrGroup[$id_attribute_group]))  // Le groupe existe toujours, on l'utilise
                $this->productColorMapping[$attr] = $id_attribute_group;

        /**
         * Mappage des tailles
         * Astuce : les grilles de tailles sont toujours un groupe d'attributs !
         */
        $this->sizeGridsMapping = [];
        if (!is_null($cfg->get(SyncConfiguration::CFG_MAP_SIZEGRIDS)))
            foreach ($cfg->get(SyncConfiguration::CFG_MAP_SIZEGRIDS) as $size => $id_attribute_group)
                if ($id_attribute_group && isset($existingsAttrGroup[$id_attribute_group]))  // Le groupe existe toujours, on l'utilise
                    $this->sizeGridsMapping[$size] = $id_attribute_group;

        /**
         * Mappage des caractéristiques
         * 
         * @var array<string, int>
         */
        // Chargement des id caractéristiques existantes sur le système
        $existingsFeatures = FeaturesManager::getExistingFeatures();

        $this->featureMapping = [];
        if (isset($this->productMappingCfg[SyncConfiguration::PRODUCT_ATTR_FEATURE]))
            foreach ($this->productMappingCfg[SyncConfiguration::PRODUCT_ATTR_FEATURE] as $attr => $id_feature)
                if ($id_feature && isset($existingsFeatures[$id_feature]))  // La feature existe toujours, on l'utilise
                    $this->featureMapping[$attr] = $id_feature;                            
        
        $this->supplierRefMngr = $this->syncer->getReferencesManager();
        $this->productFeaturesMngr = new FeaturesManager();
        $this->productAttributesMngr = new AttributesManager($this->syncer);
        $this->photosMngr = new ProductPhotosManager($this->syncer);
        $this->manufacturersMngr = new ManufacturersManager($this->syncer);
        $this->categoriesMngr = new CategoriesManager($this->syncer);
    }

    /**
     * Synchronisation des produits
     */
    public function sync()
    {
        $startTime = microtime(true);
        $nbProducts = 0;

        $this->progress->startStep('import_products', 'Importation des produits', '%d produits importés');        
        // On récupère les produits depuis le pont, en boucle
        while ($products = $this->syncer->bridge->pullProducts())
        {
            $this->syncer->checkMaxExecTime();
            $nbProducts += $this->syncProducts($products);
        }
        $this->progress->endStep('import_products');

        // Mappage des catégories en attente
        $this->mapPendingProductCategories();

        // Nettoyage produit
        $this->syncCleanProducts();

        $stopTime = microtime(true);
        if ($nbProducts > 0)
            $this->syncer->audit(sprintf("[INF] Synchronisation de %d produits effectuée en %.2f secondes", $nbProducts, $stopTime - $startTime));    
    }

    /**
     * Synchronisation des produits
     */
    public function syncCleanProducts()
    {
        // Nettoyage des références obsolètes !
        $this->progress->startStep('clean_products', 'Nettoyage des produits', '%d produits nettoyés');            

        while ($refs = $this->syncer->bridge->pullCleanedProducts())        
        {            
            $this->syncer->checkMaxExecTime();
            if (!$this->supplierRefMngr)
                $this->supplierRefMngr = $this->syncer->getReferencesManager();            

            // Pour chaque référence, on trouve la combinaison et on la supprime.
            // On se note l'id_produit car à la fin, on refait une passe pour désactiver automatiquement tous les produits
            // actifs qui n'ont plus de déclinaison aucune, ni aucun stock.
            $cleaned_products = [];
            foreach ($refs as $article_ref)
            {
                $ids = $this->supplierRefMngr->translateCombinationRefs($article_ref);
                if ($ids)
                {
                    $id_product = (int) $ids[0][0];
                    $id_product_attribute = (int) $ids[0][1];

                    // On supprime la combinaison de produit $id[1];
                    $c = new \Combination($id_product_attribute);
                    if ($c->id)
                    {
                        $c->delete();
                        $this->supplierRefMngr->cleanCombinationRef($article_ref, $c->id);
                        $cleaned_products[$id_product] = $id_product;

                        // Et on le signale dans l'audit
                        $this->syncer->audit(sprintf("[INF] Produit #%s : déclinaison `%s` supprimée", $id_product, $article_ref));
                    }                    
                }                
            }

            if ($cleaned_products && count($cleaned_products))
            {
                $db = \Db::getInstance();
                // On va interroger tous les produits concernés pour avoir la liste de ceux qui n'ont plus de déclinaison
                $results = $db->executeS(sprintf('SELECT `id_product`, (SELECT MIN(pa.id_product_attribute) FROM `' . _DB_PREFIX_ . 'product_attribute` pa WHERE pa.id_product = p.id_product) AS id_product_attribute FROM `' . _DB_PREFIX_ . 'product` AS p WHERE p.`id_product` IN (%s)', implode(',', $cleaned_products)));
                if ($results === false)
                    throw new Exception('Erreur lors de la recherche des produits sans déclinaison : '.\Db::getInstance()->getMsgError());
                
                // On désactive les produits qui n'ont plus de déclinaison
                // ni de stock
                foreach ($results as $row)
                {
                    $id_product = $row['id_product'];
                    $id_product_attribute = $row['id_product_attribute'];

                    $product = new \Product($id_product);
                    if ($product->id == $id_product)
                    {
                        // On vérifie qu'il n'y a plus de déclinaison au produit                    
                        if (!$id_product_attribute)
                        {
                            // On vérifie qu'il n'y a plus de stock
                            if ($product->active && StockAvailable::getQuantityAvailableByProduct($id_product) == 0)
                            {
                                // On désactive le produit
                                $product->active = false;
                                $product->save();
                                $this->syncer->audit(sprintf("[INF] Produit #%s : désactivé", $id_product));
                            }
                        }
                        else
                        {
                            // Autrement on vérifie qu'il existe une déclinaison par défaut et que celle-ci existe réellement
                            // Sinon on attribue au produit la première déclinaison trouvée
                            $id_product_attribute = \Product::getDefaultAttribute($id_product);
                            // On vérifie que $defaultCombination est bien la déclinaison par défaut
                            $cmb = new Combination($id_product_attribute);
                            if (!$cmb->id || !$cmb->default_on)
                            {
                                $product->setWsDefaultCombination($id_product_attribute);
                                //$product->setDefaultAttribute($id_product, $id_product_attribute);
                                $product->save();
                                $this->syncer->audit(sprintf("[INF] Produit #%s : déclinaison par défaut mise à jour", $id_product));                            
                            }
                        }
                    }
                }
            }
        }        

        $this->progress->endStep('clean_products');        
    }

    /**
     * Synchronisation des produits précisés
     * 
     * @param array $products Produits à synchroniser
     * @param bool $castErrors Doit-on lever des exceptions en cas d'erreur sur un produit ?
     * @return int Nombre de produits synchronisés     
     */
    public function syncProducts(array & $products, bool $castErrors = false) : int
    {
        $bridge = $this->syncer->bridge;
        $cfg = $this->syncer->module->getCfg();

        $nbProducts = 0;
        $supplierId = $cfg->get(SyncConfiguration::CFG_SUPPLIER);
            
        if (!$this->inited)
            $this->init();
        
        // Information sur les stocks en ligne
        $import_with_stock_only = $cfg->get(SyncConfiguration::CFG_PRODUCT_IMPORT_ONLY_WITH_STOCK);
        
        // Liste des codes magasins qui participent aux stocks des ventes en ligne
        $online_stocks_stores = $cfg->get(SyncConfiguration::CFG_ONLINE_STOCK_STORES);
        if (!is_array($online_stocks_stores))
            $online_stocks_stores = [];

        // Si aucun magasin n'est sélectionné, on prend tous les magasins
        $all_stores = count($online_stocks_stores) == 0;

        // Nous allons devoir aspirer les stocks pour les nouveaux produits dont on va avoir besoin d'un synchronisateur de stock
        $syncerStocks = new SyncerStocks($this->syncer);

        // Et pour chaque produit, on fait la synchro
        foreach ($products as $product)
        {
            $nbProducts++;
            $this->progress->progress('import_products');

            try
            {
                if (!$product->id)
                {
                    // Aucune référence exploitable, on passe au suivant
                    $this->syncer->audit(sprintf('[WRN] Produit `%s - %s` ignoré : pas référence `id`.', 
                                        $product->code, $product->name));
                    continue;
                }

                // Marques ignorées
                // La marque
                $id_manufacturer = 0;
                if ($product->manufacturer && $product->manufacturer->id)
                {
                    $id_manufacturer = $this->manufacturersMngr->translateCode($product->manufacturer->id);

                    // Finalement, on regarde où ça nous conduit
                    if ($id_manufacturer < 0)
                    {
                        $this->syncer->audit(sprintf('[INF] Produit `%s - %s` ignoré : marque `%s - %s` filtrée.',
                            $product->code, $product->name, $product->manufacturer->id, $product->manufacturer->name));
                        continue;
                    }
                }

                // Mappage de la grille de tailles
                $id_grille_tailles = 0;

                // et retombé sur le catchall (*) s'il existe
                if (array_key_exists($product->grilleTailles ?? "", $this->sizeGridsMapping))
                    $id_grille_tailles = $this->sizeGridsMapping[$product->grilleTailles ?? ""];

                if (!$id_grille_tailles && array_key_exists("*", $this->sizeGridsMapping))    // catch-all ?
                    $id_grille_tailles = $this->sizeGridsMapping["*"];

                // Si le produit n'a pas de grille de tailles, alors on le saute car il sera impossible de le classifier
                if ($id_grille_tailles < 0)
                {
                    $this->syncer->audit(sprintf('[INF] Produit `%s - %s` ignoré : les produits de la grille de taille `%s` sont filtrés !', 
                                        $product->code, $product->name, $product->grilleTailles));
                    continue;
                } else if ($id_grille_tailles == 0)
                {
                    $this->syncer->audit(sprintf('[WRN] Produit `%s - %s` ignoré : la grille de taille `%s` n\'est pas mappée !', 
                                        $product->code, $product->name, $product->grilleTailles));
                    continue;
                }

                /* Catégories 
                    Ici on vérifie qu'on ne doit pas ignorer les produits selon les mappages -1
                    mais on ne fait pas encore la translation.
                */
                $cats = [];
                foreach ($this->categoriesMngr->dynCats as $cat => $name)
                {
                    $pa = (isset($product->productAttributes[$cat]) && !empty($product->productAttributes[$cat])) ?
                                $product->productAttributes[$cat] : null;

                    // Fix #143 : si le produit ne définit pas la catégorie, on prend directement le catchall
                    $translated_cat = $this->categoriesMngr->translateCode($cat, $pa ? $pa->id : '*');
                    if (count($translated_cat) > 0)
                    {
                        if (array_search(-1, $translated_cat) !== FALSE)
                        {
                            $this->syncer->audit(sprintf('[INF] Produit `%s - %s` ignoré : catégorie `%s - %s` filtrée.', 
                                        $product->code, $product->name, $name, 
                                        $pa ? $pa->name : '(non définie)'));
                            continue 2; // On sort du foreach, mais on continue pas ce produit, on sort de la boucle externe également !
                        }                            
                    }

                    if (isset($product->productAttributes[$cat]))
                        $cats[$cat] = $product->productAttributes[$cat]->id;                    
                }                

                /**
                 * On déduit un certain nombre de données
                 * depuis les articles...
                 */
                // Dont le code EAN13
                $ean13 = null;
                // et le prix minimum
                $minPriceHT = null;
                // Zonage [Zone => cnt]
                $warehouseLocation = [];

                if ($product->articles)
                {                
                    // On parcours les articles pour trouver des infos à aggreger
                    foreach ($product->articles as $article)
                    {
                        // Le code EAN, on n'en prend pas => seules les déclinaisons ont un EAN !!
                        // if (!$ean13 && $article->ean13)
                        //    $ean13 = $article->ean13;

                        $priceHT = $article->getPriceHT($product);
                        if ($priceHT !== NULL && ($minPriceHT === NULL || $minPriceHT > $priceHT))
                            $minPrice = $priceHT;
                        if ($article->warehouseLocation)
                            $warehouseLocation[$article->warehouseLocation] = ($warehouseLocation[$article->warehouseLocation] ?? 0) + 1;
                    }

                    if ($minPriceHT === NULL)
                        $minPriceHT = 0;
                }

                // Si le produit n'a pas de prix, alors on le saute car il sera impossible de le vendre
                // et on le signale dans l'audit
                if ($minPrice <= 0)
                {
                    $this->syncer->audit(sprintf('[INF] Produit `%s - %s` ignoré : pas de prix !', 
                                        $product->code, $product->name));
                    continue;
                }
                
                // Controle des photos des produits ?
                $controlProductPhotos = false;

                // Calcul de la référence interne du produit
                $productRef = $product->id;
                // On concatène chaque attribut de produit qui est discriminant
                foreach ($this->productRefMapping as $attr)
                {
                    if (isset($product->productAttributes[$attr]))
                        $productRef .= '|' . $product->productAttributes[$attr]->id;
                }
                $productRef = mb_strtoupper($productRef);
                // Si productRef > 64 caractères, on le hash en SHA256
                if (mb_strlen($productRef) > 64)
                    $productRef = $product->id . '/' . base64_encode(hash('sha256', $productRef, true)); // base64 (44 caractères)                    
                
                // On charge ou on crée le produit au besoin
                $ref = $this->supplierRefMngr->translateProductRef($productRef);
                $psProduct = null;
                if ($ref)
                {
                    $psProduct = new \Product($ref, false);
                    if ($psProduct->id != $ref)
                    {
                        // Visiblement cette référence n'est plus bonne, on la purge de la mémoire et du disque
                        $this->supplierRefMngr->cleanProductRef($productRef);
                        $psProduct = null;
                    }
                    else
                    {
                        // On vérifie que ce produit n'est pas verrouillée
                        if ($cfg->isProductLocked($psProduct->id))
                        {
                            $this->syncer->audit(sprintf('[INF] Produit `%s - %s` ignoré : verrouillé.', 
                                            $product->code, $product->name));
                            continue;
                        }
                    }
                }

                $isCreation = false;
                if ($psProduct == null)
                {
                    $isCreation = true;
                    // Toujours aucune référence pour ce produit, le produit n'existe pas...
                    if ($import_with_stock_only && $bridge->SupportStockOnProducts)
                    {
                        $total_stocks = 0;

                        if (isset($product->stocks))
                        {
                            foreach ($product->stocks as $stock_info)
                            {
                                if (!$stock_info->isWildcard())
                                {                                    
                                    if ($all_stores)
                                    {
                                        foreach ($stock_info->qties as $qty)
                                            $total_stocks += $qty;
                                    }
                                    else
                                    {
                                        foreach ($online_stocks_stores as $code => $doIt)
                                            if ($doIt && isset($stock_info->qties[$code]) && $stock_info->qties[$code] > 0)
                                                $total_stocks += $stock_info->qties[$code];
                                    }
                                }
                            }                            
                        }                        

                        if ($total_stocks < 1)
                        {
                            $this->syncer->audit(sprintf('[INF] Produit `%s - %s` ignoré : pas de stock !',
                                $product->code, $product->name));
                            continue;
                        }
                    }

                    // toujours là ?
                    // Bon, création d'un nouveau produit ou réutilisation d'un ancien ?
                    // On va chercher un produit qui a au moins une référence en commun avec ce produit
                    // si on en trouve un, on le réutilise                    
                    foreach ($product->articles as $article)
                    {
                        $oldRefs = $this->supplierRefMngr->translateProductAttributeRefs($article->ref);
                        if ($oldRefs)
                        {
                            foreach ($oldRefs as $old_id_product)
                            {
                                // Trouvé ?                                
                                // On indique dans l'audit qu'on a trouvé une ancienne référence qui pointe vers un autre produit, 
                                // et qu'on va rediriger cet ancien produit vers le nouveau                                                                
                                $oldProduct = new \Product($old_id_product);
                                if ($oldProduct->id == $old_id_product)
                                {
                                    $this->syncer->audit(sprintf("[INF] Réutilisation du produit #%s : pour le produit %s", $old_id_product, $product->code));

                                    // On supprime toute les déclinaisons de l'ancien produit
                                    $oldProduct->deleteProductAttributes();

                                    // Et tout le stock
                                    StockAvailable::removeProductFromStockAvailable($old_id_product);

                                    // Recalcul du stock pour l'ancien produit ?
                                    StockAvailable::synchronize($old_id_product);                                    

                                    // Et on purge les références
                                    $this->supplierRefMngr->cleanAllCombinationRefs($old_id_product, true);

                                    $psProduct = $oldProduct;   // Zoouh, on réutilise !
                                    break 2; // Finito : on sort des deux foreach
                                }
                            }
                        }
                    }

                    if (!$psProduct)    // Toujours rien trouvé ? On crée un nouveau produit !
                    {
                        $psProduct = new \Product();
                        $psProduct->active = false;
                    }

                    // Option de visibilité par défaut 
                    $defaultVisibility = $cfg->get(SyncConfiguration::CFG_NO_STOCK_PRODUCT_VISIBILITY);
                    if ($defaultVisibility)
                        $psProduct->visibility = $defaultVisibility;
                } 
            
                $productAudit = new AuditHelper($psProduct); // Démarrage de l'audit sur l'entité produit !

                // On met à jour les champs basiques obligatoire

                // Pour la référence du produit concatène chaque attribut de produit qui est discriminant
                /* NOPE : la référence doit rester courte et simple, on ne concatène pas les attributs
                $addonRef = '';
                foreach ($this->productRefMapping as $attr)
                {
                    if (isset($product->productAttributes[$attr]))
                    {
                        $n = strtolower($product->productAttributes[$attr]->name);
                        if (!empty($n) &&
                            $n !== '-' &&
                            $n !== 'aucun' &&
                            $n !== 'aucune')
                        $addonRef .= ' ' . $product->productAttributes[$attr]->name;
                    }
                }*/

                if ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_REF])
                    $psProduct->reference = mb_strtoupper($product->code);

                $psProduct->id_supplier = $supplierId;            

                // On sauvegarde le produit (s'il y a des modifications)
                $productAudit->save($this->syncer);

                // Immédiatement, on sauvegarde les références fournisseurs de ce produit            
                if ($isCreation)
                {
                    // On enregistre la référence dans le cache
                    $this->supplierRefMngr->registerProductRef($productRef, $psProduct->id);
                }

                $paCreated = 0; // Création de nouvelles déclinaisons ?

                // Nettoyage anciennes références
                // On cherche toutes les références des articles, quand on les trouve dans
                // d'autres produits, on les supprime et on redirige l'ancien produit vers ce dernier
                foreach ($product->articles as $article)
                {
                    $oldRefs = $this->supplierRefMngr->translateProductAttributeRefs($article->ref);
                    if ($oldRefs)
                    {
                        foreach ($oldRefs as $old_id_product)
                        {
                            if ($old_id_product !== $psProduct->id)
                            {
                                // On indique dans l'audit qu'on a trouvé une ancienne référence qui pointe vers un autre produit, 
                                // et qu'on va rediriger cet ancien produit vers le nouveau
                                $this->syncer->audit(sprintf("[WRN] Produit #%s : ancienne référence `%s` redirigée vers `%s`", $old_id_product, $article->ref, $psProduct->id));

                                // On redirige l'ancien produit vers le nouveau
                                $oldProduct = new \Product($old_id_product);
                                if ($oldProduct->id == $old_id_product)
                                {
                                    // Désactivation du produit

                                    // Passage en produit sans déclinaison
                                    $oldProduct->product_type = \Product::PTYPE_SIMPLE;

                                    // configuration de la redirection du produit
                                    $oldProduct->redirect_type = '302-product';
                                    $oldProduct->id_type_redirected = $psProduct->id;

                                    $oldProduct->active = false;

                                    // Option de visibilité par défaut 
                                    $defaultVisibility = $cfg->get(SyncConfiguration::CFG_NO_STOCK_PRODUCT_VISIBILITY);
                                    if ($defaultVisibility)
                                        $oldProduct->visibility = $defaultVisibility;

                                    $oldProduct->save();

                                    // Du coup, on en profite, si notre produit redirige vers ce dernier, on change
                                    // la redirection pour éviter les boucles
                                    if ($psProduct->redirect_type == '302-product' && $psProduct->id_type_redirected == $old_id_product)
                                    {
                                        $psProduct->redirect_type = 'default';
                                        $psProduct->id_type_redirected = 0;
                                        $psProduct->save();
                                    }
                                }

                                // On supprime toute les déclinaisons de l'ancien produit
                                $oldProduct->deleteProductAttributes();

                                // Et tout le stock
                                StockAvailable::removeProductFromStockAvailable($old_id_product);

                                // Recalcul du stock pour l'ancien produit ?
                                StockAvailable::synchronize($old_id_product);                                
                                
                                // Et on purge les références
                                $this->supplierRefMngr->cleanAllCombinationRefs($old_id_product, true);
                            }
                        }
                    }
                }

                // On met à jour les champs facultatifs (en création, ou en mode de travail 1 = gestion depuis le backoffice)
                // Le nom
                if ($product->name && ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_NAME]))
                    $psProduct->name = $product->name;

                // La description
                if ($product->description && ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_DESCRIPTION]))
                    $psProduct->description = $product->description;

                // Les dimensions
                if ($product->width && ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_DIMENSIONS]))
                    $psProduct->width = $product->width;
                if ($product->height && ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_DIMENSIONS]))
                    $psProduct->height = $product->height;
                if ($product->depth && ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_DIMENSIONS]))
                    $psProduct->depth = $product->depth;

                // Le poids
                if ($product->weight && ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_WEIGHT]))
                    $psProduct->weight = round($product->weight / 1000.0, 3);    // Conversion en kg

                // L'éco-taxe
                if ($product->ecotax && ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_ECOTAX]))
                    $psProduct->ecotax = $product->ecotax;

                // La TVA !
                if ($product->taxrate && ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_TAXRATE]))
                {
                    // On recherche la règle de taxe correspondante
                    $taxRate = $product->taxrate * 100;
                    // La clé est un string formaté à 3 décimales
                    $taxRateKey = number_format($taxRate, 3, '.', '');
                    // On cherche le bon id_group_taxe en fonction du taux
                    if (isset($this->taxRules[$taxRateKey]))
                    {
                        // Trouvé !
                        $psProduct->id_tax_rules_group = $this->taxRules[$taxRateKey];
                    }
                    else
                    {
                        // Pas trouvé !                        
                        // On le signale dans le journal d'audit
                        // On arrête !
                        throw new \Exception(sprintf("Taux de taxe `%s` non trouvé pour le pays du magasin", $taxRateKey));
                    }
                }

                // La marque
                if ($product->manufacturer && $product->manufacturer->id && ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_MANUFACTURER]))
                {
                    // Attribution au produit si c'est mappé
                    if ($id_manufacturer)
                        $psProduct->id_manufacturer = $id_manufacturer;
                    else
                        $this->syncer->audit(sprintf("[WRN] Produit #%s : marque `%s - %s` non mappée", $psProduct->id, $product->manufacturer->id, $product->manufacturer->name));
                }

                if ($ean13 && ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_EAN13]) && strlen($ean13) <= 13) // EAN13 fait 13 caractères max, sinon c'est pas valide !
                    $psProduct->ean13 = $ean13;

                // Mappage caractéristiques
                // Les caractéristiques sont mappées comme des attributs de produit :
                // 1 caractéristique va dans un groupe défini en tant que valeur
                $productFeaturesValues = [];
                foreach ($this->featureMapping as $k => $featureId)
                {
                    if (isset($product->productAttributes[$k]))
                        $productFeaturesValues[$featureId] = $product->productAttributes[$k]->name;
                }

                $this->productFeaturesMngr->setProductFeature($this->syncer, $psProduct->id, $productFeaturesValues);                    
                
                // On sauvegarde le produit (s'il y a des modifications)
                $productAudit->save($this->syncer);

                // Cas du prix du produit géré à part pour éviter toutes autres erreurs !
                // On recalcule le prix de l'article (HT !!)
                if ($psProduct->price != $minPrice)
                {
                    // Si le prix a changé, recalculer toutes les déclinaisons, sauf si on est en création (puisqu'il n'en a pas !)
                    if (!$isCreation)
                    {
                        // Mise à jour des prix des déclinaisons du produit                            
                        $cmbs = [];                                         
                        foreach ($this->productAttributesMngr->getProductAttributes($psProduct->id) as $productAttributeId)
                            $cmbs[] = new \Combination($productAttributeId);

                        // Ok, on calcule la valeur minimale du produit
                        $actualMinPrice = null;
                        foreach ($cmbs as $c)
                        {
                            if ($actualMinPrice === NULL || ($psProduct->price + $c->price) < $actualMinPrice)
                                $actualMinPrice = $psProduct->price + $c->price;
                        }

                        if ($minPrice < $actualMinPrice || $actualMinPrice != $psProduct->price)
                        {
                            // Ok, on a une réelle diminution du prix, on doit tout recalculer !
                            $diff = $actualMinPrice - $minPrice;

                            $psProduct->price = $minPrice;

                            foreach ($cmbs as $c)
                            {
                                $ca = new AuditHelper($c);
                                $c->price = $c->price + $diff;
                                $ca->save($this->syncer);
                            }

                            $psProduct->price = $minPrice;
                            $productAudit->save($this->syncer);
                        }                            
                    }
                    else
                    {
                        $psProduct->price = $minPrice;
                        $productAudit->save($this->syncer);
                    }
                }

                // Photos du produit
                $photosMapped = [];

                if ($product->photos !== null && ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_PHOTOS]))
                {
                    // On récupère les photos actuelles
                    $photosMap = $this->photosMngr->translatePhotos($this->syncer, $bridge, $psProduct, $product->photos);

                    // On construit photosMapped dans l'ordre des photos
                    foreach ($product->photos as $photo)
                    {
                        $key = $photosMap[$photo] ?? null;
                        if ($key)
                            $photosMapped[] = $key;
                    }

                    // On supprime les photos en trop
                    // Toutes les photos qui ne sont pas attribuées à une déclinaison existente doivent être supprimées !
                    $allPhotosProduct = $this->photosMngr->getPhotosForAttr($psProduct->id, 0);
                    $allPhotosAttributes = $this->photosMngr->getPhotosForAttr($psProduct->id, -1);

                    foreach ($allPhotosProduct as $photo)
                    {
                        if (!in_array($photo, $allPhotosAttributes))
                        {
                            // On le signale dans l'audit
                            $this->syncer->audit(sprintf("[INF] Produit #%s - %s : photo `%s` supprimée", $psProduct->id, $psProduct->reference, $photo));

                            // On supprime la photo de la base de données
                            $del = new \Image($photo);
                            if ($del->id)
                                $del->delete();
                            // On supprime du cache
                            $this->photosMngr->deletePhoto($psProduct->id, $photo);
                            // On supprime la photo
                            $psProduct->deleteImage($photo);

                            $controlProductPhotos = true;
                        }
                    }
                }

                // Zonage produit
                if ($warehouseLocation && count($warehouseLocation) > 0 && ($isCreation || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_LOCATION]))
                {
                    // on récupère la clé de $warehouseLocation qui a la plus grande valeur
                    $max = max($warehouseLocation);
                    $key = array_search($max, $warehouseLocation);
                    
                    // Si le cache n'est pas chargé, c'est le moment.
                    // Le cache prend pour clé l'id du produit + id_product_attribute et renvoi la valeur de location
                    if (!$this->warehouseLocationsCache)
                        $this->warehouseLocationsCache = $this->getWarehouseLocationsCache();

                    // On regarde le zonage actuel
                    $currentLocation = $warehouseLocationsCache[$psProduct->id . '-0'] ?? null;
                    if ($currentLocation != $key)
                    {                                                                 
                        // Et s'il est différent, on le met à jour
                        StockAvailable::setLocation($psProduct->id, $key);
                        $this->syncer->audit(sprintf("[INF] Produit #%s : zonage mis à jour `%s`", $psProduct->id, $key));
                        // stockage en cache
                        $warehouseLocationsCache[$psProduct->id . '-0'] = $key;
                    }
                }

                /**
                 * Création des déclinaisons !
                 */
                $productAttributes = [];
                foreach ($this->productAttrMapping as $k => $attr_group_id)
                {
                    if (!empty($attr_group_id) && isset($product->productAttributes[$k]) && !empty($product->productAttributes[$k]->name))
                    {
                        $productAttributes[$attr_group_id] = $this->productAttributesMngr->getAttributeFor($this->syncer, 
                                                            $attr_group_id, 
                                                            $product->productAttributes[$k]->name);
                    }                            
                }
                foreach ($this->productColorMapping as $k => $attr_group_id)
                {
                    if (!empty($attr_group_id) && isset($product->productAttributes[$k]) && !empty($product->productAttributes[$k]->name))
                    {
                        $productAttributes[$attr_group_id] = $this->productAttributesMngr->getAttributeFor($this->syncer, 
                                                            $attr_group_id, 
                                                            $product->productAttributes[$k]->name);
                    }                            
                }
                
                // On s'occupe des tailles
                $firstProductAttributeId = 0;

                foreach ($product->articles as $taille)
                {
                    // On calcul les valeurs d'attributs qui compose cette déclinaisons
                    $attributeIds = $productAttributes;
                    $attributeIds[$id_grille_tailles] = $this->productAttributesMngr->getAttributeFor($this->syncer, $id_grille_tailles, $taille->name);
                                                    
                    // On vérifie qu'on a pas d'autres déclinaisons avec la même référence mais pas les mêmes attributs
                    $combination = null;
                    $productAttributeId = $this->supplierRefMngr->translateCombinationRef($taille->ref);
                    if ($productAttributeId)
                    {                            
                        // On a trouvé une déclinaison !
                        // S'agit-il de la même combinaison d'attributs ?
                        $checkProductAttributeId = $this->productAttributesMngr->getCombinationIdWithValues($psProduct->id, $attributeIds);
                        if (!$checkProductAttributeId || $checkProductAttributeId != $productAttributeId)
                        {
                            // Non ! On supprime l'ancienne si c'est pas le même produit
                            $todel = new \Combination($productAttributeId);
                            if ($todel->id_product == $psProduct->id)
                                $todel->delete();

                            $productAttributeId = null;
                        }
                    }

                    // Si on a pas trouvé notre référence, on essaye de retrouver la déclinaison par ses valeurs
                    $refFound = true;
                    if (!$productAttributeId)
                    {
                        $refFound = false;
                        $productAttributeId = $this->productAttributesMngr->getCombinationIdWithValues($psProduct->id, $attributeIds);
                    }

                    if ($productAttributeId)
                    {
                        // On a trouvé !
                        $combination = new \Combination($productAttributeId);
                        if ($combination->id != $productAttributeId)
                        {
                            // Visiblement cette référence n'est plus bonne, on la purge de la mémoire et du disque
                            $this->supplierRefMngr->cleanCombinationRef($taille->ref, $productAttributeId);
                            $combination->delete();
                            $productAttributeId = null;

                            // Et donc, ça sera une nouvelle combination
                            $combination = new \Combination();
                            $paCreated++;
                        }                        
                    }
                    else
                    {
                        $combination = new \Combination();  // Pas trouvé, on part sur une nouvelle combinaison
                        $paCreated++;
                    }
                    
                    $auditCombination = new AuditHelper($combination);
                    // Pas trouvé avec les valeurs ...
                    // On va donc la créer !
                    $combination->id_product = $psProduct->id;
                    $combination->reference = $taille->ref;                    
                    $combination->price = $taille->getPriceHT($product) - $psProduct->price;

                    if (!$combination->id || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_EAN13])
                        $combination->ean13 = $taille->ean13;
                                            
                    // Sauvegarde de la combinaison ?
                    $auditCombination->save($this->syncer);

                    // Si c'est une création, on met les champs obligatoires
                    if (!$productAttributeId)
                    {
                        // On enregistre la référence pour les cache
                        $this->productAttributesMngr->registerProductAttribute($psProduct->id, $combination->id);

                        // On enregistre les attributs de la combinaison
                        $combination->setAttributes($attributeIds);

                        // On fixe la référence fournisseur
                        $this->supplierRefMngr->registerCombinationRef($taille->ref, $psProduct->id, $combination->id);
                    }
                    else if (!$refFound)
                    {
                        // C'est une mise à jour, on s'assure que la référence est bien positionnée si elle ne l'était pas !
                        // On fixe la référence fournisseur
                        $this->supplierRefMngr->registerCombinationRef($taille->ref, $psProduct->id, $combination->id);
                    }

                    if (!$firstProductAttributeId)
                        $firstProductAttributeId = $combination->id;
                    
                    // Zonage combinaison
                    $warehouseLocation = $taille->warehouseLocation;
                    if ($warehouseLocation && (!$productAttributeId || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_LOCATION]))
                    {                           
                        // Si le cache n'est pas chargé, c'est le moment.
                        // Le cache prend pour clé l'id du produit + id_product_attribute et renvoi la valeur de location
                        if (!$warehouseLocationsCache)
                            $warehouseLocationsCache = $this->getWarehouseLocationsCache();

                        // On regarde le zonage actuel
                        $currentLocation = $warehouseLocationsCache[$psProduct->id . '-'.$combination->id] ?? null;
                        if ($currentLocation != $warehouseLocation)
                        {                                                                 
                            // Et s'il est différent, on le met à jour
                            StockAvailable::setLocation($psProduct->id, $warehouseLocation);
                            $this->syncer->audit(sprintf("[INF] Produit #%s/%s : zonage mis à jour `%s`", $psProduct->id, $combination->id, $warehouseLocation));
                            // stockage en cache
                            $warehouseLocationsCache[$psProduct->id . '-'.$combination->id] = $warehouseLocation;
                        }
                    }

                    // Les photos sont-elles identiques ?
                    if ($this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_PHOTOS] && 
                        !$this->photosMngr->arePhotosEqual($psProduct->id, $combination->id, $photosMapped))
                    {
                        // Non !
                        // On le signale dans l'audit.
                        $this->syncer->audit(sprintf("[INF] Produit #%s/%s : photos mises à jour", $psProduct->id, $combination->id));                        
                        
                        // On réattribue les photos
                        $combination->setImages($photosMapped);

                        $controlProductPhotos = true;
                    }
                }

                // Réorganisation des photos produits
                if ($controlProductPhotos)
                {
                    // On réordonne les photos au niveau du produit !!
                    $idx = 0;
                    $setcover = null;
                    foreach ($photosMapped as $photoId)
                    {
                        $img = new \Image($photoId);
                        if ($img->id && $img->id_product == $psProduct->id)
                        {
                            if ($idx == 0)
                            {
                                if (!$img->cover)
                                    $setcover = $img->id;
                            }

                            if ($img->position != $idx)
                            {
                                $img->position = $idx++;                                
                                $img->save();
                            }
                        }
                    }
                    
                    if ($setcover)
                        $psProduct->setCoverWs($setcover);
                }
                
                // Si le produit n'a pas de déclinaison par défaut, nous corrigeons cela
                // TODO : peut-être une optimisation serait la bienvenue ici pour éviter de faire 1 produit = 1 requête
                if ($firstProductAttributeId && !$psProduct->getDefaultIdProductAttribute())                    
                {
                    $psProduct->setDefaultAttribute($firstProductAttributeId);
                    $this->syncer->audit(sprintf("[INF] Produit #%s : déclinaison par défaut mise à jour `%s`", $psProduct->id, $firstProductAttributeId));
                }

                // Synchronisation des catégories dynamiques du produit
                // Ici, on construit simplement un JSON des catégories à avoir
                // et on le stocke dans la table de configuration product qui va bien s'il a changé...
                $ext_props = [
                    'weight' => ($product->weight ?? 0) / 1000.0,
                ];

                // on update la valeur actuelle, au pire c'est la même et mariaDB ne fait rien
                // en attendant d'avoir un cache pour savoir si l'A/R bdd est nécessaire, c'est plus économique que SELECT puis UPDATE
                $db = \Db::getInstance();
                // Avec un insert et si duplicate key, on update
                $result = $db->execute(
                    sprintf('INSERT INTO `' . $this->syncer->module->TblPrefix . 'product` (`id_product`, `backoffice_categorization`, `backoffice_ext_props`) VALUES '.
                    '(%1$d, \'%2$s\', \'%3$s\') ON DUPLICATE KEY UPDATE `backoffice_categorization` = \'%2$s\', `backoffice_ext_props` = \'%3$s\'',
                    $psProduct->id, 
                    $db->escape(json_encode($cats)),
                    $db->escape(json_encode($ext_props)),
                ));
                if ($result === FALSE)
                    throw new \Exception('Erreur lors de la mise à jour des catégories du produit : '. $db->getMsgError());                

                // Auto-activation du produit ?
                // Seulement si :
                // - nous sommes en création
                // - le produit n'est pas déjà actif
                // - le produit dispose d'au moins une photo et une description
                if ($isCreation && 
                    $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_AUTO_PUBLISH] &&
                    trim($psProduct->description) != '' &&
                    count($photosMapped) > 0
                    )
                {
                    // On le signale dans l'audit
                    $this->syncer->audit(sprintf("[INF] Produit #%s : produit activé automatiquement", $psProduct->id));
                    $psProduct->active = true;
                    $psProduct->save();
                }

                // Synchronisation des stocks seulement sur les nouveaux produits ou nouvelle déclinaison
                // Les autres seront effectués par la boucle des stocks, nettement plus optimisée !!
                if (($isCreation || $paCreated > 0) && count($product->stocks) > 0)
                    $syncerStocks->syncStocks($product->stocks);
            }
            catch (FatalException $e)
            {
                throw $e;
            }
            catch (\Exception $e)
            {
                $this->syncer->audit('[ERR] Erreur synchronisation du produit `' . $product->code . ' - ' . $product->name . '` : ' . $e->getMessage());
                if ($castErrors)
                    throw $e;
            }      
        }        

        // Avant de sortir de la boucle, on nettoie la mémoire et les cache 
        // de Prestashop
        if (isset($this->productAttributesMngr) && $cfg->get(SyncConfiguration::CFG_ATTR_AUTOSORT))
            $this->productAttributesMngr->rebuildAttributeOrderIfNeed();
        \Product::resetStaticCache();
        unset($products);

        return $nbProducts;
    }

    
    /**
     * Calcule et classifie les produits en fonction des catégories assignées en attente dans la table product
     * 
     */
    public function mapPendingProductCategories()
    {
        $this->progress->startStep('map_categories', 'Classement des produits...', '{nb} produits classés');        

        // On récupère les informations de mappages étendues
        $cfg = $this->syncer->module->getCfg();

        $db = \Db::getInstance();
        // On selectionne les produits qui doivent être reclassifiés
        $results = $db->executeS('SELECT `id_product`, `backoffice_categorization`, `backoffice_ext_props`, `given_categorization` FROM `' . $this->syncer->module->TblPrefix . 'product` WHERE `backoffice_categorization` != `applied_categorization`');
        if ($results === FALSE)
            throw new \Exception('Erreur lors de la récupération des produits à classer : '. $db->getMsgError());

        $products = [];
        foreach ($results as $row)
            $products[$row['id_product']] = [
                                                'b' => json_decode($row['backoffice_categorization'], true), 
                                                'g' => json_decode($row['given_categorization'], true),
                                                'p' => json_decode($row['backoffice_ext_props'], true),
                                            ];

        // On calcule les catégories que nous devons avoir...
        if (!isset($this->categoriesMngr))
            $this->categoriesMngr = new CategoriesManager($this->syncer);

        $nbDone = 0;
        $this->progress->setMax('map_categories', count($products));
        foreach ($products as $id_product => $info)
        {            
            $cats = [];            

            if ($info['b'] && is_array($info['b']))
            {                
                foreach ($info['b'] as $cat => $boId)
                {                    
                    if (!empty($boId))
                    {
                        $translated_cat = $this->categoriesMngr->translateCode($cat, $boId);
                        if (count($translated_cat) > 0)
                        {
                            if (array_search(-1, $translated_cat) !== FALSE)
                            {
                                $cats = []; // On vide tout, on ne garde rien !
                                break; // On sort du foreach, mais on continue pas ce produit, on sort de la boucle externe également !
                            }

                            $cats = array_merge($cats, $translated_cat);
                        }
                    }
                }
            }

            $givenCats = $info['g'] ?? [];

            // On récupère les catégories actuelles du produit
            $psProduct = new \Product($id_product);
            if (!$psProduct->id)
                continue; // ???

            $home_category = (int) \Configuration::get('PS_HOME_CATEGORY');

            $currentCats = $psProduct->getCategories();
            sort($currentCats);

            // On calcule les catégories qu'on devrait avoir pour ce produit
            // - on récupère les catégories actuelles
            $catsToHave = $currentCats;
            // - on enlève les catégories qu'on a préalablement ajouté
            $catsToHave = array_diff($catsToHave, $givenCats);
            // - on obtient les catégories manuelles qui ont été ajoutées au produit            
            // - on ajoute les catégories qu'on a calculé
            $catsToHave = array_merge($catsToHave, $cats);

            // - on tri le tout            
            sort($catsToHave);
            // - on enlève les doublons
            $catsToHave = array_unique($catsToHave);

            // Si les catégories sont vides, alors on définit la catégorie par défaut comme étant Accueil
            if (empty($catsToHave))
            {
                $default_category = [ $home_category ];
                $cats = $default_category;
                $catsToHave = $default_category;
            }

            // Propriétés étendues
            if (isset($info['p']))
            {
                // On récupère les informations de mappages étendues
                $mapInfos = null;
                
                if (isset($info['p']['weight']) && empty($info['p']['weight']))                
                {
                    if (!isset($mapInfos))
                    {
                        $mapInfos = [];
                        foreach ($info['b'] as $cat_type => $v)
                            $mapInfos[$cat_type] = $cfg->get(SyncConfiguration::CFG_MAP_DYN_CAT . $cat_type . '_exdata');
                    }
    
                    // On calcule le poids du produit
                    $weight = 0;
                    foreach ($info['b'] as $cat_type => $v)
                    {
                        if (isset($mapInfos[$cat_type][$v]['default_weight']))
                            $w = $mapInfos[$cat_type][$v]['default_weight'];
                        else if (isset($mapInfos[$cat_type]['*']['default_weight']))
                            $w = $mapInfos[$cat_type]['*']['default_weight'];
                        else
                            $w = 0;
                        
                        if ($w > $weight)
                                $weight = $w;
                    }
                    
                    if ($psProduct->weight != $weight)
                    {
                        $this->syncer->audit(sprintf("[INF] Produit #%s : poids mis à jour `%s` => `%s`", $id_product, $psProduct->weight, $weight));
                        $psProduct->weight = $weight;
                        $psProduct->save();
                    }
                }
            }

            // Si les tableaux $catsToHave && $currentCats sont différents, on met à jour les catégories du produit
            if ($catsToHave != $currentCats)
            {
                // On met à jour les catégories du produit
                // on le signale dans l'audit
                $this->syncer->audit(sprintf("[INF] Produit #%s : catégories mises à jour #%s => #%s", $id_product, implode(', #', $currentCats), implode(', #', $catsToHave)));
                
                // On supprime les catégories du produit
                $psProduct->deleteCategories();                    

                // On enregistre les nouvelles catégories dans product
                if ($db->execute('UPDATE `' . $this->syncer->module->TblPrefix . 'product` SET `applied_categorization` = `backoffice_categorization`, `given_categorization` = \'' . $db->escape(json_encode($cats)) . '\' WHERE `id_product` = ' . $id_product) === FALSE)
                    throw new \Exception('Erreur lors de la mise à jour des catégories du produit : '. $db->getMsgError());

                // On ajoute les catégories au produit
                $psProduct->addToCategories($catsToHave);

                // Si la catégorie par défaut du produit n'est pas dans la liste, on la met à jour avec la première de la liste
                if ($psProduct->id_category_default == $home_category || !in_array($psProduct->id_category_default, $catsToHave))
                {
                    $choice_level = 0;
                    $psProduct->id_category_default = 0;
                    foreach ($catsToHave as $cat)
                    {
                        $lvl = $this->categoriesMngr->getPsCategoryLevel($cat);
                        if ($lvl > $choice_level)
                        {
                            $psProduct->id_category_default = $cat;
                            $choice_level = $lvl;                            
                        }
                    }

                    if ($psProduct->id_category_default > 0)
                        $psProduct->save();
                }
            }
            else
            {
                // On marque le travail fini
                if ($db->execute('UPDATE `' . $this->syncer->module->TblPrefix . 'product` SET `applied_categorization` = `backoffice_categorization`, `given_categorization` = \'' . $db->escape(json_encode($cats)) . '\' WHERE `id_product` = ' . $id_product) === FALSE)
                    throw new \Exception('Erreur lors de la mise à jour des catégories du produit : '. $db->getMsgError());
            }            

            $nbDone++;
            $this->progress->progress('map_categories');
        } 
        
        $this->progress->endStep('map_categories');
    }

    /**
     * Marque tous les produits qui inclue des références vers des vieilles références pour un type de 
     * catégorie $cat
     * 
     * @param PolarisPrestaConnector $module
     * @param string $cat
     * @param array $ref
     * @param bool $applyNow Si true, on applique immédiatement le marquage, sinon c'est à l'appelant de le faire
     */
    public static function markForUpdate(PolarisPrestaConnector $module, string $cat, string|int $ref, bool $applyNow = false)
    {
        // On cherche les produits qui ont des références vers $ref dans la table product et le JSON de backoffice_categorization en utilisant les opérateurs JSON supportés par MariaDB
        // le format du JSON est le suivant { "$cat" : $ref, "$cat2": $ref2, ... }
        $db = \Db::getInstance();
        $sql = 'UPDATE `' . $module->TblPrefix . 'product` SET `applied_categorization` = \'null\' WHERE JSON_CONTAINS(`backoffice_categorization`, \'{"' . $db->escape($cat) . '": "' . $db->escape($ref) . '"}\')'; 
        $results = $db->execute($sql);
        if ($results === FALSE)
            throw new \Exception('Erreur lors du marquage des produits pour la reclassification : '. $db->getMsgError());

        // Si on doit appliquer immédiatement, on lance le processus de reclassification
        if ($applyNow)
        {
            $syncer = new Syncer($module);
            $syncerProducts = new SyncerProducts($syncer);  
            $syncerProducts->mapPendingProductCategories();
        }
    }
}