<?php
namespace PrestaShop\Module\PolarisPrestaConnector\pol;

use PolarisPrestaConnector;
use PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\MetierClientWebConnectAdresse;
use PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\MetierClientWebConnectClientUpdateable;
use PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\MetierClientWebConnectFiltreClient;
use PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\ProduitDetailTaille;
use PrestaShop\Module\PolarisPrestaConnector\BridgeAdapter;
use PrestaShop\Module\PolarisPrestaConnector\Models\Article;
use PrestaShop\Module\PolarisPrestaConnector\Models\AttributeRef;
use PrestaShop\Module\PolarisPrestaConnector\Models\Customer;
use PrestaShop\Module\PolarisPrestaConnector\Models\Discount;
use PrestaShop\Module\PolarisPrestaConnector\Models\DiscountPlan;
use PrestaShop\Module\PolarisPrestaConnector\Models\Order;
use PrestaShop\Module\PolarisPrestaConnector\Models\PrepaidCard;
use PrestaShop\Module\PolarisPrestaConnector\SyncConfiguration;
use PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Api\CatalogApi;
use PrestaShop\Module\PolarisPrestaConnector\Models\Product;
use PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Api\ConfigApi;
use PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\InfoConnect;
use PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\Pager;
use PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Api\StocksApi;
use PrestaShop\Module\PolarisPrestaConnector\Models\Stock;
use PrestaShop\Module\PolarisPrestaConnector\Models\Voucher;
use PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Api\ClientsApi;
use PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Api\VentesApi;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Psr18Client;

/**
 * Pont vers Polaris
 * 
 * Memo : utilisation de l'API Polaris possible ici 
 * $config = Configuration::getDefaultConfiguration()->setApiKey('X-API-Key', 'XX XXXX XXXX XXXX XXXX XXXX XXXX XXXX');
 * $config->getDefaultConfiguration()->setHost('https://XXX:14243/api/WebConnect');
 * $api = new CatalogApi(null, $config);
 */
class Bridge extends BridgeAdapter
{
    /**
     * Version minimale de Polaris requise pour ce module
     */
    private const MIN_POLARIS_VERSION = '14.0';

    /**
     * Nombre de produits à récupérer en une fois
     */
    private const NB_PRODUCTS_AT_A_TIME = 100;

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

    /**
     * Nombre de stocks à récupérer en une fois
     */
    private const NB_STOCKS_AT_A_TIME = 10000;


    /**
     * Identifiants de liste de diffusion
     */
    public const CONST_DIFF_MAILING = 32768;    


    /**
     * URL de base de l'API
     */
    private string $baseUrl = "";

    /**
     * Version de Polaris détectée
     */
    private string $polarisVersion = "";

    /**
     * Informations de connexion
     */
    private ?InfoConnect $structure = null;

    /**
     * Cache de nom de grilles de tailles
     */
    private array $sizeGrids = [];

    /**
     * Api Polaris de produit
     */
    private ?CatalogApi $_catalogApi = null; 

    /**
     * Api Polaris de stock
     */
    private ?StocksApi $_stocksApi = null;

    /**
     * Api Polaris de ventes
     */
    private ?VentesApi $_ventesApi = null;

    /**
     * Api Polaris de clientèle
     */
    private ?ClientsApi $_clientsApi = null;

    /**
     * Plan de promotions
     */
    private ?array $discountsPlan = null;    

    /**
     * Constructeur
     * 
     * @param PolarisPrestaConnector $module Module parent
     */
    public function __construct(PolarisPrestaConnector $module)
    {
        parent::__construct($module);

        // Options spécifiques au module : supporte les stocks lors du tirage produit
        $this->SupportStockOnProducts = true;
    }

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

    /**
     * Calcule la référence externe d'un client
     * Prend en compte l'id local, mais également la date d'ajout des clients pour éviter un retour
     * en arrière problèmatique.
     * Traduit la date en timestamp et la converti en base64 puis la concatène à l'id client.
     * 
     * @param \Customer $customer Client chargé
     */
    public function getCustomerRefExt(\Customer $customer): string
    {
        // Recrée une date à partir du timestamp pour vérifier que c'est bon
        return $customer->id . '-'. base64_encode(pack('N', (new \DateTime($customer->date_add))->getTimestamp()));
    }

    /**
     * Options supplémentaires à Polaris
     */
    public function hookAdminForm(FormBuilderInterface $builder, array &$options): void
    {        
    }

    /**
     * Sauvegarde les options du formulaire de configuration du catalogue
     */
    public function hookSaveAdminForm(FormInterface $form): void
    {

    }

    /**
     * Obtient la configuration de connexion pour l'API
     */
    private function getApiConfig() : \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Configuration
    { 
        // On charge la configuration
        $loginCfg = $this->config->get(SyncConfiguration::CFG_LOGIN);

        if (!isset($loginCfg) || !isset($loginCfg['address']) || !isset($loginCfg['key']))
        {
            if (!empty($loginCfg))
            {
                $loginCfg = null;
                $this->config->set(SyncConfiguration::CFG_LOGIN, $loginCfg);  // reset !
            }

            throw new \Exception("Le module n'est pas associé au backoffice Polaris !");
        }

        $config = \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Configuration::getDefaultConfiguration();

        $config->setHost($loginCfg['address'] .  '/api/WebConnect');
        $config->setApiKey('X-API-Key', $loginCfg['key']);
        $config->setUserAgent('PolPolarisPrestaConnector/1.0; enum_as_str;');

        $this->baseUrl = $loginCfg['address'] . '/';

        // Si la version du backoffice n'est pas encore connue, on la charge
        if (empty($this->polarisVersion))
            $this->polarisVersion = $this->config->get(SyncConfiguration::CFG_BACKOFFICE_VERSION) ?? '';

        return $config;
    }

    /**
     * Obtient le client HTTP
     * 
     * @return Psr18Client
     */
    private function getHttpClient() : Psr18Client
    {
        $symfonyNative = HttpClient::create([
            'timeout'       => (int) ($this->config->get(SyncConfiguration::CFG_API_TIMEOUT) ?: 600), // délai d'inactivité (lecture) en secondes
            'max_duration'  => 0,   // 0 = pas de limite globale (sinon fixe la durée totale)
            //'connect_timeout' => 30 // optionnel : délai de connexion
        ]);

        return new Psr18Client($symfonyNative);   // PSR-18
    }

    /**
     * Obtient l'API clients
     */
    private function getClientsApi() : ClientsApi
    {        
        if ($this->_clientsApi === null)
        {
            $apiCfg = $this->getApiConfig();
            if (!$apiCfg)
                throw new \Exception('Impossible de se connecter à l\'API Polaris');
                
            $this->_clientsApi = new ClientsApi($this->getHttpClient(), $apiCfg);
        }

        return $this->_clientsApi;
    }

    /**
     * Obtient l'API catalog
     */
    private function getCatalogApi() : CatalogApi
    {        
        if ($this->_catalogApi === null)
        {
            $apiCfg = $this->getApiConfig();
            if (!$apiCfg)
                throw new \Exception('Impossible de se connecter à l\'API Polaris');

            $this->_catalogApi = new CatalogApi($this->getHttpClient(), $apiCfg);
        }

        return $this->_catalogApi;
    }

