<?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;

    /**
     * Dans le cas où tous les attributs de produits sont dans le mappage de référence, 
     * on peut faire une optimisation et ne pas faire de concaténation de la référence du produit, 
     * mais prendre directement l'id du produit comme référence, ce qui est plus rapide à calculer et à comparer.
     */
    private bool $doProductRefMapping = true;  

    /**
     * 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;
                
        // Si tous les attributs de produits sont dans le mapping de référence, on peut faire une optimisation et ne pas faire de concaténation de la référence du produit, mais prendre directement l'id du produit comme référence, ce qui est plus rapide à calculer et à comparer.  
        $this->doProductRefMapping = count($this->productMappingCfg[SyncConfiguration::PRODUCT_ATTR_PRODUCT]) != count($this->productRefMapping);
        
        /**
         * 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
                if ($this->doProductRefMapping)
                {
                    foreach ($this->productRefMapping as $attr)
                        $productRef .= '|' . ($product->productAttributes[$attr]?->id ?? '');                    
                }
                else
                {
                    // Si on n'a pas de mapping de référence, on prend directement l'id du produit
                    $productRef .= '|{' .$product->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;
                $reUseProduct = false;
                if ($psProduct == null)
                {                    
                    // 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;
                        $isCreation = true;
                    }                       
                    else
                        $reUseProduct = true;

                    // 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 = '';
                if ($this->productRefMapping)
                {
                    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 || $reUseProduct)
                {
                    // 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] &&                    
                    count($photosMapped) > 0
                    )
                {
                    // Potentiellement activable, on vérifie que la description est présente (en fonction du paramétrage)
                    // Mais celle-ci est soit un texte (facile à vérifier), soit un array qui contient des textes (multilangue)
                    // Dans ce dernier cas, on considère que la description est présente si au moins une des langues a une description renseignée
                    $descriptionOk = false;
                    if (is_array($psProduct->description))
                    {
                        foreach ($psProduct->description as $desc)
                        {
                            if ($desc !== NULL && !empty(trim($desc)))
                            {
                                $descriptionOk = true;
                                break;
                            }
                        }
                    }
                    else if (is_string($psProduct->description))
                        $descriptionOk = !empty(trim($psProduct->description));

                    if ($descriptionOk)
                    {
                        // 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();
        }
    }
}