<?php
namespace PrestaShop\Module\LCVPrestaConnector\lcv;

use DateTime;
use LCVPrestaConnector;
use PrestaShop\Module\LCVPrestaConnector\BridgeAdapter;
use PrestaShop\Module\LCVPrestaConnector\Models\Article;
use PrestaShop\Module\LCVPrestaConnector\Models\AttributeRef;
use PrestaShop\Module\LCVPrestaConnector\Models\Customer;
use PrestaShop\Module\LCVPrestaConnector\Models\DiscountPlan;
use PrestaShop\Module\LCVPrestaConnector\Models\Order;
use PrestaShop\Module\LCVPrestaConnector\Models\Product;
use PrestaShop\Module\LCVPrestaConnector\Models\Stock;
use PrestaShop\Module\LCVPrestaConnector\Models\Voucher;
use PrestaShop\Module\LCVPrestaConnector\SyncConfiguration;
use PrestaShopBundle\Form\Admin\Type\CustomContentType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;

/**
 * Pont vers LCV
 */
class Bridge extends BridgeAdapter
{
    /**
     * Version minimale de LCV compatible avec ce module
      * (en dessous de cette version, on interdit les échanges et on invite l'utilisateur à mettre à jour son backoffice LCV)
     */
    private const MIN_LCV_VERSION = '9.9.0.1';

    /**
     * Nombre de produits par page
     */
    private const NB_PRODUCTS_AT_A_TIME = 100;
    /**
     * Nombre de stocks à récupérer en une fois
     */
    private const NB_STOCKS_AT_A_TIME = 10000;

    /**
     * Nombre de clients à récupérer en une fois
     */
    private const NB_CUSTOMERS_AT_A_TIME = 1000;

    /**
     * Option de configuration : serveur de photos
     */
    private const CFG_PHOTOS_SERVER = 'LCV_PHOTO_SERVER';

    /**
     * Clé de configuration pour le code magasin de la boutique dans laquelle passer les ventes
     */
    const CFG_WEB_ORDER_STORE_CODE = 'LCV_WEB_ORDER_STORE';

    /**
     * Clé de configuration pour le libellé des produits
     */
    const CFG_WEB_PRODUCT_NAME = 'LCV_WEB_PRODUCT_NAME';

    /**
     * Clé de configuration pour les taxes des magasins de vente
     */
    const CFG_WEB_TAXES = 'LCV_WEB_TAXES';

    /**
     * Clé de configuration pour le stockage du mappage de la correspondance entre les codes de catégories LCV et leur clé de tirage dans LectureProduits
     */
    const CFG_LCV_MAP_CODECAT_TO_CATKEYS = 'LCV_MAP_CODECAT_TO_CATKEYS';

    /**
     * Source de la référence produit : 'RefFour' (référence fournisseur, défaut) ou 'NumMdle' (numéro de modèle LCV).
     */
    const CFG_PRODUCT_REF_SOURCE = 'LCV_PRODUCT_REF_SOURCE';

    /**
     * Mapping pour les attributs de dimensions
     */
    private array $DimensionsMapping = [];

    /**
     * Supporte le filtrage des produits sur ses stocks
     */
    private bool $filterProductStocks = false;

    /**
     * Version du backoffice
     */
    private string $lcvVersion = '';

    /**
     * Constructeur
     * 
     * @param LCVPrestaConnector $module Module parent
     */
    public function __construct(LCVPrestaConnector $module)
    {
        parent::__construct($module);
        
        $dimensionsMap = [ 
            "Poids" => "weight", 
            "Weight" => "weight",
            "Hauteur" => "height", 
            "Height" => "height",
            "Profondeur" => "depth", 
            "Depth" => "depth",
            "Longueur" => "depth",
            "Length" => "depth",
            "Largeur" => "width",
            "Width" => "width",
        ];

        // Ajoutons la version minuscule/majuscule des clés qui map vers les mêmes valeurs
        foreach (array_keys($dimensionsMap) as $key)
        {
            $dimensionsMap[strtolower($key)] = $dimensionsMap[$key];
            $dimensionsMap[strtoupper($key)] = $dimensionsMap[$key];
        }

        $this->DimensionsMapping = $dimensionsMap;

        $this->filterProductStocks = $module->getCfg()->get(SyncConfiguration::CFG_PRODUCT_IMPORT_ONLY_WITH_STOCK) ?? false;

        $this->lcvVersion = $this->config->get(SyncConfiguration::CFG_BACKOFFICE_VERSION) ?? '';
    }

    /**
     * Renvoi l'identifiant du pont correspondant au backoffice géré
     */
    public function getId() : string
    {
        return 'LCV';
    }

    /**
     * Obtient la version minimale supportée du backoffice
     */
    public function getMinBackofficeVersion() : string
    {
        return self::MIN_LCV_VERSION;
    }

    /**
     * Hook appelé à l'initialisation de la synchronisation, avant tout autre traitement
     */
    public function resetSyncPosition()
    {
        parent::resetSyncPosition();

        // Si le fichier _PS_ROOT_DIR_ . '/var/discounts.todo' existe, on le supprime
        $discountsFile = _PS_ROOT_DIR_ . '/var/discounts.todo';
        if (file_exists($discountsFile))
            unlink($discountsFile);
    }

    /**
     * Configuration par défaut de ce pont
     * 
     */
    public function alterDefaultCfg(array & $cfg)
    {
        // A priori, les options par défaut nous convienne, sauf celle non supportées !
        $cfg[SyncConfiguration::CFG_PRODUCT_SYNC_PHOTOS] = false;
        $cfg[SyncConfiguration::CFG_PRODUCT_SYNC_LOCATION] = false;
        $cfg[SyncConfiguration::CFG_IMPORT_CUSTOMERS] = false;
        // Pas de vérif de stock pour LCV
        $cfg[SyncConfiguration::CFG_PRODUCT_IMPORT_ONLY_WITH_STOCK] = true;
        $cfg[SyncConfiguration::CFG_WEB_STORE_CODE] = 'WB'; // Magasin web par défaut pour les ventes
        $cfg[self::CFG_WEB_ORDER_STORE_CODE] = 'WB'; // Magasin web par défaut pour les commandes
        $cfg[self::CFG_PRODUCT_REF_SOURCE] = 'RefFour'; // Référence produit : référence fournisseur par défaut (comportement historique)
        $cfg[SyncConfiguration::CFG_ENV_PULLED] = false; // L'environnement reste à tirer tant qu'on n'est pas associé
    }

    /**
     * Retourne la liste exhaustive des options optionnelles supportées par ce pont
     * 
     * @return array<string> Liste des options optionnelles supportées     
     */
    public function getSupportedOptionalFeatures() : array
    {
        return [
            SyncConfiguration::CFG_IMPORT_CUSTOMERS,
            SyncConfiguration::CFG_PRODUCT_IMPORT_ONLY_WITH_STOCK,
        ];
    }

    /**
     * Révoque les configurations non supportées par le pont
     * 
     */
    public function getSupportedProductOptions() : array
    {
        return [
                SyncConfiguration::CFG_PRODUCT_SYNC_REF,
                SyncConfiguration::CFG_PRODUCT_SYNC_NAME,
                SyncConfiguration::CFG_PRODUCT_SYNC_MANUFACTURER,
                SyncConfiguration::CFG_PRODUCT_SYNC_CATEGORIES,
                SyncConfiguration::CFG_PRODUCT_SYNC_DESCRIPTION,
                //SyncConfiguration::CFG_PRODUCT_SYNC_DIMENSIONS,
                SyncConfiguration::CFG_PRODUCT_SYNC_WEIGHT,
                //SyncConfiguration::CFG_PRODUCT_SYNC_ECOTAX,
                //SyncConfiguration::CFG_PRODUCT_SYNC_TAXRATE,
                //SyncConfiguration::CFG_PRODUCT_SYNC_EAN13,
                //SyncConfiguration::CFG_PRODUCT_SYNC_LOCATION,
                SyncConfiguration::CFG_PRODUCT_SYNC_PHOTOS,
        ];
    }

    /**
     * Retourne la liste exhaustive des types de bon supportés par ce pont
     * 
     * @return array<string> Liste des types de bon supportés     
     */
    public function getSupportedVoucherOptions() : array
    {
        return [
            //Voucher::AVOIR_VOUCHER,
            //Voucher::GIFT_VOUCHER,
        ];
    }

