<?php
namespace PrestaShop\Module\PolarisPrestaConnector;

use Combination;
use Exception;
use PolarisPrestaConnector;
use PrestaShop\PrestaShop\Adapter\Validate;
use Product;
use StockAvailable;

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

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

    /**
     * Présence du module Colissimo ?
     * 
     * @var bool
     */
    private bool $hasColissimo = false;

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

    /**
     * Cache des emplacements de stock
     * 
     * @var array<string, string>|null
     */
    private ?array $warehouseLocationsCache = null;     

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

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

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

    /**
     * Données produit à synchronier
     * 
     * @var array|null
     */
    private ?array $dataSyncCfg = null;

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

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

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

    /**
     * Règle de taxes 
     * 
     * @var array|null
     */
    private $taxRules;

    /**
     * Cache des pays par code ISO
     * 
     * @var array<string, array>|null
     */
    private ?array $countryByIsoCache = null;

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

    /**
     * Mappage des grilles de tailles
     * 
     * @var array<string, int>
     */
    private ?array $sizeGridsMapping;

    /**
     * Mappage des caractéristiques
     * 
     * @var array<string, int>
     */
    private ?array $featureMapping;

    /**
     * Mappage des références de produits
     * 
     * @var array<int, string>
     */
    private ?array $productRefMapping;

    /**
     * Mappage des déclinaisons
     * 
     * @var array<string, int>
     */
    private ?array $productAttrMapping;

    /**
     * Mappage des couleurs
     * 
     * @var array<string, int>
     */
    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);

        // Présence du module Colissimo ?
        $this->hasColissimo = \Module::isEnabled('colissimo');
        if ($this->hasColissimo)
            require_once _PS_MODULE_DIR_ . 'colissimo/classes/ColissimoCustomProduct.php';
    }

    /**
     * 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;
    }

    /**
     * Obtient le pays à partir de son code ISO
     * 
     * @param string $isoCode Code ISO du pays
     * @return array|null Information sur le pays
     */
    private function getCountryByIsoCode(string $isoCode) : array|null
    {
        if ($this->countryByIsoCache === null)
        {
            $this->countryByIsoCache = [];
            $countries = \Country::getCountries((int) \Context::getContext()->language->id);
            foreach ($countries as $country)
                $this->countryByIsoCache[$country['iso_code']] = $country;
        }

        return $this->countryByIsoCache[$isoCode] ?? null;
    }
    
    /**
     * 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');        
        try
        {
            // On récupère les produits depuis le pont, en boucle
            while ($products = $this->syncer->bridge->pullProducts())
                $nbProducts += $this->syncProducts($products);
        }
        catch (Exception $e)
        {
            $this->syncer->audit(sprintf("[ERR] Erreur lors de la synchronisation des produits : %s", $e->getMessage()));
            $this->progress->error('import_products', $e->getMessage());
        }
        finally
        {
            $this->progress->endStep('import_products');
        }

        // Mappage des catégories en attente
        try
        {            
            $this->mapPendingProductCategories();
        }
        catch (Exception $e)
        {
            $this->syncer->audit(sprintf("[ERR] Erreur lors du mappage automatique des catégories produits en attente : %s", $e->getMessage()));
        }

        // Nettoyage produit
        try
        {
            $this->syncCleanProducts();
        }
        catch (Exception $e)
        {
            $this->syncer->audit(sprintf("[ERR] Erreur lors du nettoyage des produits : %s", $e->getMessage()));
        }
        $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');            
        try
        {
            if (!$this->inited)
                $this->init();

            while ($refs = $this->syncer->bridge->pullCleanedProducts())        
            {            
                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)
                                {
                                    // 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));                            
                                }
                            }
                        }
                    }
                }
            }
            

            // Nettoyage automatique des photos produits !!
            // Seulement si on gère les photos produits
            $days = $this->syncer->module->getCfg()->get(SyncConfiguration::CFG_PHOTO_CLEAN) ?? 0;
            $syncOptions = $this->syncer->module->getCfg()->get(SyncConfiguration::CFG_DATA_SYNC);
            if (isset($syncOptions[SyncConfiguration::CFG_PRODUCT_SYNC_PHOTOS]) && 
                $syncOptions[SyncConfiguration::CFG_PRODUCT_SYNC_PHOTOS] &&
                $days)
            {
                // On calcule la date limite à laquelle le produit doit avoir été modifié au maximum
                $dateLimit = date('Y-m-d H:i:s', strtotime('-' . $days . ' days'));

                // On sélectionne tous les produits :
                // - désactivés (active = 0) 
                // - redirigés sur autre que default et 200
                // - qui n'ont pas été modifiés depuis la date limite
                // - qui n'ont aucune déclinaison
                $db = \Db::getInstance();

                $sql = 'SELECT p.id_product FROM '._DB_PREFIX_.'product p
                        WHERE p.active = 0
                        AND p.redirect_type != "default"
                        AND p.redirect_type != "200"
                        AND (SELECT COUNT(pa.id_product_attribute) FROM '._DB_PREFIX_.'product_attribute pa WHERE pa.id_product = p.id_product) = 0
                        AND p.date_upd < "'.pSQL($dateLimit).'"';

                $products = $db->executeS($sql);
                if ($products === false)
                    throw new Exception('Erreur lors de la récupération des photo produits à nettoyer : '.$db->getMsgError());

                // Pour chaque produit, on supprime ses photos
                foreach ($products as $productRow)
                {
                    $idProduct = (int) $productRow['id_product'];
                    $product = new \Product($idProduct);
                    if (!Validate::isLoadedObject($product))
                        continue;

                    // Chargement des photos à purger
                    $photos = $product->getWsImages();
                    if ($photos)
                    {
                        foreach ($photos as $photo)
                        {
                            $this->syncer->audit(sprintf("[INF] Produit #%s - %s : nettoyage photo `%s`", $product->id, $product->reference, $photo['id']));

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

                            // On supprime la photo
                            $product->deleteImage($photo['id']);
                        }
                    }
                }
            }
        }
        catch (Exception $e)
        {
            $this->progress->error('clean_products', $e->getMessage());
            throw $e;
        }
        finally
        {
            $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);

        // Conversion noms attributs => clé Attributs
        $conv = [];
        
        $productAttrs = $cfg->get(SyncConfiguration::CFG_DYN_ATTR) ?? [];
        $productProps = $cfg->get(SyncConfiguration::CFG_DYN_PROP) ?? [];
        $productAttrsExtended = array_merge($productAttrs, $productProps, $cfg->get(SyncConfiguration::CFG_DYN_CAT));
        foreach ($productAttrsExtended as $attr_code => $attr_name)
            $conv[$attr_name] = $attr_code;
        
        // 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);

        // Nous allons devoir peut-être créer des redirections pour les produits créés, on prépare le manager de redirections
        $redirects_product_url_create_pattern = $cfg->get(SyncConfiguration::CFG_PRODUCT_REDIRECT_URL_CREATE_PATTERN);
        $redirectsManager = $redirects_product_url_create_pattern ? new UrlRedirectManager($this->syncer->module) : null;

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

            try
            {
                if (!$product->id || !$product->idProduct)
                {
                    // 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;
                }

                // DB Link
                $db = \Db::getInstance();
                
                // 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
                $minPrice = 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 && ($minPrice === NULL || $minPrice > $priceHT))
                            $minPrice = $priceHT;
                        if ($article->warehouseLocation)
                            $warehouseLocation[$article->warehouseLocation] = ($warehouseLocation[$article->warehouseLocation] ?? 0) + 1;
                    }

                    if ($minPrice === NULL)
                        $minPrice = 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->idProduct;
                // On concatène chaque attribut de produit qui est discriminant
                foreach ($this->productRefMapping as $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->idProduct . '|' . 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]))
                {
                    // Calcul automatique du nom du produit ?
                    $nameTemplate = $cfg->get(SyncConfiguration::CFG_PRODUCT_NAME_TEMPLATE);
                    if ($nameTemplate)
                    {
                        $nameTemplate = preg_replace_callback('/\{([^{}]*?)\{([^\}]+)\}([^{}]*?)\}/', function($m) use ($product, $conv) {

                            $value = '';

                            if ($m[2] === 'nom')
                                $value = $product->name;
                            if ($m[2] === 'marque')
                                $value = $product->manufacturer?->name ?? "";
                            else if (isset($conv[$m[2]]) && isset($product->productAttributes[$conv[$m[2]]]))
                                $value = $product->productAttributes[$conv[$m[2]]]->name;

                            $value = trim($value);
                            // Si la valeur est vide → supprimer entièrement le bloc
                            if ($value === '') {
                                return '';
                            }

                            // Sinon → reconstruire : before + value + after
                            return $m[1] . $value . $m[3];

                        }, $nameTemplate);
                    }
                    else
                        $nameTemplate = $product->name;

                    $psProduct->name = $nameTemplate;
                }

                // 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)
                        {
                            $newPrice = $psProduct->price + $c->price;
                            if ($actualMinPrice === NULL || $newPrice < $actualMinPrice)
                                $actualMinPrice = $newPrice;
                        }

                        if ($minPrice < $actualMinPrice || $actualMinPrice != $psProduct->price)
                        {
                            // Calcul du nouveau prix de base du produit pour que le prix minimum soit égal au prix calculé
                            $newPrice = min($minPrice ?? 0, $actualMinPrice ?? 0);

                            // La journalisation se fait automatiquement dans l'audit du produit, pas besoin de la faire à la main
                            $this->syncer->audit(sprintf("[INF] Produit #%s : le prix minimum constaté a changé de `%.02f` à `%.02f`", $psProduct->id, $psProduct->price, $newPrice));

                            // Ok, on a une réelle diminution du prix, on doit tout recalculer !
                            $diff = $psProduct->price - $newPrice;                            
                            $tmpP = null;
                            try
                            {
                                $db->execute('START TRANSACTION');
                                $psProduct->price = $newPrice;
                                $productAudit->save($this->syncer);

                                foreach ($cmbs as $c)
                                {                                    
                                    $c->price = round($c->price - $diff, 6);
                                    if ($c->price < 0) $c->price = 0;   // pb d'arrondi ?

                                    $tmpP = $c->price;
                                    $c->save();
                                }
                            }
                            catch (Exception $e)
                            {
                                $db->execute('ROLLBACK');
                                throw new \Exception(sprintf("Erreur lors de la mise à jour du prix minimum du produit #%s sur la déclinaison #%s : %s (prix de la cmb : %s)", $psProduct->id, $c?->id ?? null, $e->getMessage(), $tmpP));
                            }
                            finally
                            {
                                $db->execute('COMMIT');
                            }
                        }                            
                    }
                    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 physiquement
                            $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;
                    // Prix avec max 6 chiffres après la virgule
                    $combination->price = round($taille->getPriceHT($product) - $psProduct->price, 6);                    
                    if ($combination->price < 0) 
                    {
                        $this->syncer->audit(sprintf("[WRN] Produit #%s/%s : prix de la déclinaison négatif (%.02f), mise à 0", $psProduct->id, $combination->id, $combination->price));
                        $combination->price = 0;   // pb d'arrondi ?
                    }

                    if (!$combination->id || $this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_EAN13])
                    {
                        // Si l'EAN13 est supérieur à 13 caractères, on le zap et on le signale dans l'audit                        
                        if ($taille->ean13 && strlen($taille->ean13) > 13)
                            $this->syncer->audit(sprintf("[WRN] Produit #%s/%s : EAN13 `%s` trop long, ignoré", $psProduct->id, $combination->id, $taille->ean13));
                        else
                            $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))
                    {
                        // Visiblement pas
                        // On réattribue les photos
                        $combination->setImages($photosMapped);

                        $controlProductPhotos = true;
                    }
                }

                // Réorganisation des photos produits
                if ($controlProductPhotos)
                {
                    $old = $this->photosMngr->getPhotosForAttr($psProduct->id, $combination->id);

                    $this->syncer->audit(sprintf("[INF] Produit #%s/%s : photos mises à jour : %s -> %s", 
                        $psProduct->id, $combination->id,
                        implode(', ', $old),
                        implode(', ', $photosMapped)
                    ));

                    // 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();
                            }
                        }

                        $idx++;
                    }
                    
                    if ($setcover)
                    {
                        foreach (['image', 'image_shop'] as $table)
                        {
                            if (!$db->execute('UPDATE `' . _DB_PREFIX_ . $table . '` SET `cover` = NULL WHERE `id_product` = ' . (int) $psProduct->id))
                                $this->syncer->audit(sprintf("[WRN] Produit #%s : impossible de réinitialiser les couvertures des photos dans la table `%s` : %s", $psProduct->id, $table, $db->getMsgError()));
                            if (!$db->execute('UPDATE `' . _DB_PREFIX_ . $table . '` SET `cover` = 1 WHERE `id_image` = ' . (int) $setcover))
                                $this->syncer->audit(sprintf("[WRN] Produit #%s : impossible de définir la couverture de la photo `%s` dans la table `%s` : %s", $psProduct->id, $setcover, $table, $db->getMsgError()));
                        }                        
                    }
                }
                
                // 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                
                // 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());                

                // Colissimo - gestion du HS code et pays d'origine
                if ($this->hasColissimo && 
                    ($product->hscode || $product->hscountry) && 
                    ($isCreation || ($this->dataSyncCfg[SyncConfiguration::CFG_PRODUCT_SYNC_HSCODE] ?? false)))
                {
                    // Information colissimo
                    $updated = false;

                    $colissimoInfo = \ColissimoCustomProduct::getByIdProduct($psProduct->id); // Création si nécessaire
                    if ($colissimoInfo->id_product != $psProduct->id)
                    {
                        $colissimoInfo->id_product = $psProduct->id;
                        $updated = true;
                    }

                    if ($product->hscode && $colissimoInfo->hs_code != $product->hscode)
                    {
                        $colissimoInfo->hs_code = $product->hscode;
                        $this->syncer->audit(sprintf("[INF] Produit #%s : code douane HS mis à jour `%s`", $psProduct->id, $product->hscode));
                        $updated = true;                    
                    }

                    // Get Country ID from ISO code
                    if ($product->hscountry)
                    {
                        $country = $this->getCountryByIsoCode($product->hscountry);

                        if ($country)
                        {
                            if ($colissimoInfo->id_country_origin != $country['id_country'])
                            {
                                $colissimoInfo->id_country_origin = $country['id_country'];
                                $this->syncer->audit(sprintf("[INF] Produit #%s : pays d'origine mis à jour pour `%s` (%s)", $psProduct->id, $country['name'], $country['iso_code']));
                                $updated = true;
                            }
                        }
                        else
                        {
                            $this->syncer->audit(sprintf("[WRN] Produit #%s : code ISOa2 pays d'origine `%s` non reconnu", $psProduct->id, $product->hscountry));
                        }
                    }

                    if ($updated)
                        $colissimoInfo->save();
                }

                // 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();
                }

                // Le produit est prêt, faut-il créer l'url automatiquement ?
                if ($redirects_product_url_create_pattern && $product->id && ($isCreation || ($_REQUEST["rebuild-redirects"] ?? 0)))
                {
                    // OUI !
                    // L'ancienne URL est fournie par le pattern, dans lequel on va injecter la référence produit
                    // qui vient du bridge
                    $ancienne_url = str_replace("%%ref%%", $product->id, $redirects_product_url_create_pattern);
                    // La nouvelle URL est fourni par un calcul de lien URL vers $psProduct grace aux méthodes exposées par Prestashop
                    $nouvelle_url = "product(".$psProduct->id.")";

                    // On remplace ou crée la redirection à l'aide du manager
                    $redirectsManager->appendRule($ancienne_url, $nouvelle_url);
                }

                // 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');        
        try
        {
            // 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');
            } 
        }
        catch (Exception $e)
        {
            $this->progress->error('map_categories', $e->getMessage());
            throw $e;
        }
        finally
        {
            $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();
        }
    }
}