    /**
     * Obtient l'API stocks
     */
    private function getStocksApi() : StocksApi
    {        
        if ($this->_stocksApi === null)
        {
            $apiCfg = $this->getApiConfig();
            if (!$apiCfg)
                throw new \Exception('Impossible de se connecter à l\'API Polaris');

            $this->_stocksApi = new StocksApi($this->getHttpClient(), $apiCfg);
        }

        return $this->_stocksApi;
    }

    /**
     * Obtient l'API ventes
     */
    private function getVentesApi() : VentesApi
    {        
        if ($this->_ventesApi === null)
        {
            $apiCfg = $this->getApiConfig();
            if (!$apiCfg)
                throw new \Exception('Impossible de se connecter à l\'API Polaris');

            $this->_ventesApi = new VentesApi($this->getHttpClient(), $apiCfg);
        }

        return $this->_ventesApi;
    }

    /**
     * Obtient les informations de l'API
     */
    private function getStructure() : InfoConnect|null
    {        
        if ($this->structure === null)
        {
            // Les infos n'ont pas encore été tirées, on s'en occupe !
            $apiCfg = $this->getApiConfig();
            if (!$apiCfg)
                return null;

            $infoApi = new ConfigApi($this->getHttpClient(), $apiCfg);            

            $httpResult = $infoApi->configGetStrutureWithHttpInfo();
            $this->structure = $httpResult[0] ?? null;
            if (!$this->structure)
                throw new \Exception('Impossible de récupérer les informations de connexion');            

            // Version de Polaris
            $version = explode(' ', $httpResult[2]['server'][0] ?? '');
            $version = count($version) >= 2 ? ($version[1] == 'Embedded' ? '13.1.0.00000' : $version[1]) : '0.0.0.00000';

            if ($this->polarisVersion != $version)
                $this->config->set(SyncConfiguration::CFG_BACKOFFICE_VERSION, $version);

            // BUG API Polaris : grilles de tailles
            $this->sizeGrids = [];
            foreach ($this->config->get(SyncConfiguration::CFG_MAP_SIZEGRIDS) as $grid => $size)
                $this->sizeGrids[$grid] = $size;            

            // 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' ]);

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

            foreach ($pageMagasins->getItems() as $mag)
            {
                $code = $mag->getCode();
                $name = $mag->getCode().' - '.ucfirst($mag->getNom()).' - '.strtoupper($mag->getVille());
                $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);
            }
        }