    /**
     * Options supplémentaires à LCV
     */
    public function hookAdminForm(FormBuilderInterface $builder, array &$options): void
    {
        if ($builder->getForm()->getName() == 'catalog')
        {
            // On liste tous les magasins du système
            $stores = [];
            foreach ($this->config->get(SyncConfiguration::CFG_MAP_STORES_NAME) ?? [] as $code => $name)
            {
                if ($code == '*') continue;
                $stores[$name] = $code;
            }

            $dynprops = ['Libellé du produit' => ''];
            foreach ($this->config->get(SyncConfiguration::CFG_DYN_PROP) ?? [] as $code => $name)
                $dynprops[$name] = $code;

            $builder->add('title_lcv', CustomContentType::class, [
                'label' => false,
                'template' => '@Modules/' . $this->module->name . '/views/templates/admin/form_title.html.twig',
                'data' => [
                    'hr' => true,
                    'title' => 'Options supplémentaires',
                    'help' => null,
                ],
            ])

            ->add('LCV_WEB_STORE', ChoiceType::class, [
                'label'=> 'Sélectionner le magasin fournisseur de prix :',
                // Indice pour l'utilisateur sur ce que fait le mode basique et le mode complet 
                'help' => 'Renseignez ici le magasin utilisé pour déterminer les prix du site et la taxe à appliquer.',
                'required' => true,
                'empty_data' => 0,
                'expanded' => false,
                'multiple' => false,
                'choices' => $stores,                
                'data' => $this->config->get(SyncConfiguration::CFG_WEB_STORE_CODE),
            ])

            ->add('LCV_WEB_ORDER_STORE', ChoiceType::class, [
                'label'=> 'Sélectionner le magasin de vente :',
                // Indice pour l'utilisateur sur ce que fait le mode basique et le mode complet 
                'help' => 'Renseignez ici le magasin utilisé pour enregistrer les ventes réalisées sur ce site.',
                'required' => true,
                'empty_data' => 0,
                'expanded' => false,
                'multiple' => false,
                'choices' => $stores,                
                'data' => $this->config->get(self::CFG_WEB_ORDER_STORE_CODE),
            ])

            ->add('LCV_WEB_PRODUCT_NAME', ChoiceType::class, [
                'label'=> 'Surcharge du nom du produit :',
                // Indice pour l'utilisateur sur ce que fait le mode basique et le mode complet 
                'help' => 'Si vous stockez un nom de produit différent (au propre) dans une propriété autre que "Libellé", vous pouvez le sélectionner ici. Sinon, le libellé du produit sera utilisé.',
                'required' => false,
                'empty_data' => '',
                'expanded' => false,
                'multiple' => false,
                'choices' => $dynprops,                
                'data' => $this->config->get(self::CFG_WEB_PRODUCT_NAME) ?? '',
            ])            

            ->add('LCV_PHOTO_SERVER', TextType::class, [
                'label'=>'URL du serveur de photos',
                // Indice pour l'utilisateur sur ce que fait le mode basique et le mode complet
                'help' => 'Renseignez ici une URL au format ftp://utilisateur:motdepasse@ip:port/chemin/',
                'required' => false,
                'data' => $this->config->get(self::CFG_PHOTOS_SERVER),
            ])

            ->add('LCV_PRODUCT_REF_SOURCE', ChoiceType::class, [
                'label' => 'Référence des produits importés :',
                'help' => 'Donnée LCV utilisée comme référence (« reference ») des produits PrestaShop : la référence fournisseur (RefFour) ou le numéro de modèle LCV (NumMdle).',
                'required' => true,
                'expanded' => false,
                'multiple' => false,
                'choices' => [
                    'Référence fournisseur (RefFour)' => 'RefFour',
                    'Référence LCV / n° de modèle (NumMdle)' => 'NumMdle',
                ],
                'data' => $this->config->get(self::CFG_PRODUCT_REF_SOURCE) ?? 'RefFour',
            ]);
        }
    }

    /**
     * Options supplémentaires à LCV
     */
    public function hookSaveAdminForm(FormInterface $form): void
    {
        if ($form->getName() == 'catalog')
        {
            // Magasins web prix
            $web_store = $form->get('LCV_WEB_STORE')->getData();
            if ($web_store && $this->config->get(SyncConfiguration::CFG_WEB_STORE_CODE) != $web_store)
                $this->config->set(SyncConfiguration::CFG_WEB_STORE_CODE, $web_store);

            // Magasin web commande
            $web_store = $form->get('LCV_WEB_ORDER_STORE')->getData();
            if ($web_store && $this->config->get(self::CFG_WEB_ORDER_STORE_CODE) != $web_store)
                $this->config->set(self::CFG_WEB_ORDER_STORE_CODE, $web_store);

            // Propriété du nom du produit
            $product_name_prop = $form->get('LCV_WEB_PRODUCT_NAME')->getData();
            if ($product_name_prop !== null && $this->config->get(self::CFG_WEB_PRODUCT_NAME) != $product_name_prop)
                $this->config->set(self::CFG_WEB_PRODUCT_NAME, $product_name_prop);

            $photo_url = $form->get('LCV_PHOTO_SERVER')->getData();
            // Si $photo_url n'est pas vide, on vérifie qu'il commence par ftp:// ou http:// ou https://. Si ce n'est pas le cas, erreur
            if (!empty($photo_url) && !preg_match('/^(ftp|http|https):\/\/.*/', $photo_url))
                throw new \Exception('L\'URL du serveur de photos doit commencer par ftp://, http:// ou https://');
            // Si $photo_url ne se termine pas par /, on ajoute un /
            if (!empty($photo_url) && substr($photo_url, -1) !== '/')
                $photo_url .= '/';
            // On sauve
            $this->config->set(self::CFG_PHOTOS_SERVER, $photo_url);

            // Source de la référence produit (référence fournisseur ou numéro de modèle LCV)
            $refSource = $form->get('LCV_PRODUCT_REF_SOURCE')->getData();
            if ($refSource && in_array($refSource, ['RefFour', 'NumMdle'], true))
                $this->config->set(self::CFG_PRODUCT_REF_SOURCE, $refSource);
        }
    }

    /**
     * Renvoi les spécificités du pont sous forme de tableau associatif clé/valeur de type string
     * 
     * @return array<string,string>
     */
    public function getSpecifics() : array
    {
        $cfgLogin = $this->config->get(SyncConfiguration::CFG_LOGIN);        

        return [
            "ADDRESS_LABEL" => "Quelle est l'adresse de connexion à votre backoffice LCV (il s'agit en général d'une adresse de la forme https://84.66.142.12:8443/)",
            "PREVIOUS_ADDRESS" => isset($cfgLogin) && isset($cfgLogin["address"]) ? $cfgLogin["address"] : "",
        ];
    }

    /**
     * Teste la connexion au backoffice avec les paramètres fournis
     * 
     * @param array $args Arguments de connexion, avec les clés "address", "username", "password" et éventuellement "fingerprint"
     * @return string|null En cas d'erreur, renvoi un message d'erreur à afficher à l'utilisateur, sinon null si tout est ok
     */
    protected function onBind($args) : string|null
    {
        // Interressons nous à l'URL
        if (!isset($args["address"]) || empty(trim($args["address"])))
            return "1 : l'URL de connexion au service LCV n'est pas définie, ni valide !";

        $url = $args["address"];

        // Si l'adresse ne commence pas par http:// ou https://, on considère que c'est un raccourci, on l'ajoute
        if (substr($url, 0, 5) !== 'http:' && substr($url, 0, 6) != 'https:')
            $url = "https://" . $url;

        // Si nous sommes en production et que l'url commence par http:// alors il s'agit d'un système non sécurisé
        // et nous refusons la connexion
        // on peut savoir si nous sommes en production si le host d'appel est différent de localhost
        if ($_SERVER['HTTP_HOST'] !== 'localhost' && substr($url, 0, 7) === 'http://')
            return "2 : le backoffice LCV ne peut pas être contacté en HTTP, il doit être en HTTPS";

        // On calcule la date du lendemain
        $tomorrow = date('Ymd', strtotime('+1 day'));

        // Testons une connexion
        
        // Initialisation de la session cURL
        $ch = curl_init();
    
        // Configuration des options cURL pour une requête GET
        curl_setopt($ch, CURLOPT_URL, $url . "/LectureDesModelesAvecPrix");
        curl_setopt($ch, CURLOPT_POST, true); // Utiliser la méthode GET
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Pour retourner la réponse comme une chaîne

        curl_setopt($ch, CURLOPT_CERTINFO, true); // Pour récupérer le fingerprint au passage
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);    // NE PAS vérifier la validité du certificat (nom)
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Vérifier que le certificat appartient bien à l'host qu'il désigne
        
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); // Permet de suivre les redirections
        curl_setopt($ch, CURLINFO_HEADER_OUT, true);
        
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json', 'Content-Type: application/json' ]);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
                                                    "user" => $args['username'],
                                                    "mdp" => $args['password'],
                                                    "datemodif" => $tomorrow,
                                                ]));

        // Timeout
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30); 
        curl_setopt($ch, CURLOPT_TIMEOUT, 60);

        // Exécution de la requête cURL
        $response = curl_exec($ch);
        $result = curl_getinfo($ch);

        // Si la vérification du certificat a échouée et que nous n'avons pas de fingerprint passé dans les arguments
        // alors, c'est une erreur
        $pubkeycheck = null;
        if ($result["ssl_verify_result"] != 0)
        {
            // Calcul du fingerprint de la connexion
            $certInfo = !empty($result["certinfo"]) ? $result["certinfo"][0] : null;
            if (!$certInfo)
                return "13 : impossible de récupérer les informations du certificat SSL";        

            // On calcule l'empreinte de la clé publique du certificat
            $fingerprint = openssl_x509_fingerprint($certInfo["Cert"], "sha256");            
            
            if (!isset($args["fingerprint"]))
                return "14 : impossible de vérifier le certificat SSL : Empreinte : " . $fingerprint;

            if (empty(trim($args["fingerprint"])))
                return "16 : il semble que votre backoffice ne dispose pas d'un certificat SSL vérifié par un tier. Vérifiez l'empreinte du certificat SSL. Empreinte : " . $fingerprint;

            if ($args["fingerprint"] != $fingerprint)
                return "15 : le certificat SSL ne correspond pas à l'empreinte fournie. Empreinte : " . $fingerprint;
            // On extrait la clé publique du certificat au format binaire pour en faire un hash256
            // on isole le DER de la clé publique
            $pemPubKey = openssl_pkey_get_details(openssl_pkey_get_public($certInfo["Cert"]));
            $pemPubKey = $pemPubKey["key"];
                
            $begin = "KEY-----";
            $end   = "-----END";
            $pemPubKey = substr($pemPubKey, strpos($pemPubKey, $begin)+strlen($begin));   
            $pemPubKey = substr($pemPubKey, 0, strpos($pemPubKey, $end));

            $der = base64_decode($pemPubKey);

            // Etape 2 : on préviens CURL de ne répondre qu'à cette clé publique
            $pubkeycheck = "sha256//".base64_encode(hash("sha256", $der, true));
        }
        
        if ($result["http_code"] == 0)
        {
            $errMsg = curl_error($ch); 
            return "6 : impossible de se connecter au serveur ".$url." :<br>".$errMsg.".";
        }

        // Si nous avons une erreur 404, c'est très certainement car le serveur n'est pas un backoffice LCV
        if ($result["http_code"] == 404)
            return "7 : le serveur ne semble pas être un backoffice LCV";

        // Fermer cURL
        curl_close($ch);

        // On vérifie maintenant que le nom d'utilisateur et le mot de passe sont corrects
        // Ils sont dans le paramètre $args sous les termes "username" et "password"
        if (!isset($args["username"]) || empty(trim($args["username"])))
            return "20 : vous devez saisir des identifiants valides pour vous connecter à votre service LCV"; 

        if (!isset($args["password"]) || empty(trim($args["password"])))
            return "21 : vous n'avez pas précisé votre mot de passe !";

        // Testons maintenant une connexion AVEC les identifiants et un pinning du certificat

        $response = $this->callApi("LectureDesModelesAvecPrix", [
            "datemodif" => $tomorrow,
        ], [
            'address' => $url,
            'username' => $args["username"],
            'password' => $args["password"],
            'fingerprint' => $args["fingerprint"],
            'pubkeycheck' => $pubkeycheck,
        ]);

        $this->_getErrorFromResponse($response, "Erreur au test des identifiants");

        // sauver la config
        $loginCfg = [
            'address' => $url,
            'username' => $args["username"],
            'password' => $args["password"],
            'fingerprint' => $args["fingerprint"],
            'pubkeycheck' => $pubkeycheck,
        ];
        $this->config->set(SyncConfiguration::CFG_LOGIN, $loginCfg);

        return null;
    }

    /**
     * Obtient l'URL d'une photo par sa clé
     */
    public function getPhotoURL(string $photo) : string
    {
        return $this->config->get(self::CFG_PHOTOS_SERVER) . $photo . '.jpg';
    }

    /**
     * Cache de photos existantes
     */
    private array|null $photoCacheList = null;

    /**
     * Obtient la liste des photos existantes
     */
    private function & getExistantsPhotoList() : array|null
    {
        if ($this->photoCacheList === null)
        {
            // Pas de cache, on renvoi la liste des photos
            $url = $this->config->get(self::CFG_PHOTOS_SERVER);            
            if (substr($url, 0, 6) === 'ftp://')
            {
                $connId = null;
                try
                {
                    $parsedUrl = parse_url($url);
                    $ftpHost = $parsedUrl['host'];
                    $ftpUser = $parsedUrl['user'];
                    $ftpPass = $parsedUrl['pass'];
                    $ftpPath = $parsedUrl['path']; // Récupère le répertoire

                    // Connexion au serveur FTP
                    $connId = ftp_connect($ftpHost, $parsedUrl['port'] ?? 21, 30);
                    if (!$connId) 
                        throw new \Exception("Impossible de se connecter à $ftpHost");
                        
                    // Authentification
                    if (!ftp_login($connId, $ftpUser, $ftpPass))
                        throw new \Exception("Impossible de s'authentifier avec l'utilisateur $ftpUser");

                    // Change le répertoire
                    if (!ftp_chdir($connId, $ftpPath))
                        throw new \Exception("Impossible de changer de répertoire vers $ftpPath");

                    // Mode passif ?
                    ftp_pasv($connId, true);

                    // Liste les fichiers du répertoire
                    $fileList = ftp_nlist($connId, ".");
                    if ($fileList === false)
                        throw new \Exception("Impossible de récupérer la liste des fichiers");                    

                    foreach ($fileList as $file)
                    {
                        $file = basename($file, '.jpg');
                        $this->photoCacheList[$file] = true;
                    }
                }
                catch (\Exception $e)
                {                    
                    throw new \Exception("Erreur lors de la connexion au serveur des photos : " . $e->getMessage());
                }
                finally
                {
                    // Ferme la connexion FTP
                    if ($connId)
                        ftp_close($connId);
                }                
            }
        }
        return $this->photoCacheList;
    }

    /**
     * Appel API LCV
     * 
     * @param string $endpointName Point d'entrée de l'API
     * @param array|null $args Arguments à passer à l'API
     * @param array|null $cfgLogin Configuration de connexion à utiliser (si null, on utilise celle de la config du module)     
     */
    private function callApi(string $endpointName, ?array $args = null, array|null $cfgLogin = null) : array|null
    {
        if (!$cfgLogin)
        {
            $cfgLogin = $this->config->get(SyncConfiguration::CFG_LOGIN);
            if (!$cfgLogin)
                throw new \Exception('Erreur de configuration : le module n\'est pas associé au backoffice LCV !');
        }

        // On rempli les informations de connexion
        if (!isset($args))
            $args = [];

        $args["user"] = $cfgLogin["username"];
        $args["mdp"] = $cfgLogin["password"];

        // Initialisation de la session cURL
        $ch = curl_init();

        // Configuration des options cURL pour une requête POST        
        curl_setopt($ch, CURLOPT_URL, $cfgLogin["address"] . '/' . urlencode($endpointName));
        curl_setopt($ch, CURLOPT_POST, true); // Utiliser la méthode POST
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Pour retourner la réponse comme une chaîne

        curl_setopt($ch, CURLOPT_CERTINFO, true); // Pour récupérer le fingerprint au passage
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);    // NE PAS vérifier la validité du certificat (nom)
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Vérifier que le certificat appartient bien à l'host qu'il désigne

        // Timeout
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
        curl_setopt($ch, CURLOPT_TIMEOUT, (int) ($this->config->get(SyncConfiguration::CFG_API_TIMEOUT) ?: 600));

        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); // Permet de suivre les redirections
        curl_setopt($ch, CURLINFO_HEADER_OUT, true);

        // Pin le certificat sur l'empreinte passée en paramètre si elle existe
        if (isset($cfgLogin["pubkeycheck"]) && !empty($cfgLogin["pubkeycheck"]))
            curl_setopt($ch, CURLOPT_PINNEDPUBLICKEY, $cfgLogin["pubkeycheck"]);

        // On envoi les arguments en JSON dans le body
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json', 'Content-Type: application/json' ]);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($args));        

        // Debug ?
        if ($this->debugMode)
            $this->debug("DEBUG API CALL: " . $cfgLogin["address"] . '/' . urlencode($endpointName) . " with args: " . json_encode($args));

        // Envoi de la requête
        $response = curl_exec($ch);
        $result = curl_getinfo($ch);

        if ($this->debugMode)
            $this->debug("DEBUG API RESPONSE: " . $result["http_code"]);

        if ($result["http_code"] < 200 || $result["http_code"] >= 300)
        {
            $errMsg = '';
            switch ($result["http_code"])
            {
                case 401:
                case 403:
                    $errMsg = "les identifiants fournis sont incorrects pour se connecter au backoffice LCV.";
                    break;
                case 404:
                    $errMsg = "le point d'entrée API '" . $endpointName . "' n'existe pas sur le serveur LCV.";
                    break;
                case 500:
                    $errMsg = "le serveur LCV a rencontré une erreur interne.";                    
                    break;
                case 501:
                    $errMsg = "le serveur LCV ne supporte pas la fonctionnalité demandée.";
                    break;
                case 502: 
                    $errMsg = "le serveur LCV est indisponible.";
                    break;
                case 503:
                    $errMsg = "le serveur LCV est temporairement surchargé.";
                    break;
                case 504:
                    $errMsg = "le serveur LCV ne répond pas.";
                    break;
                default:
                    $errMsg = $response;
                    break;
            }

            $json = json_decode($response, true);
            if ($json && isset($json["fault"]))
            {
                if (isset($json["fault"]["faultstring"]))
                    $errMsg .= "\nErreur: " . $json["fault"]["faultstring"];
                if (isset($json["fault"]["detail"]))
                    $errMsg .= "\nDétails: " . $json["fault"]["detail"];
            }

            throw new \Exception(sprintf("Erreur lors de la récupération des données via API '%s' LCV : Code %d, %s", $endpointName, $result["http_code"], $errMsg));            
        }

        if (!$response || !$result || !is_array($result))
        {
            $errMsg = curl_error($ch);
            if ($errMsg)
                throw new \Exception(sprintf("Erreur lors de la récupération des données via API '%s' LCV : %s", $endpointName, $errMsg));
            else if ($response)
                throw new \Exception(sprintf("Erreur lors de la récupération des données via API '%s' LCV : réponse non attendue, %s", $endpointName, $response));

            // Si on arrive ici, c'est que la réponse est vide, tout est peut-être normal ?
        }

        // Fermer cURL
        curl_close($ch);        

        // On retourne le résultat
        $response = json_decode($response, true) ?? null;
        if ($response)
        {
            // Version ?
            $version = $response["Version"] ?? null;
            if ($version)
            {
                if ($version != $this->lcvVersion)
                {
                    $this->config->set(SyncConfiguration::CFG_BACKOFFICE_VERSION, $version);
                    $this->lcvVersion = $version;
                }

                // Le module n'est compatible qu'à partir de la version 9.9 de LCV, si la version est inférieure, on renvoi une exception
                if (version_compare($this->lcvVersion ?? "0.0.0.0", self::MIN_LCV_VERSION, '<'))
                    throw new \Exception("Ce module n'est compatible qu'avec les versions " . self::MIN_LCV_VERSION . " et supérieures de LCV. Veuillez mettre à jour votre backoffice LCV pour pouvoir utiliser ce module.");
            }
        }

        return $response;
    }

    /**
     * Extrait un message d'erreur d'une réponse API
     * et renvoi une exception si une erreur a été trouvée
     * 
     * @param mixed $response Réponse brute de l'API     
     * @param string|null $errorMsg Message d'erreur à utiliser en cas d'erreur, ou null pour un message générique
     * @throws \Exception Si une erreur a été trouvée dans la réponse
     */
    protected function _getErrorFromResponse(mixed &$response, string|null $errorMsg = null)
    {
        if (!$errorMsg)
            $errorMsg = "Erreur lors de la récupération des données via API LCV";

        $errorMsg .= " : ";

        if (!isset($response) || !$response)
            throw new \Exception($errorMsg . "réponse vide");

        if (is_string($response))
            throw new \Exception($errorMsg . $response);

        if (is_array($response))            
        {
            if (isset($response["Succes"]) && $response["Succes"] === 0)
            {
                if (isset($response["Message"]))
                    throw new \Exception($errorMsg . $response["Message"]);
                else
                    throw new \Exception($errorMsg . "erreur inconnue : " . json_encode($response));
            }
        }
        
        // Tout vas bien ?
    }

    /**
     * Importe la configuration du backoffice via les API
     */
    protected function _pullEnvironment()
    {
        // Chargement configuration
        $this->config->preload();        

        // Tirage
        $infos = $this->callApi('LectureDesTables'); // Tirage des modèles
        $this->_getErrorFromResponse($infos, 'Erreur lors de la récupération des données de configuration via API LCV');

        // Clé de code pour les mappages de catégories dynamiques
        $dynCatLcvCodeKey = 'NomTable';

        // Si costes : 
        /*$cfgLogin = $this->config->get(SyncConfiguration::CFG_LOGIN);        
        if (str_starts_with($cfgLogin["address"], "https://51.68.71.114") || 
            str_starts_with($cfgLogin["address"], "https://51.210.242.21")
            )
            $dynCatLcvCodeKey = 'NumTable';*/

        // Tirage des grilles de taille
        foreach ($infos["Table_des_Grilles_de_Taille"]["Grillesdetaille"] as $grTailles)
        {
            if ($this->config->getMap(SyncConfiguration::CFG_MAP_SIZEGRIDS, $grTailles["CodeGrille"]) === null)
                $this->config->setMap(SyncConfiguration::CFG_MAP_SIZEGRIDS, $grTailles["CodeGrille"], 0);
    
            $name = $grTailles["CodeGrille"] . ' - '. $grTailles["NomGrille"];

            if ($this->config->getMap(SyncConfiguration::CFG_MAP_SIZEGRIDS_NAME, $grTailles["CodeGrille"]) != $name)
                $this->config->setMap(SyncConfiguration::CFG_MAP_SIZEGRIDS_NAME, $grTailles["CodeGrille"], $name);
        }

        // Tirage des magasins
        $taxesMap = [];
        if (isset($infos["Table_des_Magasins"]["Magasin"]))
        {            
            // Tirages des magasins existants
            if (!$this->config->get(SyncConfiguration::CFG_MAP_STORES))
                $this->config->set(SyncConfiguration::CFG_MAP_STORES, [ '*' => 0 ]);                

            if (!$this->config->get(SyncConfiguration::CFG_MAP_STORES_NAME))
                $this->config->set(SyncConfiguration::CFG_MAP_STORES_NAME, [ '*' => 'Tous les autres' ]);

            $stores = $this->config->get(SyncConfiguration::CFG_MAP_STORES);
            $stores_name = $this->config->get(SyncConfiguration::CFG_MAP_STORES_NAME);

            foreach ($infos["Table_des_Magasins"]["Magasin"] as $magasin)
            {
                $code = $magasin["Code"];
                $name = $magasin["Code"].' - '.ucfirst($magasin["Libelle"]);
                $name = trim($name, " \r\n\t-");

                if (!isset($stores[$code]))
                    $this->config->setMap(SyncConfiguration::CFG_MAP_STORES, $code, 0);
                if (!isset($stores_name[$code]) || $stores_name[$code] != $name)
                    $this->config->setMap(SyncConfiguration::CFG_MAP_STORES_NAME, $code, $name);

                if (isset($magasin["TVA"]))
                    $taxesMap[$code] = (float) ($magasin["TVA"] ?? -100) / 100.0;
            }
        }

        // Sauvegarde des taxes
        $this->config->set(self::CFG_WEB_TAXES, $taxesMap);

        // Tirage des marques, à défaut les fournisseurs
        $tblMarques = null;

        // Tirage des catégories dynamiques
        $dyncats = [];        
        $dynattrs = [];
        $dynprops = [];  // attributs non discrimants
        
        foreach ($infos["Table_Article"]["IdTable"] as $cat)
        {
            $nomTable = $cat["NomTable"];            
            // Si le nom de la table est présent dans le mappage des infos de dimensions, alors ce n'est pas une table de mappage
            // mais un lieu de stockage d'information
            if (isset($this->DimensionsMapping[$nomTable]))
                continue;   // On ignore

            $dynattrs[$nomTable] = $nomTable;
        }

        if (isset($infos["Table_des_caracteristiques_complementaires"]["Caracteristique"]))
        {
            foreach ($infos["Table_des_caracteristiques_complementaires"]["Caracteristique"] as $cat)
            {
                if (isset($cat["Code"]) && $cat["Code"] && $cat["Code"] != "*")
                {
                    $dynattrs[$cat["Code"]] = $cat["Libelle"] ?? $cat["Code"];
                    $dynprops[$cat["Code"]] = $cat["Libelle"] ?? $cat["Code"];
                }
            }
        }

        // On sauve les propriétés non discrimantes
        $this->config->set(SyncConfiguration::CFG_DYN_PROP, $dynprops);        

        // Chargement de la correspondance entre les catégories dynamiques LCV et les noms de tables
        $mapCodeToTable = [];

        foreach ($infos["Table_Modele"]["IdTable"] as $cat)
        {
            $nomTable = $cat["NomTable"];
            $codeCategory = $cat[$dynCatLcvCodeKey] ?? $nomTable;
            $mapCodeToTable[$codeCategory] = $nomTable;

            switch ($nomTable)
            {
                case "Marque":
                case "marque":
                    $tblMarques = $cat["Description"]["TableMdle"];
                    break;
                default:
                    // Importation !                    
                    $map = SyncConfiguration::CFG_MAP_DYN_CAT . $codeCategory;
                    $this->assureMapCatchallExists($map, "Tous les autres");

                    $dyncats[$codeCategory] = $nomTable;

                    $mapNames = SyncConfiguration::CFG_MAP_DYN_CAT . $codeCategory."_name";

                    foreach ($cat["Description"]["TableMdle"] as $category)
                    {
                        $code = $category["Code"];
                        if (!$code || $code == "*") continue; // possibilité de null

                        $name = $category["Code"] . ' - ' . $category["Libelle"];
                        if ($this->config->getMap($map, $code) === null)
                            $this->config->setMap($map, $code, 0);

                        if ($this->config->getMap($mapNames, $code) != $name)
                            $this->config->setMap($mapNames, $code, $name);
                    }
                    break;
            }
        }

        // Sauvegarde de la correspondance des codes de catégories dynamiques
        $this->config->set(self::CFG_LCV_MAP_CODECAT_TO_CATKEYS, $mapCodeToTable);

        // Saisons
        $nomTable = "SaisonArticle";
        $codeCategory = $nomTable;
        $map = SyncConfiguration::CFG_MAP_DYN_CAT . $codeCategory;
        $this->assureMapCatchallExists($map, "Tous les autres");

        $dyncats[$codeCategory] = "Saison";

        $mapNames = SyncConfiguration::CFG_MAP_DYN_CAT . $codeCategory."_name";        

        foreach ($infos["Table_des_Saisons"]["Saison"] as $category)
        {
            $code = $category["Code"];
            if (!$code || $code == "*") continue; // possibilité de null

            $name = $category["Code"] . ' - ' . $category["Libelle"];
            if ($this->config->getMap($map, $code) === null)
                $this->config->setMap($map, $code, 0);

            if ($this->config->getMap($mapNames, $code) != $name)
                $this->config->setMap($mapNames, $code, $name);
        }

        if (!$tblMarques)   // Pas de marque, on prend les fournisseurs !
            $tblMarques = $infos["Table_des_Fournisseurs"]["Fournisseur"];

        // Catégories dynamiques
        $this->config->set(SyncConfiguration::CFG_DYN_CAT, $dyncats);
        $this->config->set(SyncConfiguration::CFG_DYN_ATTR, $dynattrs);

        $this->assureMapCatchallExists(SyncConfiguration::CFG_MAP_MANUFACTURER, "Toutes les autres");

        if ($tblMarques)
        {
            foreach ($tblMarques as $marque)
            {
                if (!$marque["Code"] || $marque["Code"] == "*") continue; // possibilité de null
                if ($this->config->getMap(SyncConfiguration::CFG_MAP_MANUFACTURER, $marque["Code"]) === null)
                    $this->config->setMap(SyncConfiguration::CFG_MAP_MANUFACTURER, $marque["Code"], 0);

                $name = $marque["Code"] . ' - '. $marque["Libelle"];

                if ($this->config->getMap(SyncConfiguration::CFG_MAP_MANUFACTURER_NAME, $marque["Code"]) != $name)
                    $this->config->setMap(SyncConfiguration::CFG_MAP_MANUFACTURER_NAME, $marque["Code"], $name);
            }
        }

        // Frais généraux
        if (isset($infos['Table_des_Parametres_Generaux']['Parametres']))
        {
            $params = &$infos['Table_des_Parametres_Generaux']['Parametres'];
            if (isset($params['RefPort']))
            {
                // Si RefPort ne se termine pas par "01000", on l'ajoute automatiquement
                if (substr($params['RefPort'], -5) !== "01000")
                    $params['RefPort'] .= "01000";
                if (empty($this->config->get(SyncConfiguration::CFG_SHIPPING_COST_REF)))
                    $this->config->set(SyncConfiguration::CFG_SHIPPING_COST_REF, $params['RefPort']);
                if (empty($this->config->get(SyncConfiguration::CFG_WRAPPING_COST_REF)))
                    $this->config->set(SyncConfiguration::CFG_WRAPPING_COST_REF, $params['RefPort']);
            }

            // Référence pour les réductions
            if (isset($params['RefPromo']))
            {
                // Si RefPromo ne se termine pas par "01000", on l'ajoute
                if (substr($params['RefPromo'], -5) !== "01000")
                    $params['RefPromo'] .= "01000";
                $this->setMapPayment('Réductions', $params['RefPromo'], true);
            }
        }
        
        // Règlements
        if (isset($infos['Table_des_Modes_de_Reglement']['Mode']))
        {
            foreach ($infos['Table_des_Modes_de_Reglement']['Mode'] as $mode)
            {
                switch ($mode["Libelle"])
                {
                    case "CHEQUE":
                        $this->setMapPayment('Chèque', $mode['Code'], true);
                        break;
                    case "CB":
                    case "CC":
                        $this->setMapPayment('*', $mode['Code'], true);
                        break;
                }
            }
        }
    }

    /**
     * Obtient les produits à synchroniser sous forme de tableau
     * 
     * @param string|null $productsRefsToSync Références des produits à tirer, ou null pour toutes
     * @return array<Product>
     */
    protected function & _pullProducts(?string $productsRefsToSync = null) : array
    {
        $products = [];

        $codeMagasin = $this->config->get(SyncConfiguration::CFG_WEB_STORE_CODE);
        if (!$codeMagasin)
            throw new \Exception('Erreur de configuration : le magasin fournisseur de prix n\'est pas défini');

        $tauxTVA = -1;
        $taxesMap = $this->config->get(self::CFG_WEB_TAXES);
        if (!$taxesMap || !isset($taxesMap[$codeMagasin]))
        {
            $this->pullEnvironment();

            $taxesMap = $this->config->get(self::CFG_WEB_TAXES);
            if (!$taxesMap)
                throw new \Exception('Erreur de configuration : le mappage des taxes n\'a pas été trouvé dans votre configuration');            
        }

        $tauxTVA = $taxesMap[$codeMagasin] ?? -1;
        if ($tauxTVA < 0)
            throw new \Exception('Erreur de configuration : le taux de TVA du magasin web est introuvable !');

        $productNameOverrideProp = $this->config->get(self::CFG_WEB_PRODUCT_NAME) ?? '';

        $photoLists = $this->getExistantsPhotoList();

        if (!$productsRefsToSync)
        {
            // Il faut également être sûr de faire l'opération complète au moins une fois par jour
            if ($this->getSyncPosition("auto_reset_products_sync") != date('Ymd'))
            {
                // Auto-reset
                $this->setSyncPosition('products', '20000101/0');
                $this->setSyncPosition('auto_reset_products_sync', date('Ymd'));
            }
        }

        // Si on a une liste de produits à tirer, on l'utilise
        if ($productsRefsToSync)
        {
            $position = ['', 0];
            $no_modele = explode('|', $productsRefsToSync)[0];

            $response = $this->callApi('LectureDesModelesAvecPrix', [ 
                "cdata" => "0",
                "nummdle" => $no_modele,
                "magasin" => $codeMagasin,
            ]); 
        }
        else
        { 
            $position = explode('/', $this->validateSyncProgress('products') ?? '20000101/1');
            if (count($position) < 2)
                $position[1] = 0;

            $args = [ 
                "datemodif" => $position[0],
                "magasin" => $codeMagasin,
                "page" => $position[1],
                "ligne" => self::NB_PRODUCTS_AT_A_TIME 
            ];

            if ($this->filterProductStocks)
                $args["epuise"] = 1;

            $response = $this->callApi('LectureDesModelesAvecPrix', $args);                
        }

        // Réponse vide du serveur
        if ($response)
        {
            $this->_getErrorFromResponse($response, 'Erreur lors de la récupération des produits');            

            // Tirage des produits
            if ($response && isset($response["Modele"]))
            {
                $dynCats = array_keys($this->config->get(SyncConfiguration::CFG_DYN_CAT));
                $dynAttrs = array_keys($this->config->get(SyncConfiguration::CFG_DYN_ATTR));

                // Chargement de la correspondance entre les catégories dynamiques LCV et les noms de tables
                $mapCodeToTable = $this->config->get(self::CFG_LCV_MAP_CODECAT_TO_CATKEYS) ?? [];

                // Source de la référence produit : référence fournisseur (RefFour) ou numéro de modèle LCV (NumMdle)
                $refSource = $this->config->get(self::CFG_PRODUCT_REF_SOURCE) ?? 'RefFour';

                // Conversion mono réponse
                if (isset($response["Modele"]["NumMdle"]))  // Le numéro de modèle n'est pas au bon niveau, ce qui signifie que $reponse["Modele"] n'est pas un tableau de modèle
                    $response["Modele"] = [ $response["Modele"] ];

                foreach ($response["Modele"] as $source)
                {
                    if (!isset($source["Article"]) || !is_array($source["Article"]))
                        continue;   // ??

                    if (isset($source["Article"]["NumArti"]))
                        $declinaisons = [ $source["Article"] ]; // Un seul
                    else
                        $declinaisons = $source["Article"]; // J'ai un tableau 
                    
                    foreach ($declinaisons as $declinaison)
                    {                    
                        // Construction du produit
                        // Référence selon la configuration : référence fournisseur (RefFour, avec repli NumMdle)
                        // ou directement le numéro de modèle LCV (NumMdle)
                        $code = $source[$refSource] ?? $source["RefFour"] ?? $source["NumMdle"]; 
                        
                        $product = new Product($source["NumMdle"].$declinaison["NumArti"], $code);
                        $product->idProduct = $source["NumMdle"];

                        // Description produit
                        if (isset($declinaison["Desart"]))
                        {
                            // Sous LCV la description article est encodée au format base64url
                            $product->description = $this->cleanHtmlText(base64_decode(strtr($declinaison["Desart"], '-_', '+/')));
                        }                    

                        // Attributs du produit
                        $product->productAttributes = [];
                        
                        foreach ($dynCats as $dynCatPrestaCode)
                        {
                            // Traduction dynCat
                            $dynCatLcvCode = $mapCodeToTable[$dynCatPrestaCode] ?? $dynCatPrestaCode;

                            if (isset($source[$dynCatLcvCode]))
                            {
                                $name = $source[$dynCatLcvCode."_LIB"] ?? null;
                                if (!$name)
                                {
                                    // On localise la clé pour aller chercher le nom de la catégorie dynamique
                                    $name = $this->config->getMap(SyncConfiguration::CFG_MAP_DYN_CAT . $dynCatPrestaCode . "_name", $source[$dynCatLcvCode]);
                                    // Si name est de la forme X - YYYZ, on ne garde que YYYZ
                                    $names = explode(' - ', $name, 2);
                                    if (count($names) > 1)
                                        $name = $names[1];
                                }

                                if (!$name)
                                    $name = $source[$dynCatLcvCode];    // fallback sur le code si pas de nom trouvé

                                $product->productAttributes[$dynCatPrestaCode] = new AttributeRef($source[$dynCatLcvCode], $name);
                            }
                        }

                        foreach ($dynAttrs as $attrCat)
                        {
                            if (isset($declinaison[$attrCat]))
                            {
                                $name = $declinaison[$attrCat."_LIB"] ?? null;
                                if (!$name)
                                    $name = $declinaison[$attrCat];                                        

                                $product->productAttributes[$attrCat] = new AttributeRef($declinaison[$attrCat], $name);
                            }                                
                        }
                        
                        // Override SaisonArticle -> Saison
                        // Les saisons sont un cas particulier car elles sont présentes dans les articles mais vont servir
                        // au niveau du produit. Du coup, on prends la dernière saison (dans l'ordre alphanumérique E26 plutôt que E25 par exemple) parmi les articles pour la positionner au niveau du produit, ce qui permettra de faire du tri et du filtrage plus facilement.
                        if (isset($declinaison["SaisonArticle"]))
                        {
                            if (!isset($product->productAttributes["SaisonArticle"]) || 
                                strcmp($declinaison["SaisonArticle"], $product->productAttributes["SaisonArticle"]->id) > 0)
                                $product->productAttributes["SaisonArticle"] = new AttributeRef($declinaison["SaisonArticle"], $declinaison["SaisonArticle_LIB"] ?? $declinaison["SaisonArticle"]);
                        }
                                            
                        // Grille de tailles
                        $product->grilleTailles = (string) $source["Grille"];

                        // Taxe
                        $product->taxrate = $tauxTVA; 

                        // Les dimensions / poids en case insensitive
                        foreach ($this->DimensionsMapping as $key => $propName)
                        {
                            if (isset($declinaison[$key]) && is_numeric($declinaison[$key]))
                                $product->$propName = $declinaison[$key];
                        }

                        // Les déclinaisons (tailles)
                        if (isset($declinaison["TarifMagInternet"]["QtTaille"]))
                        {
                            if (isset($declinaison["TarifMagInternet"]["QtTaille"]["Indice"]))
                                $tailles = [ $declinaison["TarifMagInternet"]["QtTaille"] ];    // Une seule taille ?
                            else
                                $tailles = $declinaison["TarifMagInternet"]["QtTaille"];    // Un tableau de taille !

                            foreach ($tailles as $taille)
                            {
                                $refs = $taille["CodeBarre"];
                                if (empty($refs) || empty(trim($taille["Taille"], " _\n\r\t\v\0"))) continue;

                                $article = new Article($refs, $taille["Taille"]);

                                // Prix TTC
                                $article->priceTTC = $taille["PrixVente"];                                                                                    

                                // Si une balise EAN_1 existe à la taille, on l'injecte dans le EAN13
                                if (isset($taille["EAN_1"]) && !empty($taille["EAN_1"]))
                                    $article->ean13 = trim($taille["EAN_1"]);

                                $product->articles[] = $article;
                            }
                        }
                        
                        // Nom du produit                    
                        $product->name = $source["Libelle"];
                        if ($productNameOverrideProp && isset($declinaison[$productNameOverrideProp]) && !empty($declinaison[$productNameOverrideProp]))
                            $product->name = $declinaison[$productNameOverrideProp];                    

                        // Marque
                        $code_marque = $source["Marque"] ?? $source["CodFour"] ?? null;
                        if ($code_marque)
                        {
                            // On localise la clé pour aller chercher le nom de la marque
                            $name = $this->config->getMap(SyncConfiguration::CFG_MAP_MANUFACTURER_NAME, $code_marque);
                            // Si name est de la forme X - YYYZ, on ne garde que YYYZ
                            $names = explode(' - ', $name, 2);
                            if (count($names) > 1)
                                $name = $names[1];

                            if (!$name)
                                $name = $code_marque;    // fallback sur le code si pas de nom trouvé

                            $product->manufacturer = new AttributeRef($code_marque, $name);
                        }

                        // Photos !
                        if ($photoLists)
                        {
                            $product->photos = [];                        
                            for ($i = 1; $i <= 6; $i++)
                            {
                                // Modèle + article
                                $photoKey = $source["NumMdle"] . $declinaison["NumArti"] . '_' . $i;
                                if (isset($photoLists[$photoKey]))
                                    $product->photos[] = $photoKey;

                                // Modèle
                                $photoKey = $source["NumMdle"] . '_' . $i;
                                if (isset($photoLists[$photoKey]))
                                    $product->photos[] = $photoKey;

                            }
                        }
                        
                        $products[] = $product;
                    }                
                }
            }

            // Dans le flux LCV, $position n'est pas une page mais la position dans le flux !
            $position[1] += self::NB_PRODUCTS_AT_A_TIME; // la taille étant fixe, on fait un bond, peu importe ce qu'on a reçu

            // Fini
            if (!$productsRefsToSync)   // Pas de filtre, on sauvegarde la position
            {
                $validate = false;

                if (count($products) == 0) // Si c'est fini mais qu'on a des produits à traiter, on refait une boucle car on doit renvoyer 0 quand on a fini fini
                {
                    // Le cas LCV est particulier : il n'y a pas de gestion d'heure donc les produits ne sont pas triés correctement.
                    // Par conséquent, si modifié, un produit peut s'intercaler entre deux positions/pages déjà tirées et on ne le verra pas.
                    // Pour éviter ce problème, on retire tout le flux d'une même journée systématiquement.
                    
                    $position[0] = date('Ymd');    // a priori, on a tout tiré, on déplace notre date de modification pour accélérer la prochaine fois
                    $position[1] = 0;   // Et donc du coup, ça sera la première page

                    $validate = true;
                }

                $this->markSyncProgressForValidation('products', $position[0] . '/' . $position[1], $validate);
            }
        }

        return $products;
    }

    /**
     * Obtient les stocks à synchroniser sous forme de tableau
     * 
     * @return array<Stock>
     */
    protected function & _pullStocks(): array
    {
        $codeMagasin = $this->config->get(SyncConfiguration::CFG_WEB_STORE_CODE);
        if (!$codeMagasin)
            throw new \Exception('Erreur de configuration : le magasin fournisseur de prix n\'est pas défini');
        
        $stocks = [];
        $deltaMode = 0;

        $stores = array_keys($this->config->get(SyncConfiguration::CFG_MAP_STORES) ?? []); 

        // Si nous avons une ancienne position à sauver, c'est le moment
        $position = explode('/', $this->validateSyncProgress('stocks') ?? '19000101/1');
        if (count($position) < 2)
            $position[1] = 0;

        $deltaMode = ($position[0] == date('Ymd')) ? 1 : 0; // En date du jour, on est en mode delta, sinon, on retire tout = on tire au moins tout une fois par jour !

        // Soldes & promos
        $discounts = ['soldes' => [], 'promos' => []];
 
        while (count($stocks) == 0)
        {
            // On va tirer tant qu'on a pas au moins une réponse valide !
            $response = $this->callApi('LectureDuStock', [ 
                "page" => $position[1],
                "ligne" => self::NB_STOCKS_AT_A_TIME,
                "detail_magasin" => 1,
                "magasin" => $codeMagasin,
                "delta" => $deltaMode,
            ]); 

            $this->_getErrorFromResponse($response, 'Erreur lors de la récupération du stock via API LCV');

            /* // On sauve la réponse pour analyse à la suite d'un fichier dump (append) à la racine de prestashop,
               // avec formatage PRETTY
            file_put_contents(_PS_ROOT_DIR_ . '/var/stock_dump.json', json_encode($response, JSON_PRETTY_PRINT)."\n", FILE_APPEND);*/

            if (!$response || 
                !isset($response["Article"]) ||
                !is_array($response["Article"]) ||
                count($response["Article"]) == 0) 
                break;   // Réponse vide : on a tout fait !

            foreach ($response["Article"] as $source)
            {
                $position[1]++; // position suivante pour le rappel de flux !
                
                if ($source && is_array($source) && isset($source["Total"]) && $source["Total"] > 0)
                {
                    if (!isset($source["QtTaille"]) || count($source["QtTaille"]) == 0)
                        continue;   // Pas de détail de taille pour lui = pas de stock

                    if (isset($source["QtTaille"]["Taille"]))
                        $source["QtTaille"] = [ $source["QtTaille"] ]; // C'est un tableau !!

                    foreach ($source["QtTaille"] as $taille)
                    {
                        $stocks_by_store = [];
                        foreach ($stores as $storeCode)
                        {
                            $k = 'Mag_' . $storeCode;
                            if ($storeCode != '*' && isset($taille[$k]))
                            {
                                $n = (int) $taille[$k]; 
                                $stocks_by_store[$storeCode] = $n > 0 ? $n : 0; // On ne garde que les stocks positifs
                            }
                        }

                        $stock = new Stock([$taille["CodeBarre"]], $stocks_by_store);
                        $stock->prixTTC = $taille["PrixVente"];
                        $stocks[] = $stock;
                            
                        $discountSolde = new \PrestaShop\Module\LCVPrestaConnector\Models\Discount($stock->refs);
                        if ($taille["PrixVenteSolde"] > 0 && !empty($stock->prixTTC))
                        {
                            $discountSolde->discount = 1 - round((float) $taille["PrixVenteSolde"] / (float) $stock->prixTTC, Article::PRICE_PRECISION);
                            $discountSolde->active = true;
                        }
                        else
                        {
                            $discountSolde->discount = 0;
                            $discountSolde->active = false;
                        }

                        $discountPromo = new \PrestaShop\Module\LCVPrestaConnector\Models\Discount($stock->refs);
                        if ($taille["PrixVentePromo"] > 0 && !empty($stock->prixTTC))
                        {
                            $discountPromo->discount = 1 - round((float) $taille["PrixVentePromo"] / (float) $stock->prixTTC, Article::PRICE_PRECISION);
                            $discountPromo->active = true;
                        }
                        else
                        {
                            $discountPromo->discount = 0;
                            $discountPromo->active = false;
                        }

                        $discounts['promos'][] = $discountPromo;
                        $discounts['soldes'][] = $discountSolde;                        
                    }                    
                }
                else if (isset($source["NumArticle"]))
                {
                    // Pas de stock, donc pas de détail de taille
                    $stock = new Stock([$source["NumArticle"] . "*"], []);                    
                    $stocks[] = $stock;
                }
            }
        }
                
        if (count($stocks) == 0)
        {            
            // On a fini !
            // Le cas LCV est particulier : il n'y a pas de gestion d'heure donc les produits ne sont pas triés correctement.
            // Par conséquent, si modifié, un produit peut s'intercaler entre deux positions/pages déjà tirées et on ne le verra pas.
            // Pour éviter ce problème, on retire tout le flux d'une même journée systématiquement.            
            $this->markSyncProgressForValidation('stocks', date('Ymd') . '/1', true);
        }
        else
            $this->markSyncProgressForValidation('stocks', $position[0] . '/' . $position[1]);

        if (count($discounts['promos']) > 0 || count($discounts['promos']) > 0)
        {
            // On sauvegarde la sérialisation des soldes et promos dans un fichier pour pouvoir les traiter plus tard
            file_put_contents(
                _PS_ROOT_DIR_ . '/var/discounts.todo',
                serialize($discounts)."\n", 
                FILE_APPEND
                );
        }

        return $stocks;
    }

    /**
     * Interroge le backoffice pour obtenir les promos en cours
     * et les sauve dans un fichier temporaire pour traitement annexe
     * 
     */
    protected function _pullDailyPromos()
    {
        $codeMagasin = $this->config->get(SyncConfiguration::CFG_WEB_STORE_CODE);
        if (!$codeMagasin)
            throw new \Exception('Erreur de configuration : le magasin fournisseur de prix n\'est pas défini');
        
        // Mais il faut également être sûr de faire l'opération complète au moins une fois par jour
        if ($this->getSyncPosition("auto_reset_promos_sync") != date('Ymd'))
        {
            // Auto-reset
            $this->setSyncPosition('daily_promos', '20000101/0');
            $this->setSyncPosition('auto_reset_promos_sync', date('Ymd'));                        
        }

        $position = explode('/', $this->getSyncPosition('daily_promos') ?? '20000101/1');
        if (count($position) < 2)
            $position[1] = 0;

        $last_position = 0;

        while ($last_position != $position[1])
        {        
            $last_position = $position[1];

            $response = $this->callApi('LectureDesModelesAvecPrix', [ 
                "datemodif" => $position[0],
                "magasin" => $codeMagasin,
                "page" => $position[1],
                "ligne" => self::NB_PRODUCTS_AT_A_TIME 
            ]);

            $this->_getErrorFromResponse($response, 'Erreur lors de la récupération des promotions via API LCV');

            // Soldes & promos
            $discounts = ['soldes' => [], 'promos' => []];

            // Tirage des produits
            if ($response && isset($response["Modele"]))
            {
                foreach ($response["Modele"] as $source)
                {
                    // Dans le flux LCV, $position n'a pas l'air d'être une page mais la position dans le flux ?
                    $position[1]++; // position suivante pour le rappel de flux !

                    if (!isset($source["Article"]) || !is_array($source["Article"]))
                        continue;   // ??

                    if (isset($source["Article"]["NumArti"]))
                        $declinaisons = [ $source["Article"] ]; // Un seul
                    else
                        $declinaisons = $source["Article"]; // J'ai un tableau 
                    
                    foreach ($declinaisons as $declinaison)
                    {                    
                        // Les déclinaisons (tailles)
                        if (isset($declinaison["TarifMagInternet"]["QtTaille"]))
                        {
                            if (isset($declinaison["TarifMagInternet"]["QtTaille"]["Indice"]))
                                $tailles = [ $declinaison["TarifMagInternet"]["QtTaille"] ];    // Une seule taille ?
                            else
                                $tailles = $declinaison["TarifMagInternet"]["QtTaille"];    // Un tableau de taille !

                            foreach ($tailles as $taille)
                            {
                                $refs = $taille["CodeBarre"];
                                if (empty($refs) || empty($taille["Taille"])) continue;

                                // Prix TTC
                                $priceTTC = (float) $taille["PrixVente"];
                                if ($priceTTC == 0) continue; // Pas de prix, pas de promo

                                $discountSolde = new \PrestaShop\Module\LCVPrestaConnector\Models\Discount([$refs]);
                                if ($taille["PrixVenteSolde"] > 0 && !empty($priceTTC))
                                {
                                    $discountSolde->discount = 1 - round((float) $taille["PrixVenteSolde"] / $priceTTC, Article::PRICE_PRECISION);
                                    $discountSolde->active = true;
                                }
                                else
                                {
                                    $discountSolde->discount = 0;
                                    $discountSolde->active = false;
                                }

                                $discountPromo = new \PrestaShop\Module\LCVPrestaConnector\Models\Discount([$refs]);
                                if ($taille["PrixVentePromo"] > 0 && !empty($priceTTC))
                                {
                                    $discountPromo->discount = 1 - round((float) $taille["PrixVentePromo"] / $priceTTC, Article::PRICE_PRECISION);
                                    $discountPromo->active = true;
                                }
                                else
                                {
                                    $discountPromo->discount = 0;
                                    $discountPromo->active = false;
                                }

                                $discounts['promos'][] = $discountPromo;
                                $discounts['soldes'][] = $discountSolde;                            
                            }
                        }                    
                    }                
                }
            }

            if (count($discounts['promos']) > 0 || count($discounts['promos']) > 0)
            {
                // On sauvegarde la sérialisation des soldes et promos dans un fichier pour pouvoir les traiter plus tard
                file_put_contents(
                    _PS_ROOT_DIR_ . '/var/discounts.todo',
                    serialize($discounts)."\n", 
                    FILE_APPEND
                    );
            }

            $this->setSyncPosition('daily_promos', $position[0] . '/' . $position[1]);
        }
    }

    /**
     * Obtient les promotions permanentes (soldes et promos) à appliquer sur les produits, sous forme de tableau
     * 
     * @return array<DiscountPlan>
     */
    private array|null $PermanentDiscounts = null;

    /**
     * Obtient les promotions à mettre à jour sous forme de tableau
     * 
     * @return array<DiscountPlan>
     */
    protected function & _pullDiscounts(): array
    {
        // Sur LCV, c'est un peu particulier car il y a toujours deux plans en permanence
        if (!$this->PermanentDiscounts)
        {
            // On essaye de les charger depuis la base de données locale
            $db = \Db::getInstance();
            $sql = "SELECT `code`, `label`, `date_start`, `date_end`, `id_category` FROM `" . $this->module->TblPrefix . "promotion` WHERE code IN ('soldes', 'promos')";
            $res = $db->executeS($sql);
            if ($res === false)
                throw new \Exception('Erreur lors de la récupération des promotions : '.$db->getMsgError());
            foreach ($res as $row)
            {
                $discount = new DiscountPlan($row['code']);
                $discount->name = $row['label'];
                $discount->dateStart = new DateTime($row['date_start']);
                $discount->dateEnd = new DateTime($row['date_end']);                
                
                $this->PermanentDiscounts[$discount->id] = $discount;
            }

            // Il en manque ?
            if (!isset($this->PermanentDiscounts["soldes"]))
            {
                $discount = new DiscountPlan("soldes");
                $discount->name = "Soldes";
                $discount->dateStart = new DateTime("2020-01-01 08:00:00");
                $discount->dateEnd = new DateTime("2020-01-02 00:00:00");
                $this->PermanentDiscounts[$discount->id] = $discount;
            }

            if (!isset($this->PermanentDiscounts["promos"]))
            {
                $discount = new DiscountPlan("promos");
                $discount->name = "Promotions";
                $discount->dateStart = new DateTime(date("Y-m-d")." 00:00:00");
                $discount->dateEnd = new DateTime("2050-01-01");    // Les promos sont permanentes !
                $this->PermanentDiscounts[$discount->id] = $discount;
            }
        }

        $discountsPlan = $this->PermanentDiscounts;
        foreach ($discountsPlan as $plan)
            $plan->discounts = [];  // réinitialisation des promotions
        
        // $this->_pullDailyPromos();  // Tirage des promos du jour : plus nécessaire depuis qu'on passe par le stock !
        if (file_exists( _PS_ROOT_DIR_ . '/var/discounts.todo'))
        {
            $position = $this->validateSyncProgress('current_discounts') ?? '0';

            // On va simplement lire la première ligne du fichier
            $fp = fopen( _PS_ROOT_DIR_ . '/var/discounts.todo', 'r');

            // On passe les lignes déjà lues ($position lignes)
            $ok = true;
            for ($i = 0; $i < $position; $i++)
            {
                if (fgets($fp) === false)
                {
                    $ok = false;
                    break;
                }
            }

            if ($ok)
            {
                $line = trim(fgets($fp));
                fclose($fp);

                // Puis on désérialise la ligne et on l'ajoute aux plans
                if ($line)
                {
                    $discounts = unserialize($line);
                    
                    // Promos
                    if (isset($discounts["promos"]))
                        foreach ($discounts["promos"] as $d)
                            $this->PermanentDiscounts["promos"]->discounts[] = $d;

                    // Soldes
                    if (isset($discounts["soldes"]))
                    {
                        $haveSoldes = false;
                        foreach ($discounts["soldes"] as $d)
                        {
                            $this->PermanentDiscounts["soldes"]->discounts[] = $d;
                            if ($d->active)
                                $haveSoldes = true;
                        }

                        // On a des soldes !
                        if ($haveSoldes)
                        {
                            // Si la date de fin actuelle du plan de soldes est dépassée, alors
                            // on interprète que ce sont de nouvelles soldes, et on recalcule correctement
                            // la nouvelle date de début, et de fin des soldes comme si c'était aujourd'hui
                            $this->PermanentDiscounts["soldes"]->dateStart = new DateTime(date('Y-m-d')." 08:00:00");
                            $this->PermanentDiscounts["soldes"]->dateEnd = (new DateTime(date('Y-m-d')." 00:00:00"))->add(new \DateInterval('P28D'));
                        }
                    }

                    $this->markSyncProgressForValidation('current_discounts', ++$position);
                    return $discountsPlan;
                }
            }
            else
                fclose($fp);

            // Si on arrive ici, c'est que le traitement est terminé !
            $this->markSyncProgressForValidation('current_discounts', 0, true);
            unlink( _PS_ROOT_DIR_ . '/var/discounts.todo');
        }

        // TODO : à faire pour une v2
        $discountsPlan = [];
        
        return $discountsPlan;
    }

    /**
     * Obtient les promotions à mettre à jour sous forme de tableau
     * 
     * @return array<Voucher>
     */
    protected function & _pullVouchers(): array
    {
        throw new \Exception('Tirage des bons non implémenté');
    }

    /**
     * Obtient les clients à synchroniser sous forme de tableau
     * (on importe jamais les clients sur LCV)
     * 
     * @return array<Customer>
     */
    protected function & _pullCustomers(): array
    {
        $customers = [];        
        
        $position = explode('/', $this->validateSyncProgress('customers') ?? '20000101/1');
        if (count($position) < 2)
            $position[1] = 0;

        $args = [ 
            "datederappel" => $position[0],
            "numpage" => $position[1],
            "numligne" => self::NB_CUSTOMERS_AT_A_TIME 
        ];

        $response = $this->callApi('ListeClient', $args);

        // Réponse vide du serveur
        if ($response)
        {
            $this->_getErrorFromResponse($response, 'Erreur lors de la récupération des clients');

            // Tirage des clients
            if ($response && isset($response["Client"]))
            {                    
                // Conversion mono réponse
                if (isset($response["Client"]["NumClient"]))  // Le numéro de modèle n'est pas au bon niveau, ce qui signifie que $reponse["Client"] n'est pas un tableau de modèle
                    $response["Client"] = [ $response["Client"] ];

                foreach ($response["Client"] as $source)
                {
                    /*
                        {
                            "NumClient": "C1978171",
                            "Titre": null,
                            "Nom": "LO",
                            "Prenom": "Jyoxi",
                            "Tel": null,
                            "Portable": null,
                            "Email": "blabla@gmail.com",
                            "Date_creation": 20101029,
                            "Adresse": null,
                            "Adresse2": null,
                            "CP": null,
                            "Ville": null,
                            "Pays": "FRANCE",
                            "Langue": null,
                            "Newsletter": null
                        }
                    */
                    if ($source == null || !isset($source["Email"]) || empty($source["Email"]))
                        continue;   // Pas d'email = pas de client

                    $customer = new Customer(0, $source["Email"]);
                    $customer->company = $source['Societe'] ?? null;
                    $customer->firstname = $source['Prenom'] ?? null;
                    $customer->lastname = $source['Nom'] ?? null;
                    $customer->civilite = $source['Titre'] ?? null;

                    $customers[] = $customer;
                }
            }

            // Dans le flux LCV, $position n'est pas une page mais la position dans le flux !
            $position[1] += self::NB_CUSTOMERS_AT_A_TIME; // la taille étant fixe, on fait un bond, peu importe ce qu'on a reçu

            // Fini
            $validate = false;

            if (count($customers) == 0) // Si c'est fini mais qu'on a des produits à traiter, on refait une boucle car on doit renvoyer 0 quand on a fini fini
            {
                // Le cas LCV est particulier : il n'y a pas de gestion d'heure donc les produits ne sont pas triés correctement.
                // Par conséquent, si modifié, un produit peut s'intercaler entre deux positions/pages déjà tirées et on ne le verra pas.
                // Pour éviter ce problème, on retire tout le flux d'une même journée systématiquement.
                
                $position[0] = date('Ymd');    // a priori, on a tout tiré, on déplace notre date de modification pour accélérer la prochaine fois
                $position[1] = 0;   // Et donc du coup, ça sera la première page

                $validate = true;
            }

            $this->markSyncProgressForValidation('customers', $position[0] . '/' . $position[1], $validate);
        }                 

        return $customers;
    }

    /**
     * Pousse le client indiqué vers le backoffice LCV, retourne son identifiant
     * 
     * @param \Customer $psCustomer Client à exporter
     * @param string $codecli Code client retourné par LCV
     * @return string|null null si le client a été exporté, autrement justification de la non-exportation sans erreur
     */
    private function _pushCustomerToLCV(\Customer $psCustomer, string & $codecli = null) : string|null
    {
        $customer = [
            "email" => ltrim($psCustomer->email ?? ''),
            "nom" => ltrim($psCustomer->lastname ?? ''),
            "prenom" => ltrim($psCustomer->firstname ?? ''),
            //"sCodePays" => "?",
            "societe" => $psCustomer->company,
            "codesite" => "WB",
            // "Typecli" => "",
            // "MagOrigine" => "",
            "newsletter" => $psCustomer->newsletter ? "O" : "N",
            //"sDateNewsLetter" => "",
        ];

        if (!$customer['nom'] || !$customer['prenom'] || !$customer['email'])
            throw new \Exception('Impossible d\'exporter le client vers LCV : nom, prénom ou email manquant');

        $birthdate = str_replace('-', '', $psCustomer->birthday ?? '');
        if ($birthdate && strlen($birthdate) == 8 && $birthdate != '00000000')
            $customer["datenaissance"] = $birthdate;

        // L'adresse principale
        $psAddress = $this->getCustomerInvoiceAddress($psCustomer);
        if ($psAddress && ltrim($psAddress->address1 ?? '') && ltrim($psAddress->postcode ?? '') && ltrim($psAddress->city ?? ''))
        {
            $customer["adr1"] = $psAddress->address1;
            $customer["adr2"] = $psAddress->address2;
            $customer["cp"] = $psAddress->postcode;
            $customer["ville"] = $psAddress->city;
            $customer["codepays"] = strtoupper($psAddress->country);
        }

        $civilite = $this->getGenderFromPsGenderId($psCustomer->id_gender);
        if ($civilite)
            $customer["civ"] = $civilite;

        $returnMsg = $this->callApi('EcritureClient', $customer);
        $this->_getErrorFromResponse($returnMsg, 'Erreur lors de l\'envoi du client "' . $psCustomer->email . '" par API LCV');

        // Normalement le retour devrait être un JSON du style { "Resultat": "Enregistrement reussi, client numero WB052732" }
        // On extrait l'id qui est WB052732
        $codecli = null;
        if (isset($returnMsg["Message"]))
        {
            $matches = [];
            if (preg_match('/client numero ([A-Z0-9]+)/i', $returnMsg["Message"], $matches))
                $codecli = $matches[1];
            else
                return $returnMsg["Message"];
        }
        
        return null;    // Tout est ok !
    }

    /**
     * Ne supporte pas la référence client externe, car LCV génère lui même son propre code client et ne permet pas de le fournir en entrée
     */
    public function supportCustomersRefExt(): bool
    {
        return false;
    }
    
    /**
     * Pousse le client indiqué vers le backoffice LCV
     * 
     * @param \Customer $psCustomer Client à exporter     
     * @return string|null null si le client a été exporté, autrement justification de la non-exportation sans erreur
     */
    protected function _pushCustomer(\Customer $psCustomer) : string|null
    {
        $codecli = null;
        return $this->_pushCustomerToLCV($psCustomer, $codecli);        
    }

    /**
     * Pousse la commande indiquée vers le backoffice comme une seule et unique vente rattaché au client de la première
     * 
     * @param Order $order Commande à importer comme une seule et unique vente sous le backoffice
     * @return string|null null si la commande a été exportée, autrement justification de la non-exportation sans erreur
     */
    protected function _pushOrder(Order $order) : string|null
    {
        $codeMagasin = $this->config->get(self::CFG_WEB_ORDER_STORE_CODE);
        if (!$codeMagasin)
            throw new \Exception('Erreur de configuration : le magasin de vente n\'est pas défini');

        // On construit la vente
        $comm = $order->revision == 0 ? 
                    sprintf("Cde. Prestashop %s", $order->reference) : 
                    sprintf("Modif. cde. Prestashop %s (rev. %d)", $order->reference, $order->revision);
        // Si les deux caractères de fin du tag comporte /R, alors on ajoute le terme 'Annulation ' devant le libellé
        if (substr($order->tag, -2) == '/R')
            $comm = "Annulation " . strtolower($comm);

        $vente = [     
            "numcde" => $order->tag,
            "commentaire" => $comm,
            "codemag" => $codeMagasin,
            "codesite" => $codeMagasin,
            "coderepresentant" => "WEB",
            "coordonnees" => 0,
            "mp" => "WEB/PRESTA",

            "codereglt" => "",            
        ];
                
        // On ajoute les produits
        $products = [];
        $total = 0;
        $nbbase = 1;
        foreach ($order->details as $orderDetail)
        {                
            // Calcul du SKU
            // La référence stockée dans la commande sinon l'ean13 du produit !
            $sku = $orderDetail->product_supplier_reference;
            if (!$sku) $sku = $orderDetail->product_ean13;

            $price_tax_included = round(($orderDetail->original_product_price * $orderDetail->product_quantity * (100 + $orderDetail->tax_rate) / 100.0), 2);
            $price_tax_included = $price_tax_included;   // devise
            $product = [ "V",
                            $sku,    // SKU
                            //1, // Position dans la grille de taille ???
                            $orderDetail->product_quantity, // Quantité
                            sprintf("%0.2f", $price_tax_included), // Montant TTC
                            str_replace(";", " ", str_replace("+", " ", $orderDetail->product_name ?? "-")), // Nom du produit
                            $nbbase + count($products),
                        ];

            $total += $price_tax_included;
            
            $products[] = join(";", $product); // Ajout du produit
        }
        
        // On ajoute les paiements
        $unknownCodes = [];
        $total_paid = 0;
        foreach ($order->payments as $payment)
        {            
            // paiement ?
            $total_paid += $payment->amount;
            if (empty($vente["codereglt"]))
            {
                $pay_code = $this->mapPayment($payment->method);
                if (!$pay_code || empty($pay_code))
                    $unknownCodes[] = $payment->method;
                else
                    $vente["codereglt"] = $pay_code; // On prend le dernier paiement comme mode de paiement
            }
        }

        if (empty($vente["codereglt"]))
        {
            foreach ($unknownCodes as $pay_method)
                return sprintf("le mode de paiement '%s' n'est pas mappé", $pay_method);   // Commande ignorée !
            // Même pas d'erreur ? Pas de paiement ???
            return sprintf("aucun mode de paiement mappé pour la commande %s", $order->reference);   // Commande ignorée !
        }

        // Gestion des remises paniers
        // Sur LCV la remise est simple, c'est la différence entre les prix qu'on aurait du payer et le prix qu'on a payé !!!
        $remise_key = "Réductions";
        $remise_code = $this->mapPayment($remise_key, false);   // pas de catch-all sur celui-ci malheureux !
        if (!$remise_code || empty($remise_code))
            return sprintf("le mode de paiement '%s' n'est pas mappé", $remise_key);   // Commande ignorée !

        $total_discount = $total - $total_paid;
        if ($total_discount != 0)
        {
            // L'indice de taille est composé des deux derniers chiffre du code remise
            $products[] = join(";", [ "V", $remise_code, -1, sprintf("%.02f", $total_discount), $remise_key, $nbbase + count($products) ]);
        }

        $vente["vente"] = join("+", $products); // Ajout des produits

        // TODO : Légalement, la date de la vente est la date du premier paiement !
        // Il semblerait que nous n'ayons pas la possibilité d'exporter cette information vers LCV
        if ($this->config->get(SyncConfiguration::CFG_EXPORT_CUSTOMERS) && $this->isCustomerValidForExport($order->id_customer))
        {
            $psCustomer = new \Customer($order->id_customer);
            $codecli = null;
            if (!$this->_pushCustomerToLCV($psCustomer, $codecli) && $codecli)
                $vente["codecli"] = $codecli; 
        }
        
        // Puis on enregistre la vente dans LCV        
        $result = $this->callApi('EcritureVenteGescom', $vente);
        $this->_getErrorFromResponse($result, 'Erreur lors de l\'envoi de la commande "' . $order->reference . '" par API LCV');

        return null;
    }
}