        return $this->structure;
    }

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

    /**
     * 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
    {
        //  La connexion avec votre backoffice LCV a été perdue. Merci de recommencer l'association.
        return [            
            "ADDRESS_LABEL" => "Quel est votre numéro de licence (de la forme pl-pX-XXXX-XXX) ou l'adresse https:// de votre Polaris",
        ];
    }

    /**
     * Importe la configuration du backoffice via les API
     */
    protected function _pullEnvironment()
    {
        // Pour la suite, on va devoir tout charger, ça sera quand même bc plus rapide !
        if (!$this->config->hasBeenPreloaded())
            $this->config->preload();

        // Migration PolarisLink ?
        $polarislink_maps = null;

        // Si une table ps_polarislink_ref_category existe, on va tenter de récupérer les mappages
        try
        {
            // Si le module "polarislink" est installé
            if (\Module::isInstalled('polarislink'))
            {
                $db = \Db::getInstance();
                $results = $db->executeS("SELECT * FROM `" . _DB_PREFIX_ . "polarislink_categories_map`");
                if ($results !== false)
                {
                    $qm = [];
                    foreach ($results as $row)
                    {
                        if (!isset($qm[$row['id_category']]))
                            $qm[$row['id_category']] = [];
                        $qm[$row['id_category']][] = $row['id_category_l'];
                    }

                    // On charge la correspondance de mappings et on remplace les mappages de catégories dynamiques
                    $results = $db->executeS("SELECT * FROM `" . _DB_PREFIX_ . "polarislink_ref_category`");
                    if ($results !== false)
                    {
                        foreach ($results as $row)
                            $polarislink_maps[$row['id_polaris']] = isset($qm[$row['id_ps']]) ? $qm[$row['id_ps']] : [$row['id_ps']];
                    }

                    // Marques...
                    $results = $db->executeS("SELECT * FROM `" . _DB_PREFIX_ . "polarislink_ref_manufacturer`");
                    if ($results !== false)
                    {
                        foreach ($results as $row)
                            $polarislink_maps['m ' . $row['id_polaris']] = intval($row['id_ps']);
                    }
                }
            }
        }
        catch (\Exception $e)
        {
            // Rien à faire, on continue sans                        
        }

        // Charger la structure
        $structure = $this->getStructure();

        // Fixation du magasin web
        if ($this->config->get(SyncConfiguration::CFG_WEB_STORE_CODE) !== $structure->getMagasin()->getCode())
            $this->config->set(SyncConfiguration::CFG_WEB_STORE_CODE, $structure->getMagasin()->getCode());

        // Nom des catégories dynamiques
        $dyncat = $this->config->get(SyncConfiguration::CFG_DYN_CAT); 
        $dynattr = $this->config->get(SyncConfiguration::CFG_DYN_ATTR); 

        // Les catégories dynamiques
        foreach (['categorie1', 'categorie2', 'categorie3', 'categorie4'] as $cat)
        {
            $funcName = 'get' . ucfirst($cat);
            $catname = $structure->$funcName()->getNom();
            if (!isset($dyncat[$cat]) || $dyncat[$cat] != $catname)
                $this->config->setMap(SyncConfiguration::CFG_DYN_CAT, $cat, $catname);
        } 

        // Les attributs dynamiques
        foreach (['niveau1', 'niveau2', 'niveau3', 'niveau4'] as $attr)
        {
            $funcName = 'get' . ucfirst($attr);
            $attrname = $structure->$funcName()->getNom();
            if (!isset($dynattr[$cat]) || $dynattr[$cat] != $attrname)
             
            $this->config->setMap(SyncConfiguration::CFG_DYN_ATTR, $attr, $attrname);
        }

        $catalogApi = $this->getCatalogApi();

        $key_for_polarislinks = [
            'categorie1' => 'c1 ',
            'categorie2' => 'c2 ',
            'categorie3' => 'c3 ',
            'categorie4' => 'c4 ',
            'collection' => 'col ',
            'saison' => 'ssn ',
        ];

        // Tirage des valeurs des catégories dynamiques
        foreach ([  'Categories1' => 'categorie1', 
                    'Categories2' => 'categorie2',
                    'Categories3' => 'categorie3',
                    'Categories4' => 'categorie4',
                    'Collections' => 'collection',
                    'Saisons' => 'saison'
                    ] as $func => $cat)
        {
            $funcName = 'catalogGet' . ucfirst($func);
            $mapName = SyncConfiguration::CFG_MAP_DYN_CAT . $cat;

            $this->assureMapCatchallExists($mapName, "Tous les autres");

            // On tire les catégorie            
            $pager = null;
            do
            {
                $pageCategories = $catalogApi->$funcName(null, null, json_encode($pager));
                $pager = $pageCategories->getPagerNext();

                foreach ($pageCategories->getItems() as $category)
                {
                    $code = $category->getNo();
                    $name = $category->getNom();
                    if ($name === '')
                        $name = '-';
                
                    $mappage = 0;
                    if ($polarislink_maps !== null && isset($polarislink_maps[$key_for_polarislinks[$cat] . $category->getNo()]))
                        $mappage = $polarislink_maps[$key_for_polarislinks[$cat] . $category->getNo()];

                    if ($this->config->getMap($mapName, $code) === null)
                        $this->config->setMap($mapName, $code, $mappage);

                    if ($this->config->getMap($mapName."_name", $category->getNo()) != $name)
                        $this->config->setMap($mapName."_name", $category->getNo(), $name);
                }
            }
            while ($pager != null);
        }

        // Tirage RFS
        $mapName = SyncConfiguration::CFG_MAP_DYN_CAT . 'rfs';
        $this->assureMapCatchallExists($mapName, "Tous les autres");

        // On tire les RFS
        $pager = null;
        do
        {                
            $pageCategories = $catalogApi->catalogGetClassifications(null, null, null, null, json_encode($pager));
            $pager = $pageCategories->getPagerNext();                

            foreach ($pageCategories->getItems() as $category)
            {
                $code = $category->getCode();
                $name = $category->getCode() . ' - ' . $category->getNom();

                $mappage = 0;
                if ($polarislink_maps !== null)
                {
                    $niveau = 'c ';
                    if ($niveau && isset($polarislink_maps[$niveau . $category->getNo()]))
                        $mappage = $polarislink_maps[$niveau . $category->getNo()];
                }

                if ($this->config->getMap($mapName, $code) === null)
                    $this->config->setMap($mapName, $code, $mappage);

                if ($this->config->getMap($mapName."_name", $code) != $name)
                    $this->config->setMap($mapName."_name", $code, $name);
            }
        }
        while ($pager != null);

        // Charger les grilles de tailles
        // BUG POLARIS : on ne peux pas tirer les grilles de tailles, alors on va les chercher en live (voir code dans nextProducts)
        
        // On tire les marques
        $this->assureMapCatchallExists(SyncConfiguration::CFG_MAP_MANUFACTURER, "Toutes les autres");

        $pager = null;
        do
        {
            $pageMarques = $catalogApi->catalogGetMarques(null, null, json_encode($pager));
            $pager = $pageMarques->getPagerNext();

            foreach ($pageMarques->getItems() as $marque)
            {
                if ($this->config->getMap(SyncConfiguration::CFG_MAP_MANUFACTURER, $marque->getNo()) === null)
                {
                    $id_manufacturer = 0;
                    if ($polarislink_maps !== null && isset($polarislink_maps['m ' . $marque->getNo()]))
                        $id_manufacturer = $polarislink_maps['m ' . $marque->getNo()];

                    $this->config->setMap(SyncConfiguration::CFG_MAP_MANUFACTURER, $marque->getNo(), $id_manufacturer);
                }

                if ($this->config->getMap(SyncConfiguration::CFG_MAP_MANUFACTURER_NAME, $marque->getNo()) != $marque->getNom())
                    $this->config->setMap(SyncConfiguration::CFG_MAP_MANUFACTURER_NAME, $marque->getNo(), $marque->getNom());
            }
        }
        while ($pager != null);
    }

    /**
     * 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,
            SyncConfiguration::CFG_SUPPORT_PREPAID_CARDS,
        ];
    }

    /**
     * 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_HSCODE,
                SyncConfiguration::CFG_PRODUCT_SYNC_PHOTOS,
        ];
    }

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

    /**
     * Configuration par défaut de ce pont
     * 
     */
    public function alterDefaultCfg(array & $cfg)
    {
        $cfg[SyncConfiguration::CFG_DYN_CAT] = [
            'rfs' => 'RFS',
            'categorie1' => 'Catégorie 1',
            'categorie2' => 'Catégorie 2',
            'categorie3' => 'Catégorie 3',
            'categorie4' => 'Catégorie 4',            
            'collection' => 'Collection',
            'saison' => 'Saison', 
        ];

        $cfg[SyncConfiguration::CFG_DYN_ATTR] = [
            'niveau1' => 'Niveau 1',
            'niveau2' => 'Niveau 2',
            'niveau3' => 'Niveau 3',
            'niveau4' => 'Niveau 4',
        ];

        $cfg[SyncConfiguration::CFG_PR_ATTRIBUTES_MAPPING] = [
            SyncConfiguration::PRODUCT_ATTR_VARIANT => [
                'niveau1' => 0,
                'niveau2' => 0, // souvent la couleur
                'niveau3' => 0,
                'niveau4' => 0,
            ],
            SyncConfiguration::PRODUCT_ATTR_PRODUCT => [
                'niveau1' => true,
                'niveau2' => true, // souvent la couleur
                'niveau3' => true,
                'niveau4' => true,
            ],
            SyncConfiguration::PRODUCT_ATTR_FEATURE => [
                'collection' => 0, 
                'saison' => 0
            ],
        ];

        $cfg[SyncConfiguration::CFG_ORDER_PAY_MAPPAGE] = [
            '*' => 'CC',
            'Chèque' => 'CH',
            'Remise panier' => 'RE',
        ];

        // Importation avec stock seulement par défaut pour Polaris
        $cfg[SyncConfiguration::CFG_PRODUCT_IMPORT_ONLY_WITH_STOCK] = true;

        // L'environnement reste à tirer tant qu'on n'est pas associé
        $cfg[SyncConfiguration::CFG_ENV_PULLED] = false;
    }

    /**
     * Teste la connexion au backoffice avec les paramètres fournis
     */
    protected function onBind($args) : string|null
    {
        // Interressons nous à l'adresse qui est pour Polaris le numéro de licence
        // avec ce numéro, on va en déduire l'adresse de connexion
        
        if (!isset($args["address"]) || empty(trim($args["address"])))
            return "1 : le numéro de licence n'est pas défini !";

        $rawAddress = trim($args["address"]);

        // L'utilisateur peut saisir soit un numéro de licence (résolu en URL via le gestionnaire),
        // soit directement l'URL de son Polaris. On détecte le cas « URL » au schéma http(s) en tête de saisie.
        if (preg_match('#^https?://#i', $rawAddress))
        {
            // Saisie directe d'une URL : on l'utilise telle quelle, sans passer par le gestionnaire
            // de licence. Le HTTP non sécurisé est refusé : seul le HTTPS est accepté.
            if (!preg_match('#^https://#i', $rawAddress))
                return "2 : l'adresse de connexion à votre Polaris doit être sécurisée (https://).";

            // On retire un éventuel slash final : baseUrl (cf. setLogin) ajoute déjà « / ».
            $url = rtrim($rawAddress, '/');
        }
        else
        {
            // Comportement historique : on déduit l'URL de connexion à partir du numéro de licence.
            $licenceId = strtolower($rawAddress);

            // Le numéro de licence Polaris doit être de la forme "pl-pX-XXXX-XXX" où X est un chiffre ou "pl-vega-xxxx" où x est un chiffre, quelque soit le nombre de chiffres pour les deux formes
            if (!preg_match('/^pl-(p\d-)?\d+-\d+$/', $licenceId) && !preg_match('/^pl-vega-\d+$/', $licenceId))
                return "2 : le numéro de licence n'est pas valide !";

            // On peut retrouver l'URL de connexion à partir du numéro de licence en se connectant au gestionnaire de licence polaris
            // avec l'URL suivante : https://extranet.vega-info.fr/manager/api/Public/polaris/XX/Url où XX est le numéro de licence (url escapé)
            // Cet endpoint nous renvoi un JSON avec l'adresse de connexion dans la propriété "url"
            $urlManager = 'https://extranet.vega-info.fr/manager/api/Public/polaris/' . urlencode($licenceId) . '/Url';

            // On contacte le serveur de licence pour obtenir l'URL de connexion
            // Si le serveur répond une erreur 404, c'est que le numéro de licence n'est pas reconnu
            // Si le serveur répond une erreur 500, c'est qu'il s'est produit une erreur
            // Si le serveur ne répond pas, c'est qu'il est impossible de le contacter
            $context = stream_context_create([
                'http' => [
                    'ignore_errors' => true, // pour pouvoir lire le contenu même si c'est une erreur HTTP
                    'timeout' => 60 // borne l'attente pour ne pas pendre jusqu'à default_socket_timeout
                ]
            ]);

            $response = @file_get_contents($urlManager, false, $context);

            // Obtenir le code status http
            if ($response === false)
                return "3 : impossible de contacter le gestionnaire de licence Polaris. Merci de réessayer dans quelques instants. Si l'erreur se reproduit, merci de contacter le support technique de VEGA Informatique.";
            $response = json_decode($response);
            if (!isset($response))
                return "3 : le gestionnaire a répondu une erreur inconnue. Merci de contacter le support technique de VEGA Informatique.";

            if (!isset($response->url))
            {
                if (!isset($response->status) || $response->status != 404)
                    return "3 : le gestionnaire a répondu une erreur inconnue. Merci de contacter le support technique de VEGA Informatique.";
                return "3 : le numéro de licence indiqué n'est pas reconnu. Merci de contacter le support technique VEGA Informatique.";
            }
            $url = $response->url;
        }

        // On vérifie maintenant que le pincode est correct. Il est rangé sous password
        if (!isset($args["pincode"]) || empty(trim($args["pincode"])))
            return "30 : renseignez maintenant le pincode de jumelage. Il est disponible dans votre backoffice Polaris."; 

        // On vérifie maintenant que le pincode est correct.
        // On va se connecter à l'API Polaris en POST avec les informations fournies pour transformer notre pincode en clé d'accès
        // Si on obtient un 401, c'est que le pincode est incorrect. Il nous faut donc le code de réponse HTTP du serveur
        // Pour cela, on va plutôt utiliser curl en GET   
        $ch = curl_init($url . '/api/Core/App/Binding?pinCode=' . urlencode($args["pincode"]));
        curl_setopt($ch, CURLOPT_HTTPGET, true);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); // Permet de suivre les redirections
        curl_setopt($ch, CURLINFO_HEADER_OUT, true);
        // Timeouts courts : ce binding est un simple handshake (réponse en quelques secondes en
        // fonctionnement normal). Sans cela, si le Polaris local n'est pas joignable et que la socket
        // pend (ports filtrés, TLR éteint), curl bloque > 120s et le proxy devant PrestaShop renvoie une
        // erreur 524 au lieu de laisser remonter le message « 31 » ci-dessous. 30s reste largement sous
        // la fenêtre de timeout du proxy.
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30); // délai d'établissement de connexion
        curl_setopt($ch, CURLOPT_TIMEOUT, 60); // délai total de l'appel
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        // On récupère l'éventuelle erreur AVANT de fermer le handle (curl_error sur un handle fermé est vide)
        $errMsg = curl_error($ch);
        curl_close($ch);

        if ($httpCode == 0)
        {
            // Si le code HTTP est 0, c'est qu'il y a un problème de connexion (dont timeout)
            return "31 : impossible de contacter le backoffice Polaris sur (".$url.") :<br>".$errMsg.".<br>Vérifiez que vos ports sont ouverts sur votre box et que votre TLR est allumé !";
        }

        if ($httpCode == 401)
            return "32 : le pincode de jumelage est incorrect. Merci de vérifier le code fourni.";

        if ($httpCode != 200)
            return "33 : une erreur inconnue (".$httpCode.") est survenue. Merci de contacter le support technique de VEGA Informatique.";

        // Si on arrive ici, on a toutes les informations nécessaires
        $response = json_decode($response, true);

        // sauver la config
        $loginCfg = [
            'address' => $url,
            'key' => $response["Guid"] . '/' . $response["Secret"],
        ];
        $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->baseUrl . sprintf("api/Files/Media/Photo?noPhoto=%s&photoSize=original", $photo);
    }

    /**
     * Obtient le code magasin internet
     */
    public function getMagasinInternet() : string
    {
        $codeMagasin = $this->config->get(SyncConfiguration::CFG_WEB_STORE_CODE);
        if (!$codeMagasin)
        {
            $this->pullEnvironment();   
            $codeMagasin = $this->config->get(SyncConfiguration::CFG_WEB_STORE_CODE);
            if (!$codeMagasin)
                throw new \Exception('Erreur de configuration : le magasin web n\'est pas défini');
        }

        return $codeMagasin;
    }

    /**
     * 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
    {
        $catalogApi = $this->getCatalogApi();
        
        $products = [];

        // Données de base de l'API BUG Grilles de taille
        $this->sizeGrids = $this->config->get(SyncConfiguration::CFG_MAP_SIZEGRIDS);
        
        $codeMagasin = $this->getMagasinInternet();
        
        // Deux modes d'interogation :
        if ($productsRefsToSync)
        {
            // Mode référence
            // On extrait le no_modele (séparé par des pipes, c'est la première)
            $no_modele = explode('|', $productsRefsToSync)[0];
            // Nouveau filtre produit
            $filtreProduit = new \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\FiltreProduit();
            $filtreProduit->setNoModeles([$no_modele]);

            $pageProducts = $catalogApi->catalogGetProduits(json_encode($filtreProduit), null, null, null, 'true', 'true', 'true', 'true' );
        }
        else
        {
            // Mode pager            
            // Construction du pager
            $pager = new Pager();        
            $pager->setFrom($this->validateSyncProgress('products') ?? '');
            $pager->setNb(self::NB_PRODUCTS_AT_A_TIME);
            $pager->setSort('+DerniereModification, +No');
            
            // endless pager ?
            $pager = json_encode($pager); // bug OpenAPI            

            $pageProducts = $catalogApi->catalogGetProduits(null, null, $pager, null, 'true', 'true', 'true', 'true' );            
        }

        if ($pageProducts === null || $pageProducts->getItems() === null)
            throw new \Exception('Erreur API Polaris : impossible de récupérer les produits');

        foreach ($pageProducts->getItems() as $source)
        {
            // Construction du produit
            $product = new Product($source->getNo(), $source->getCode());
            $product->idProduct = $source->getNoModele();

            // Attributs du produit
            $product->productAttributes = [
                "categorie1" => new AttributeRef($source->getCategorie1()->getNo(), $source->getCategorie1()->getNom()),
                "categorie2" => new AttributeRef($source->getCategorie2()->getNo(), $source->getCategorie2()->getNom()),
                "categorie3" => new AttributeRef($source->getCategorie3()->getNo(), $source->getCategorie3()->getNom()),
                "categorie4" => new AttributeRef($source->getCategorie4()->getNo(), $source->getCategorie4()->getNom()),

                "niveau1" => new AttributeRef($source->getNiveau1()->getNo(), $source->getNiveau1()->getNom()),
                "niveau2" => new AttributeRef($source->getNiveau2()->getNo(), $source->getNiveau2()->getNom()),
                "niveau3" => new AttributeRef($source->getNiveau3()->getNo(), $source->getNiveau3()->getNom()),
                "niveau4" => new AttributeRef($source->getNiveau4()->getNo(), $source->getNiveau4()->getNom()),

                "collection" => new AttributeRef($source->getCollection()->getNo(), $source->getCollection()->getNom()),
                "saison" => new AttributeRef($source->getSaison()->getNo(), $source->getSaison()->getNom()),
                "rfs" => new AttributeRef($source->getClassification()->getCode(), $source->getClassification()->getNom()),
            ];

            // Grille de tailles
            $product->grilleTailles = $source->getGrilleTaille();

            // BUG API Polaris : on ne peux pas tirer les grilles de tailles, alors on va les chercher en live
            if (!array_key_exists($product->grilleTailles, $this->sizeGrids))
            {
                $this->sizeGrids = $this->config->get(SyncConfiguration::CFG_MAP_SIZEGRIDS);
                $this->sizeGrids[$product->grilleTailles] = 0;
                $this->config->set(SyncConfiguration::CFG_MAP_SIZEGRIDS, $this->sizeGrids);

                $sizeGridsName = $this->config->get(SyncConfiguration::CFG_MAP_SIZEGRIDS_NAME);
                $sizeGridsName[$product->grilleTailles] = $product->grilleTailles;
                $this->config->set(SyncConfiguration::CFG_MAP_SIZEGRIDS_NAME, $sizeGridsName);
            }

            // Taxe
            $product->taxrate = $source->getTauxTva();

            // Description
            $product->description = $this->cleanHtmlText($source->getDescription());

            // Le code douane
            $product->hscode = $source->getCodeTaric();
            $product->hscountry = $source->getCodePaysFabricationISOa2();

            // Les dimensions / poids
            $product->weight = $source->getPoids();
            $volume = $source->getVolume();
            $product->depth = $volume[0] ?? null;
            $product->width = $volume[1] ?? null;
            $product->height = $volume[2] ?? null;

            $product->ecotax = $source->getEcoParticipationHt();

            // Les déclinaisons (tailles)            
            foreach ($source->getTailles() as $taille)
            {
                $refs = $taille->getRefs();
                if (empty($refs)) continue;

                $article = new Article($refs[0], $taille->getTaille());

                if (!empty($taille->getEans()))
                    $article->ean13 = $taille->getEans()[0];

                // Prix TTC
                foreach ($taille->getMagasins() as $store)
                    if ($store->getCodeMagasin() == $codeMagasin)
                        $article->priceTTC = $store->getPrixNormalTtc();

                // Zonage
                $article->warehouseLocation = $taille->getZonage();

                $product->articles[] = $article;
            }
            
            // Nom du produit
            $product->name = $source->getLibelle();

            // Marque
            $product->manufacturer = new AttributeRef($source->getMarque()->getNo(), $source->getMarque()->getNom());

            // Photos
            $product->photos = $source->getPhotos() ?? [];

            // Stock
            foreach ($source->getTailles() as $taille)
                $product->stocks[] = $this->buildStockFromTaille($taille);                    

            $products[] = $product;
        }

        // Fini
        if (!$productsRefsToSync)   // Pas de dernière position si on a filtré !
            $this->markSyncProgressForValidation('products', $pageProducts->getEndlessPager()->getFrom());
        return $products;
    }

    /**
     * Construit un stock à partir d'une response ProduitDetailTaille
     * 
     * @param ProduitDetailTaille $taille Taille à convertir
     * @return Stock Stock construit
     */
    private function buildStockFromTaille(ProduitDetailTaille $taille) : Stock
    {
        $codeMagasin = $this->getMagasinInternet();

        $prixTTC = null;
        $qties = [];
        foreach ($taille->getMagasins() as $store)
        {
            $qties[$store->getCodeMagasin()] = $store->getDispo();

            // Si on est dans le magasin Internet, on met le prix à jour
            if ($store->getCodeMagasin() == $codeMagasin)
                $prixTTC = $store->getPrixNormalTtc();
        }
        
        $stock = new Stock($taille->getRefs(), $qties);

        // Ean ?
        $stock->eans = $taille->getEans();

        // Prix ?
        $stock->prixTTC = $prixTTC;
    
        return $stock;
    }

    /**
     * Obtient les stocks à synchroniser sous forme de tableau
     * 
     * @return array<Stock>
     */
    protected function & _pullStocks(): array
    {
        $stocks = [];
        
        // Construction du pager
        $pager = new Pager();        
        $pager->setFrom($this->validateSyncProgress('stocks') ?? '');
        $pager->setNb(self::NB_STOCKS_AT_A_TIME);
        $pager->setSort('+DerniereModification, +No');        
        
        // endless pager ?
        $pager = json_encode($pager); // bug OpenAPI        

        $pageStocks = $this->getStocksApi()->stocksGetStocks(null, null, $pager, null, 'false', 'false', 'false', 'false' );
        if ($pageStocks === null || $pageStocks->getItems() === null)
            throw new \Exception('Erreur API Polaris : impossible de récupérer les stocks');

        foreach ($pageStocks->getItems() as $source)
        {            
            foreach ($source->getTailles() as $taille)
                $stocks[] = $this->buildStockFromTaille($taille);
        }        

        // Fini
        $this->markSyncProgressForValidation('stocks', $pageStocks->getEndlessPager()->getFrom());
        return $stocks;
    }

    /**
     * Tire toutes les promotions depuis Polaris
     */
    private function pullDiscountPlans()
    {
        $this->discountsPlan = [];

        $pagerPlanPromos = null;
        do
        {
            $pagePlanPromos = $this->getStocksApi()->stocksGetPromotions(null, 'true', $pagerPlanPromos);
            if ($pagePlanPromos === null || $pagePlanPromos->getItems() === null)
                throw new \Exception('Erreur API Polaris : impossible de récupérer les plans de promotions');

            if (count($pagePlanPromos->getItems()) > 0)
            {
                foreach ($pagePlanPromos->getItems() as $planPromo)
                {
                    if (!$planPromo->getSurInternet())
                        continue; // Pas pour le web

                    $plan = new DiscountPlan($planPromo->getCode());
                    $plan->name = $planPromo->getNom();
                    $plan->dateStart = $planPromo->getDateDebut();
                    $plan->dateEnd = $planPromo->getDateFin();
                    
                    $this->discountsPlan[$plan->id] = $plan;
                }

                $pagerPlanPromos = $pagePlanPromos->getPagerNext();
            }
            else
                $pagerPlanPromos = null; // finito !
                
        } while ($pagerPlanPromos != null);
    }

    /**
     * Obtient les promotions à mettre à jour sous forme de tableau
     * 
     * @return array<DiscountPlan>
     */
    protected function & _pullDiscounts(): array
    {
        $discountsPlan = [];
        
        // Construction du pager
        $pager = new Pager();
        $pager->setFrom($this->validateSyncProgress('discounts') ?? '');
        $pager->setNb(self::NB_STOCKS_AT_A_TIME);
        $pager->setSort('+DerniereModification, +Code');        
        
        // endless pager ?
        $pager = json_encode($pager); // bug OpenAPI        

        $pagePromos = $this->getStocksApi()->stocksGetDetailsPromotions(null, 'false', null, null, $pager);
        if ($pagePromos === null || $pagePromos->getItems() === null)
            throw new \Exception('Erreur API Polaris : impossible de récupérer les promotions');

        foreach ($pagePromos->getItems() as $source)
        {
            // Ok, une promotion a été modifiée, chargeons les plans de promotions !
            if (!isset($this->discountsPlan))
                $this->pullDiscountPlans();

            if (count($discountsPlan) == 0)
            {
                $discountsPlan = $this->discountsPlan;  // On copie les plans de promotions dans une variable locale
                foreach ($discountsPlan as $plan)
                    $plan->discounts = [];  // réinitialisation des promotions
            }
            
            $promoCode = $source->getCodePromotion();
            if (!isset($discountsPlan[$promoCode]))
                continue; // On ne gère plus cette promo, elle va être supprimée dans l'étape d'au dessus

            $promo = new Discount([$source->getRefProduit()]);
            $promo->eans = [$source->getEan()];
            $promo->discount = $source->getP();

            $discountsPlan[$promoCode]->discounts[] = $promo;
        }        

        // Fini
        $this->markSyncProgressForValidation('discounts', $pagePromos->getEndlessPager()->getFrom());
        
        // Pas de promotion pour l'instant
        if (count($discountsPlan) == 0)
        {
            // C'est fini, on peut libérer l'espace mémoire
            // pris par les plans promo
            $this->discountsPlan = null;
        }

        return $discountsPlan;
    }

    /**
     * Obtient les cartes prépayées à synchroniser sous forme de tableau
     * 
     * @return array<PrepaidCard>
     */
    protected function & _pullPrepaidCards(): array
    {
        $results = [];

        // Construction du pager
        $pager = new Pager();        
        $pager->setFrom($this->validateSyncProgress('prepaid_cards') ?? '');
        $pager->setNb(self::NB_STOCKS_AT_A_TIME);
        $pager->setSort('+DerniereModification, +Code');        
        
        // endless pager ?
        $pager = json_encode($pager); // bug OpenAPI        

        $page = $this->getClientsApi()->clientsGetCartePrePayes(null, $pager);
        if ($page === null || $page->getItems() === null)
            throw new \Exception('Erreur API Polaris : impossible de récupérer les cartes prépayées');

        $now = new \DateTime();
        foreach ($page->getItems() as $source)
        {
            $card = new PrepaidCard(
                                $source->getCode(),
                            );

            $card->amount = $source->getMontant();
            $card->expirationDate = $source->getDateValidite();

            // La carte est active que si getQuarantaine() renvoi 0 et que la date de validité est dans le passé
            $card->active = !$source->getQuarantaine() && 
                            $source->getEstValide() && 
                            $source->getDateValidite() < $now;

            $results[] = $card;
        }        

        // Fini
        $this->markSyncProgressForValidation('prepaid_cards', $page->getEndlessPager()->getFrom());

        return $results;
    }

    /**
     * Obtient les promotions à mettre à jour sous forme de tableau
     * 
     * @return array<Voucher>
     */
    protected function & _pullVouchers(): array
    {        
        $vouchers = [];
                
        // Construction du pager
        $pager = new Pager();        
        $pager->setFrom($this->validateSyncProgress('vouchers') ?? '');
        $pager->setNb(self::NB_STOCKS_AT_A_TIME);
        $pager->setSort('+DerniereModification, +Code');        
        
        // endless pager ?
        $pager = json_encode($pager); // bug OpenAPI        

        $pageBons = $this->getClientsApi()->clientsGetBons(null, $pager);
        if ($pageBons === null || $pageBons->getItems() === null)
            throw new \Exception('Erreur API Polaris : impossible de récupérer les bons');

        foreach ($pageBons->getItems() as $source)
        {
            $bon = new Voucher(
                                $source->getCode(),
                                $source->getMontant() ?? 0,
                                $source->getPourcentage() ?? 0,
                            );

            // Nommage automatique
            $bon->name = $source->getLibelle();
            if (empty($bon->name))
            {
                switch ($source->getType())
                {
                    case \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\TTypeBon::CHEQUE_CADEAUX:
                        $bon->name = "Chèque cadeau";
                        break;
                    case \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\TTypeBon::BON_CADEAUX:
                        $bon->name = "Bon cadeau";
                        break;                    
                    case \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\TTypeBon::AVOIR:
                        $bon->name = "Avoir";
                        break;
                    case \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\TTypeBon::ARRHES:
                        $bon->name = "Arrhes";
                        break;
                    case \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\TTypeBon::ACOMPTE:
                        $bon->name = "Acompte";
                        break;            
                }
            }

            // Validité
            $bon->expirationDate = $source->getDateValidite();
            $bon->minimumAmount = $source->getSeuil();
            $bon->quantity = 1;
            if ($source->getAEteUtilise())
                $bon->quantity = 0;
            $bon->active = !$source->getQuarantaine();

            // Ces options que nous ne gérons pas !
            $bon->ignore = $source->getPlusCher();

            // Client ?
            $customer = $source->getClient();
            if ($customer && $customer->getNo() !== null && !empty($customer->getNo()))
            {
                $refsCustomer = $customer->getRefsExt() ?? null;
                if ($refsCustomer && count($refsCustomer))
                {
                    // On extrait la référence client PrestaShop, c'est le premier id-xxx dans extRefs
                    $refs = explode('-', $refsCustomer[count($refsCustomer)-1]);
                    // Parse $refsCustomer[0] en int, prend la dernière référence (la plus à jour !?)
                    $bon->customer_id = (int) $refs[0];
                }
                else
                {
                    $bon->customer_id = -1; /* client inexistant sur cette plateforme */
                }
            }

            switch ($source->getType())
            {
                case \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\TTypeBon::CHEQUE_CADEAUX:
                case \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\TTypeBon::BON_CADEAUX:                    
                    // Importation OK sous option
                    $bon->type = Voucher::GIFT_VOUCHER; // Cadeaux
                    break;                    
                case \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\TTypeBon::AVOIR:
                    // Importation OK sous option
                    $bon->type = Voucher::AVOIR_VOUCHER; // Avoir
                    break;
                case \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\TTypeBon::ARRHES:                
                case \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\TTypeBon::ACOMPTE:
                default:
                    // type de bon non importé
                    $bon->active = false;
                    break;
            }

            $vouchers[] = $bon;
        }        

        // Fini
        $this->markSyncProgressForValidation('vouchers', $pageBons->getEndlessPager()->getFrom());

        return $vouchers;
    }

    /**
     * Pousse l'annulation de 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 _pushRevertOrder(Order $order) : string|null
    {        
        $note = $order->revision ? 
                    sprintf("Annulation modif. Cde. Prestashop %s (rev. %d)", $order->reference, $order->revision) :
                    sprintf("Annulation commande Prestashop %s", $order->reference);

        $annulation = new \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\DemandeAnnulationDebit();
        $annulation->setRefExt($order->tag);    // Idempotence
        $annulation->setRefExtAnnulation($order->tag . '/R');    // Idempotence
        $annulation->setNote($note);

        // Puis on enregistre la vente dans Polaris
        try
        {
            $this->getVentesApi()->ventesDeleteVente($annulation);
        }
        catch (\PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\ApiException $e)
        {
            $msgBody = trim($e->getResponseBody());
            if (!$msgBody)
                $msgBody = $e->getMessage();
            // Si le message commence par un { et se fini par }, c'est un JSON
            if (substr($msgBody, 0, 1) == '{' && substr($msgBody, -1, 1) == '}')
            {
                $errMsg = json_decode($msgBody);
                if ($errMsg && isset($errMsg->Message))
                    throw new \Exception('Erreur API Polaris : impossible d\'annuler la vente : '. $errMsg->Message);
            }            
            
            throw new \Exception('Erreur API Polaris : impossible d\'annuler la vente : '. $msgBody);
        }

        return null;
    }

    /**
     * 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
    {
        $clientsApi = $this->getClientsApi();

        // On construit la vente
        $vente = new \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\MetierDebitWebConnectVenteUpdateable();
        $vente->setRefExt($order->tag);    // Idempotence
        $vente->setCodeCaisse(null);
        $vente->setCodeCaissier(null);
        $vente->setTypeVenteInternet('Site'); // 0 = Vente Internet
        $vente->setCodeMagasin($this->getMagasinInternet());
        
        if ($order->revision)
            $vente->setMemo(sprintf("Modif. Cde. Prestashop %s (rev. %d)", $order->reference, $order->revision));
        else
            $vente->setMemo(sprintf("Commande Prestashop %s", $order->reference));

        // 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')
            $vente->setMemo("Annulation " . strtolower($vente->getMemo()));
        
        // On ajoute les produits
        $products = [];
        foreach ($order->details as $orderDetail)
        {                
            $product = new \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\MetierDebitWebConnectDetailVente();

            // 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;
            $product->setSku($sku);
            $product->setQte($orderDetail->product_quantity);            
            $product->setMontantTtc($orderDetail->total_price_tax_incl);

            // Remise ?
            if ($orderDetail->unit_price_tax_excl < $orderDetail->original_product_price)
            {
                // Calcul de la remise
                $remise = ($orderDetail->original_product_price * $orderDetail->product_quantity * (100 + $orderDetail->tax_rate))/100.0 - $orderDetail->total_price_tax_incl;
                $product->setRemiseTtc(round($remise, 2));
                $product->setTypeRemise('Promo'); // 0 = Normal ou 3 = Remise article
            }
            else
                $product->setTypeRemise('Normal'); // 0 = Normal ou 3 = Remise article

            $product->setTauxTva($orderDetail->tax_rate);
            
            $products[] = $product; // Ajout du produit
        }
        $vente->setDetails($products);

        // On ajoute les paiements
        $paiements = [];
        foreach ($order->payments as $payment)
        {
            // paiement ?            
            $pay_code = $this->mapPayment($payment->method);
            if (!$pay_code || empty($pay_code))
                return sprintf("le mode de paiement '%s' n'est pas mappé", $payment->method);   // Commande ignorée !

            $sens = 1;
            /*switch ($payment->method)
            {
                case 'Remise panier':
                    $sens = -1;
                    break;
            }*/            
                
            $paiement = new \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\MetierDebitWebConnectReglement();
            $paiement->setCode($pay_code);   // Carte de crédit par défaut            
            $paiement->setMontant($payment->amount * $sens);
            $paiement->setTransactionId($payment->transaction_id);

            $paiements[] = $paiement;
        } 
        
        // Gestion des remises paniers
        $totalRemises = 0;
        $bons = [];
        foreach ($order->vouchers as $voucher)
        {
            // Nous allons vérifier si ce voucher existe sur Polaris.
            // Si oui, alors on l'ajoute aux vouchers, sinon, il sera traité comme une remise caisse
            try
            {
                // Même pas la peine de vérifier si le bon n'est pas un EAN13 (entièrement numérique)
                $totalRemises += $voucher->amount;
                if (!is_numeric($voucher->code))
                    continue;

                $polarisVoucher = $clientsApi->clientsGetBon($voucher->code);
                if ($polarisVoucher)
                {                            
                    $bon_utilise = new \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\MetierDebitBonUtilise();
                    $bon_utilise->setCode($voucher->code);
                    $bon_utilise->setMontant($voucher->amount);
                    $bons[] = $bon_utilise;                            

                    $totalRemises -= $voucher->amount;
                }
            }
            catch (\PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\ApiException $e)
            {                        
                if ($e->getCode() != 404)
                    throw $e; // Erreur inattendue
            }
        }
       
        // Les remises paniers non gérées comme des bons ...
        if ($totalRemises)
        {
            // Remise panier
            $code_remise = $this->mapPayment('Remise panier', false);
            if (!$code_remise || empty($code_remise))
                return sprintf("le mode de paiement '%s' n'est pas mappé", "Remise panier");   // Commande ignorée !

            $paiement = new \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\MetierDebitWebConnectReglement();
            $paiement->setCode($code_remise);   // Carte de crédit par défaut            
            $paiement->setMontant($totalRemises);
    
            $paiements[] = $paiement;
        }
        
        // Bons !
        if (count($bons) > 0)
            $vente->setUtiliserBonsV2($bons);        

        // Légalement, la date de la vente est la date du premier paiement !
        $vente->setDateVente($order->date);        

        if ($this->config->get(SyncConfiguration::CFG_EXPORT_CUSTOMERS) && $this->isCustomerValidForExport($order->id_customer))
        {
            $psCustomer = new \Customer($order->id_customer);
            if ($psCustomer->id)
            {
                $client = new \PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\Model\MetierClientWebConnectClientMinimal();
                $client->setRefsExt([Customer::getCustomerUid($psCustomer)]);
                $client->setMail($psCustomer->email);
                if ($psCustomer->company)
                    $client->setRaisonSociale($psCustomer->company);
                $client->setNom($psCustomer->lastname);
                $client->setPrenom($psCustomer->firstname);

                $vente->setClient($client);
            }            
        }

        // Paiements
        $vente->setReglements($paiements);
        
        // Puis on enregistre la vente dans Polaris
        try
        {
            $this->getVentesApi()->ventesPostVente($vente, 'false');
        }
        catch (\PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\ApiException $e)
        {
            $msgBody = trim($e->getResponseBody());
            if (!$msgBody)
                $msgBody = $e->getMessage();
            // Si le message commence par un { et se fini par }, c'est un JSON
            if (substr($msgBody, 0, 1) == '{' && substr($msgBody, -1, 1) == '}')
            {
                $errMsg = json_decode($msgBody);
                if ($errMsg && isset($errMsg->Message))
                    throw new \Exception('Erreur API Polaris : impossible de créer la vente : '. $errMsg->Message);
            }            
            
            throw new \Exception('Erreur API Polaris : impossible de créer la vente : '. $msgBody);
        }

        return null;
    }

    /**
     * Obtient les clients à synchroniser sous forme de tableau
     * 
     * @return array<Customer>
     */
    protected function & _pullCustomers(): array
    {
        $customers = [];        
        
        // Construction du pager
        $pager = new Pager();        
        $pager->setFrom($this->validateSyncProgress('customers') ?? '');

        $pager->setNb(self::NB_CUSTOMERS_AT_A_TIME);
        $pager->setSort('+DerniereModification, +No');        
        
        // endless pager ?
        $pager = json_encode($pager); // bug OpenAPI        

        $filtre = new MetierClientWebConnectFiltreClient();
        $filtre->setExEmail(true); // Exclure les clients sans email

        $pageClients = $this->getClientsApi()->clientsGetClients(json_encode($filtre), $pager);
        if ($pageClients === null || $pageClients->getItems() === null)
            throw new \Exception('Erreur API Polaris : impossible de récupérer les clients');

        foreach ($pageClients->getItems() as $source)
        {
            $id = $source->getRefsExt();
            if ($id && count($id))
                $id = $id[count($id)-1]; // On prend le dernier ID
            else
                $id = null;            

            $customer = new Customer($id, $source->getMail());
            if ($source->getRaisonSociale())
                $customer->company = $source->getRaisonSociale();
            $customer->firstname = $source->getPrenom();
            $customer->lastname = $source->getNom();
            $customer->civilite = $source->getIdent();
            $customer->newsletter = in_array(self::CONST_DIFF_MAILING, $source->getNoListeDiffusions());
            $customer->delete = $source->getQuarantaine();

            $customers[] = $customer;
        }        

        // Fini
        $this->markSyncProgressForValidation('customers', $pageClients->getEndlessPager()->getFrom());

        return $customers;
    }

    /**
     * Supporte la référence client externe pour faire le lien entre les clients PrestaShop et Polaris
     */
    public function supportCustomersRefExt(): bool
    {
        return true;
    }

    /**
     * Pousse le client indiqué vers le backoffice
     * 
     * @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
    {        
        $client = new MetierClientWebConnectClientUpdateable();
        $client->setMail($psCustomer->email);
        if ($psCustomer->company)
                    $client->setRaisonSociale($psCustomer->company);
        $client->setNom($psCustomer->lastname);
        $client->setPrenom($psCustomer->firstname);
        $client->setRefsExt([Customer::getCustomerUid($psCustomer)]); // Idempotence
        $civilite = $this->getGenderFromPsGenderId($psCustomer->id_gender);
        if ($civilite)
            $client->setIdent($civilite); 

        if ($psCustomer->newsletter)
            $client->setAjouterAListeDiffusions([self::CONST_DIFF_MAILING]);
        else
            $client->setRetirerDeListeDiffusions([self::CONST_DIFF_MAILING]);

        // L'adresse principale
        $psAddress = $this->getCustomerInvoiceAddress($psCustomer);
        if ($psAddress)
        {            
            $address = new MetierClientWebConnectAdresse();
            $address->setNom(empty($psAddress->alias) ? "Adresse principale" : $psAddress->alias);
            if (!empty($psAddress->company))
                $address->setRaisonSociale($psAddress->company);
            $address->setAdresse1($psAddress->address1 ?? '');
            $address->setAdresse2($psAddress->address2 ?? '');
            $address->setCodePostal($psAddress->postcode ?? '');
            $address->setVille($psAddress->city ?? '');
            $address->setCodePays($psAddress->country ?? '');
            
            // On ajoute l'adresse
            $client->setAdresse($address);            
        }

        // On envoi le client via l'API
        try
        {            
            $result = $this->getClientsApi()->clientsPostClient($client);
            if ($result === null)
                return "Erreur API Polaris : impossible de créer le client";  
        }
        catch (\PrestaShop\Module\PolarisPrestaConnector\pol\api\lib\ApiException $e)
        {
            $msgBody = trim($e->getResponseBody());
            if (!$msgBody)
                $msgBody = $e->getMessage();
            
            // Si le message commence par un { et se fini par }, c'est un JSON
            if (substr($msgBody, 0, 1) == '{' && substr($msgBody, -1, 1) == '}')
            {
                $errMsg = json_decode($msgBody);
                if ($errMsg && isset($errMsg->Message))
                    throw new \Exception('Erreur API Polaris : impossible de créer/modifier le client : '. $errMsg->Message);
            }            
            
            throw new \Exception('Erreur API Polaris : impossible de créer/modifier le client : '. $msgBody);
        }

        return null;
    }

    /**
     * Obtient les produits à nettoyer
     *      
     * @return array<string> Références des produits à nettoyer
     */
    public function & pullCleanedProducts(): array
    {
        $catalogApi = $this->getCatalogApi();

        $clean_list = parent::pullCleanedProducts();
        
        if (version_compare($this->polarisVersion, '14.0.0') < 0)
            return $clean_list; // Version de l'API bugguée avant la version 14.0.0
        
        // Construction du pager
        $pager = new Pager();        
        $pager->setFrom($this->validateSyncProgress('products_clean') ?? '');
        $pager->setNb(self::NB_STOCKS_AT_A_TIME);
        $pager->setSort('+DerniereModification, +Code');
        
        // endless pager ?
        $pager = json_encode($pager); // bug OpenAPI            

        $pageInvalidProducts = $catalogApi->catalogGetProduitsInvalides($pager);

        if ($pageInvalidProducts === null || $pageInvalidProducts->getItems() === null)
            throw new \Exception('Erreur API Polaris : impossible de récupérer les produits périmés');

        foreach ($pageInvalidProducts->getItems() as $source)
        {
            // Construction des références
            $clean_list[] = $source->getCode();
        }

        // Fini
        $this->markSyncProgressForValidation('products_clean', $pageInvalidProducts->getEndlessPager()->getFrom());

        return $clean_list;
    }    
}
