<?php
namespace PrestaShop\Module\PolarisPrestaConnector;

use PolarisPrestaConnector;
use PrestaShop\Module\PolarisPrestaConnector\Models\AttributeRef;
use PrestaShop\Module\PolarisPrestaConnector\Models\Order;
use PrestaShop\Module\PolarisPrestaConnector\Models\Product;
use PrestaShop\Module\PolarisPrestaConnector\Models\PrepaidCard;
use PrestaShop\Module\PolarisPrestaConnector\Models\Voucher;
use PrestaShop\Module\PolarisPrestaConnector\Models\Stock;
use PrestaShop\Module\PolarisPrestaConnector\Models\DiscountPlan;
use PrestaShop\Module\PolarisPrestaConnector\Models\Customer;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;

/**
 * Interface de pont
 */
abstract class BridgeAdapter
{
    /**
     * Mapping pour les principales Marketplace reconnues
     */
    public array $MarketplaceMailMap = [
        'Amazon' => '@marketplace.amazon', 
        'Miinto' => '@miinto.com',
        'Spartoo' => '@spartoo', 
        'Ebay' => '@ebay', 
        'Cdiscount' => '@cdiscount', 
        'FNAC' => '@fnac', 
        'Rakuten' => '@rakuten', 
        'PriceMinister' => '@priceminister', 
        'LeBonCoin' => '@leboncoin', 
        'Vinted' => '@vinted', 
        'Wish' => '@wish', 
        'AliExpress' => '@aliexpress'
    ];    
    /**
     * Configuration du module
     */
    protected SyncConfiguration $config;

    /**
     * Connecteur parent
     */
    protected PolarisPrestaConnector $module;

    /**
     * Mode debug
     */
    protected bool $debugMode = false;

    /**
     * Liste des genres installés sur le système
     */
    private array|null $genders = null;

    /**
     * Supporte les stocks lors du tirage produit
     */
    public bool $SupportStockOnProducts = false;

    /**
     * Lien vers le fichier de debug
     */
    private mixed $debugFileHandler = null;

    /**
     * Constructeur
     * 
     * @param PolarisPrestaConnector $module Module parent
     */
    public function __construct(PolarisPrestaConnector $module)
    {
        $this->module = $module;
        $this->config = $this->module->getCfg();

                // Debug ?
        $this->debugMode = isset($_REQUEST["debug"]) && $_REQUEST["debug"] == "1";
    }

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

    /**
     * Renvoi les spécificités du pont sous forme de tableau associatif clé/valeur de type string
     * 
     * @return array<string,string>
     */
    public abstract function getSpecifics() : array;

    /**
     * DEBUG des actions
     * 
     * @param string $message Message à journaliser     
     */
    public function debug(string $message)
    {
        if (!$message)
            return; // rien à faire 

        // ON prend la date avec information gmt
        $currentDate = date('c');

        // Journalisation de l'audit, séparé, dans un fichier à part
        // le fichier est il ouvert ?
        if (!$this->debugFileHandler)
        {
            // sinon, on l'ouvre en mode ajout dans la racine /var/audits
            // si le répertoire n'existe pas, on le crée            
            $path = _PS_ROOT_DIR_ . '/var/logs/'.$this->module->name;
            if (!is_dir($path))
                mkdir($path, 0777, true);

            // On supprime les fichiers d'audit qui ont plus de 2 jours
            $files = scandir($path);
            foreach ($files as $file)
            {
                if (is_file($path . '/' . $file) && (time() - filemtime($path . '/' . $file)) > 2 * 86400)
                    unlink($path . '/' . $file);
            }

            // Le fichier est nommé debug-AAAAMMJJHHmmSS.log
            $this->debugFileHandler = fopen($path . '/debug-' . date('Ymd') . '.log', 'a');
            if (!$this->debugFileHandler)
                throw new \Exception('Impossible d\'ouvrir le fichier de debug');            
        
            fwrite($this->debugFileHandler, $currentDate . ' -- Nouvelle synchronisation'."\n");
        }

        // On écrit le message dans le fichier
        fwrite($this->debugFileHandler, $currentDate . ' - ' . $message . "\n");
    }

    /**
     * Teste la connexion au backoffice avec les paramètres fournis
     * Doit renvoyer vide si pas d'erreur
     * 
     * @param mixed $args Paramètres de connexion au backoffice
     */
    protected abstract function onBind($args) : string|null;

    /**
     * Teste la connexion au backoffice avec les paramètres fournis.
     * Renvoi null quand la connexion a été effectuée correctement,
     * autrement, renvoi l'erreur.
     * 
     * @param mixed $args Paramètres de connexion au backoffice
     */
    public function bind($args) : string|null
    {
        try
        {
            $errorMsg = $this->onBind($args);
            if ($errorMsg)
                return $errorMsg;

            // On ne tire PAS l'environnement ici : c'est potentiellement long (appels API)
            // et cela bloquerait la requête de connexion. On marque simplement l'environnement
            // comme « à tirer » : il sera récupéré en arrière-plan après l'association, et au
            // plus tard garanti au démarrage de la première synchronisation (cf. Syncer::sync()).
            $this->config->set(SyncConfiguration::CFG_ENV_PULLED, false);
        }
        catch (\Exception $e)
        {
            // En cas d'erreur, on enregistre l'erreur dans les paramètres du login
            return "99" . " : " . $e->getMessage();
        }

        return null;
    }

    /**
     * Renvoi vrai si le pont est capable de synchroniser un seul produit à la fois
     * 
     * @return bool
     */
    public function canSyncOneProductOnly() : bool
    {
        return true;
    }

    /**
     * Configuration par défaut de ce pont
     * 
     */
    public abstract function alterDefaultCfg(array & $cfg);

    /**
     * Ajout de la configuration de la page Catalogue de ce pont
     * 
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function hookAdminForm(FormBuilderInterface $builder, array & $options) : void
    {
    }

    /**
     * Ajout de la configuration de la page Catalogue de ce pont
     */
    public function hookSaveAdminForm(FormInterface $builder) : void
    {
    }

    /**
     * Obtient vrai si le pont supporte une option spécifique
     * 
     * @param string $optionName nom de l'option
     * 
     * @return bool vrai si l'option est supportée, faux autrement
     */
    public function hasSupportFor(string $optionName) : bool
    {
        $supportedOptionalFeatures = $this->getSupportedOptionalFeatures();
        return in_array($optionName, $supportedOptionalFeatures);
    }

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

    /**
     * Retourne la liste exhaustive des options de produits supportées par ce pont
     * 
     * @return array<string> Liste des options de produits supportés     
     */
    public function getSupportedProductOptions() : array
    {
        return [];
    }

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

    /**
     * Obtient les produits à synchroniser sous forme de tableau
     * 
     * @param string $productsRefsToSync Références des produits à synchroniser ou null pour tous
     * @return array<Product>
     */
    protected abstract function & _pullProducts(?string $productsRefsToSync = null): array;

    /**
     * Obtient les stocks à synchroniser sous forme de tableau
     * 
     * @return array<Stock>
     */
    protected abstract function & _pullStocks(): array;    

    /**
     * Obtient les détails promotions à mettre à jour sous forme de tableau
     * 
     * @return array<DiscountPlan>
     */
    protected abstract function & _pullDiscounts(): array;

    /**
     * Obtient les bons à synchroniser sous forme de tableau
     * 
     * @return array<Voucher>
     */
    protected abstract function & _pullVouchers(): array;

    /**
     * Supporte les références clients externes
     * 
     * @return bool True si le pont supporte les références clients externes, false autrement
     */
    public abstract function supportCustomersRefExt(): bool;

    /**
     * Obtient les clients à synchroniser sous forme de tableau
     * 
     * @return array<Customer>
     */
    protected abstract function & _pullCustomers(): array;

    /**
     * Importe la configuration du backoffice via les API
     * 
     */
    protected abstract function _pullEnvironment();

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

    /**
     * Importe la configuration du backoffice via les API
     * 
     * @param bool $clean Nettoie les mappages avant la synchronisation de l'environnement
     */
    public function pullEnvironment(bool $clean = false)
    {
        // Si on doit nettoyer, on le fait maintenant
        if ($clean)
        {
            // Map => Names
            $map_names = [                
                SyncConfiguration::CFG_MAP_MANUFACTURER => SyncConfiguration::CFG_MAP_MANUFACTURER_NAME, 
                SyncConfiguration::CFG_MAP_SIZEGRIDS => SyncConfiguration::CFG_MAP_SIZEGRIDS_NAME, 
                SyncConfiguration::CFG_MAP_STORES => SyncConfiguration::CFG_MAP_STORES_NAME
            ];

            // Ensuite, on nettoie toutes les maps dynamiques
            foreach (array_keys($this->config->get(SyncConfiguration::CFG_DYN_CAT)) as $map)
                $map_names[SyncConfiguration::CFG_MAP_DYN_CAT . $map] = SyncConfiguration::CFG_MAP_DYN_CAT . $map . '_name';

            // Catégories dynamiques
            foreach ($map_names as $cfg => $names)
            {                
                $map_cleaned = $this->config->get($cfg);
                if ($map_cleaned)
                {
                    // On va parcourir les clés du tableau. A chaque fois que la valeur de l'entrée est vide
                    // on supprime la clé
                    foreach (array_keys($map_cleaned) as $key)
                    {
                        if ($key != '*' && empty($map_cleaned[$key]))  // On supprime les clés vides, sauf le wildcard
                            unset($map_cleaned[$key]);
                    }

                    // Et on sauvegarde le résultat
                    $this->config->set($cfg, $map_cleaned);
                }

                // On s'occupe des noms, s'il y a des noms
                if ($names)
                {
                    $names_cleaned = $this->config->get($names);
                    if ($names_cleaned)
                    {
                        // Pour chaque clé, on vérifie s'il existe encore dans la map après nettoyage.
                        // Si c'est pas le cas, on supprime le libellé
                        foreach (array_keys($names_cleaned) as $key)
                        {
                            if (!isset($map_cleaned[$key]))  // On supprime les références fantômes
                                unset($names_cleaned[$key]);
                        }

                        // Et on sauvegarde le résultat
                        $this->config->set($names, $names_cleaned);
                    }
                }
            }
        }

        // On tire l'environnement
        $this->_pullEnvironment();

        // L'environnement a été tiré avec succès (une exception aurait court-circuité ce marquage).
        $this->config->set(SyncConfiguration::CFG_ENV_PULLED, true);
    }

    /**
     * S'assure que le catchall existe dans la $map
     * 
     * @param string $mapName
     * @param string $name
     */
    protected function assureMapCatchallExists(string $mapName, string $name)
    {
        $map = $this->config->get($mapName);
        if (!$map)
        {
            // On crée la map vide
            $map = [ "*" => 0 ];
            $this->config->set($mapName, $map);
        }
        else if (!isset($map["*"]))
            $this->config->setMap($mapName, $map, 0);

        // Le nom
        $map = $this->config->get($mapName . "_name");
        if (!$map)
        {
            // On crée la map vide
            $map = [ "*" => $name ];
            $this->config->set($mapName . "_name", $map);
        }
        else if (!isset($map["*"]))
            $this->config->setMap($mapName . "_name", $map, $name);
    }

    /**
     * Obtient l'URL d'une photo par sa clé
     */
    public abstract function getPhotoURL(string $photo): string;

    /**
     * Réinitialise la synchronisation
     * (synchro totale)
     */
    public function resetSyncPosition()
    {
        $empty = [];
        // NOTE : ON NE RESYNCHRONISE JAMAIS LES EXPORTATIONS CLIENTS CAR TROP DANGEREUX
        // pour le faire, il faut faire un update dans la base obligatoirement !!!
        // $this->config->set(SyncConfiguration::SYNC_EXPORT_CUSTOMER, NULL);
        
        $this->config->set(SyncConfiguration::CFG_SYNC_POSITIONS, $empty);        
    }

    /**
     * Réinitialise la synchronisation des stocks
     * (synchro totale)
     */
    public function resetSyncStockPosition()
    {
        $positions = $this->config->get(SyncConfiguration::CFG_SYNC_POSITIONS);
        $positions['stocks'] = null;
        $positions['discounts'] = null;
        $this->config->set(SyncConfiguration::CFG_SYNC_POSITIONS, $positions);
    }

    /**
     * Réinitialise la synchronisation des produits
     * (synchro totale)
     */
    public function resetSyncProductPosition()
    {
        $positions = $this->config->get(SyncConfiguration::CFG_SYNC_POSITIONS);
        $positions['products'] = null;        
        $positions['stocks'] = null;
        $this->config->set(SyncConfiguration::CFG_SYNC_POSITIONS, $positions);
    }

    /**
     * Obtient le mappage des genres
     */
    protected function getGenders() : array
    {
        if (!isset($this->genders))
        {
            // Chargement
            /** @var \Gender $gender */     
            foreach (\Gender::getGenders()->getAll() as $gender)
            {
                $this->genders[$gender->id] = $gender;    
                $this->genders[strtoupper($gender->name)] = $gender;    
            }
        }
        return $this->genders;
    }

    /**
     * Obtient le genre prestashop associé à celui du backoffice
     * 
     * @param string|null $gender Genre du backoffice
     */
    public function getPsGenderFromGender(string|null $gender) : \Gender|null
    {
        if (!$gender)
            return null;

        $genders = $this->getGenders();
        return isset($genders[$gender]) ? $genders[$gender] : null;
    }

    /**
     * Obtient le genre du backoffice associé à celui de prestashop
     */
    public function getGenderFromPsGenderId(int $genderId) : string|null
    {     
        if (!$genderId)
            return null;
        
        $genders = $this->getGenders();
        return isset($genders[$genderId]) ? $genders[$genderId]->name : null;
    }

    /**
     * Obtient la position de synchronisation pour un type de données précisé
     * 
     * @param string $type Type de données
     * @return mixed Position de synchronisation
     */
    protected function getSyncPosition(string $type) : mixed
    {
        $pos = $this->config->get(SyncConfiguration::CFG_SYNC_POSITIONS);
        if (!isset($pos))
            $pos = [];
        if (!isset($pos[$type]))
            return null;
        return $pos[$type];
    }

    /**
     * Obtient la version minimale supportée du backoffice
     */
    abstract public function getMinBackofficeVersion() : string;    

    /**
     * Est-ce que le backoffice lié est dans une version obsolète ou non compatible 
     * avec cette version du module ? (ex: pas de support du multistore, ou pas de support des catégories méta, etc.)
     */
    public function isOutdatedBackoffice() : bool
    {
        if (version_compare($this->config->get(SyncConfiguration::CFG_BACKOFFICE_VERSION), $this->getMinBackofficeVersion(), '<'))
            return true;

        return false;
    }

    /**
     * Obtient la position de synchronisation pour un type de données précisé
     * 
     * @param string $type Type de données
     * @return mixed Position de synchronisation
     */
    protected function setSyncPosition(string $type, mixed $position)
    {
        $pos = $this->config->get(SyncConfiguration::CFG_SYNC_POSITIONS);
        if (!isset($pos))
            $pos = [];
        $pos[$type] = $position;        
        $this->config->set(SyncConfiguration::CFG_SYNC_POSITIONS, $pos);
    }

    /**
     * Progression des synchronisations, par type
     */
    private array $SyncProgress = [];

    /**
     * Valide la progression de la synchronisation du type défini et la renvoi
     */
    protected function validateSyncProgress(string $type) : string|null
    {
        if (isset($this->SyncProgress[$type]) && $this->SyncProgress[$type] !== null)
        {            
            $this->setSyncPosition($type, $this->SyncProgress[$type]);
            $this->SyncProgress[$type] = null;            
        }
        
        return $this->getSyncPosition($type);
    }

    /**
     * Déclare une progression
     */
    protected function markSyncProgressForValidation(string $type, string $position, bool $saveNow = false)
    {        
        if ($saveNow)
        {
            $this->setSyncPosition($type, $position);
            $this->SyncProgress[$type] = null;  // reset
        }
        else
            $this->SyncProgress[$type] = $position;
    }

    /**
     * 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 abstract function _pushOrder(Order $order) : string|null; 
    
    /**
     * 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
    {
        // On clone la commande pour en réaliser l'exact opposé !
        $order = unserialize(serialize($order));
        $order->tag .= '/R';

        // On inverse les paiements
        foreach ($order->payments as $payment)
        {
            $payment->transaction_id = "";  // Pas d'ID de transaction ici 
            $payment->amount = -$payment->amount;
        }

        // On inverse les bons
        foreach ($order->vouchers as $voucher)
            $voucher->amount = -$voucher->amount;

        // On inverse les quantités
        foreach ($order->details as $detail)
        {
            $detail->product_quantity = -$detail->product_quantity;
            $detail->total_price_tax_incl = -$detail->total_price_tax_incl;            
            $detail->unit_price_tax_excl = -$detail->unit_price_tax_excl;
            $detail->original_product_price = -$detail->original_product_price;            
        }

        // Et on pousse la commande !
        return $this->_pushOrder($order);
    }

    /**
     * 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 abstract function _pushCustomer(\Customer $psCustomer) : string|null;    

    /**
     * Mappe le mode de paiement PrestaShop vers le backoffice
     * 
     * @param string $paymentMethod Mode de paiement PrestaShop
     * @param bool $catchAll Si vrai, on utilise le mode de paiement par défaut si non trouvé (par défaut)
     * @return string Mode de paiement backoffice
     */
    protected function mapPayment(string $paymentMethod, bool $catchAll = true) : string|null
    {
        // paiement ?
        $pay_map = $this->config->get(SyncConfiguration::CFG_ORDER_PAY_MAPPAGE);
        if (!isset($pay_map))
        {
            $pay_map = ['*' => ''];
            $this->config->set(SyncConfiguration::CFG_ORDER_PAY_MAPPAGE, $pay_map);
        }

        $pay_code = null;
        if (!isset($pay_map[$paymentMethod]))
        {
            // On ajoute la clé manquante à la configuration
            $this->config->setMap(SyncConfiguration::CFG_ORDER_PAY_MAPPAGE, $paymentMethod, '');
        }
        else
            $pay_code = $pay_map[$paymentMethod];

        // Toujours rien ? Catch-all ?
        if (!$pay_code && isset($pay_map["*"]) && $catchAll)
            $pay_code = $pay_map["*"];

        return $pay_code;
    }

    /**
     * Définit le mappage d'un mode de paiement
     * 
     * @param string $paymentMethod Mode de paiement PrestaShop
     * @param string $reference Référence du mode de paiement backoffice
     * @param bool $onlyIfEmpty Si vrai, on ne remplace pas le mappage existant
     */
    protected function setMapPayment(string $paymentMethod, string $reference, bool $onlyIfEmpty)
    {
        // paiement ?
        $pay_map = $this->config->get(SyncConfiguration::CFG_ORDER_PAY_MAPPAGE);
        if (!isset($pay_map))
        {
            $pay_map = ['*' => ''];
            $this->config->set(SyncConfiguration::CFG_ORDER_PAY_MAPPAGE, $pay_map);
        }

        if (!$onlyIfEmpty || !isset($pay_map[$paymentMethod]) || empty($pay_map[$paymentMethod]))
        {
            // On ajoute la clé manquante à la configuration
            $this->config->setMap(SyncConfiguration::CFG_ORDER_PAY_MAPPAGE, $paymentMethod, $reference);
        }
    }

    /**
     * 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
     */
    public function pushOrder(Order $order) : string|null
    {
        // On fait les premières vérifications
        if (!$order)
            return "Commande vide !";   // Rien à faire ?
        
        // Si tout est bon, on fait appel au sous-système
        return $this->_pushOrder($order);
    }

    /**
     * Pousse une annulation de commande vers le backoffice
     * 
     * @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
     */
    public function pushRevertOrder(Order $order) : string|null
    {
        // On fait les premières vérifications
        if (!$order)
            return "Commande vide !";   // Rien à faire ?

        // Si tout est bon, on fait appel au sous-système
        return $this->_pushRevertOrder($order);
    }

    /**
     * Détecte si le client est associé à un marketplace par son mail.
     * 
     * @param string $email email du client
     * @return string|null nom de la marketplace si détectée
     */
    public function detectMarketplace(string $email) : string|null
    {
        // On utilise le module pour détecter le marketplace        
        foreach ($this->MarketplaceMailMap as $name => $domain)
            if (strpos($email, $domain) !== false)
                return $name;

        return null;    // Pas de marketplace trouvée !
    }

    /**
     * Retourne vrai si le client est valide pour l'export
     */
    public function isCustomerValidForExport(\Customer|int|null $customer) : bool
    {
        if (!$customer)
            return false;

        if (!is_object($customer))
        {
            $customer = new \Customer($customer);
            if (!$customer->id)
                return false;
        }
        
        // On vérifie que ce n'est pas un compte de marketplace, auquel cas, on ne veut pas l'importer sous Polaris !
        if ($this->detectMarketplace($customer->email) !== null)
            return false;

        return !$customer->is_guest;
    }

    /**
     * Obtient les produits à synchroniser sous forme de tableau
     * 
     * @param string $productsRefsToSync Références des produits à synchroniser ou null pour tous
     * @return array<Product>
     */
    public function & pullProducts(?string $productsRefsToSync = null): array
    {
        $products = $this->_pullProducts($productsRefsToSync);

        $metaCats = $this->config->get(SyncConfiguration::CFG_META_CATEGORIES);

        foreach ($products as $product)
        {
            $product->sanitize();

            // Calcul des méta-catégories ici !!
            if ($metaCats)
            {
                // S'il y a des catégories méta, on va les calculer ici pour chaque produit
                foreach ($metaCats as $metaCatId => $catComposition)
                {
                    // On ne calcule que la clé composite (ex. "02,05") : le libellé n'est plus
                    // matérialisé ici, il est reconstruit à l'affichage à partir des catégories
                    // composantes (cf. CategoriesManager::buildMetaCategoryLabels).
                    $key = '';
                    $idx = 0;
                    foreach ($catComposition as $catCode)
                    {
                        if ($idx++ == 0) continue;  // la première entrée est le nom de la méta-catégorie, on la skip

                        switch ($catCode)
                        {
                            case '__manufacturer':
                                // Traitement spécifique pour la catégorie "marque"
                                $key .= ($product->manufacturer->id ?? '') . ',';
                                break;
                            default:
                                // Check the value of the category in the product's categories. If it exists, we add the code to the key, otherwise we add an empty string
                                $key .= ($product->productAttributes[$catCode]?->id ?? '') . ',';
                                break;
                        }
                    }

                    if ($key)
                    {
                        $key = rtrim($key, ', ');  // On retire la virgule finale

                        // Enregistrement dans le produit (le nom n'est plus stocké : on réutilise la clé)
                        $product->productAttributes[$metaCatId] = new AttributeRef($key, "");
                    }
                }
            }
        }

        return $products;
    }

    /**
     * Obtient les cartes prépayées à synchroniser sous forme de tableau
     * 
     * @return array<PrepaidCard>
     */
    public function & pullPrepaidCards(): array
    {
        $cards = $this->_pullPrepaidCards();
        foreach ($cards as $card)
            $card->sanitize();
        return $cards;
    }

    /**
     * Obtient les produits à nettoyer
     *      
     * @return array<string> Références des produits à nettoyer
     */
    public function & pullCleanedProducts(): array
    {
        $clean_list = [];        
        return $clean_list;
    }

    /**
     * Obtient les stocks à synchroniser sous forme de tableau
     * 
     * @return array<Stock>
     */
    public function & pullStocks(): array
    {
        $stocks = $this->_pullStocks();
        foreach ($stocks as $stock)
            $stock->sanitize();
        return $stocks;
    }

    /**
     * Obtient les détails promotions à mettre à jour sous forme de tableau
     * 
     * @return array<DiscountPlan>
     */
    public function & pullDiscounts(): array
    {
        $discounts = $this->_pullDiscounts();
        foreach ($discounts as $discount)
            $discount->sanitize();
        return $discounts;
    }

    /**
     * Obtient les bons à synchroniser sous forme de tableau
     * 
     * @return array<Voucher>
     */
    public function & pullVouchers(): array
    {
        $vouchers = $this->_pullVouchers();
        foreach ($vouchers as $voucher)
            $voucher->sanitize();
        return $vouchers;
    }

    /**
     * Obtient les clients à synchroniser sous forme de tableau
     * 
     * @return array<Customer>
     */
    public function & pullCustomers(): array
    {
        $customers = $this->_pullCustomers();
        foreach ($customers as $customer)
            $customer->sanitize();
        return $customers;
    }

    /**
     * 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
     */
    public function pushCustomer(\Customer $psCustomer) : string|null
    {
        // On fait les premières vérifications
        if ($psCustomer == null)
            return false;   // Rien à faire ?

        // Si tout est bon, on fait appel au sous-système
        return $this->_pushCustomer($psCustomer);
    }

    /**
     * Obtient l'adresse de facturation par défaut du client.
     * 
     * L'adresse de facturation d'un client est la dernière adresse de facturation avec laquelle le client a commandé, ou null si aucune.
     *      
     * 
     * @param \Customer $customer Client
     * @return \Address|null Adresse de facturation par défaut du client ou null si aucune adresse trouvée
     */
    protected function getCustomerInvoiceAddress(\Customer $customer) : \Address|null
    {
        $db = \Db::getInstance();
        $results = $db->executeS(sprintf('SELECT o.`id_address_invoice`, c.`iso_code` FROM `' . _DB_PREFIX_ . 'orders` o LEFT JOIN `' . _DB_PREFIX_ . 'address` a ON (a.`id_address` = o.`id_address_invoice`) LEFT JOIN `' . _DB_PREFIX_ . 'country` c ON (c.`id_country` = a.`id_country`) WHERE o.id_customer = %d ORDER BY o.date_add DESC LIMIT 1', (int)$customer->id));
        if ($results === false)
            throw new \Exception('Impossible de récupérer l\'adresse de facturation par défaut du client : ' . $db->getMsgError());
        
        $psAddress = null;
        if (count($results) > 0)
        {
            $psAddress = new \Address($results[0]['id_address_invoice']);
            if ($psAddress->id)
            {                
                // Remplaçons le pays par son code ISO
                $psAddress->country = strtolower($results[0]['iso_code']);
            }                
        }        

        return $psAddress;
    }

    /**
     * Nettoie le texte HTML d'une description
     * 
     * @param string $txt Le texte à nettoyer
     * @return string Le texte nettoyé
     */
    public function cleanHtmlText(?string $txt) : string
    {
        if (!$txt) return ''; // shortcut null, vide, 0

        // Nettoyage de la description

        // On ne convertit que si le texte n'est pas déjà de l'UTF-8 valide : on suppose alors
        // qu'il est en iso-latin (cas LCV) et on le traduit en UTF8.
        // Note : mb_detect_encoding(..., 'ISO-8859-1', true) était inutilisable ici car TOUS les
        // octets sont valides en ISO-8859-1, la conversion était donc appliquée même à de l'UTF-8
        // déjà correct (cas Polaris), produisant un double encodage (« é » → « Ã© », « € » → « â¬ »).
        if (!mb_check_encoding($txt, 'UTF-8'))
            $txt = mb_convert_encoding($txt, 'UTF-8', 'ISO-8859-1');

        // TODO : URL vers site extérieur ?

        // Erreurs classiques
        $txt = str_replace("\\r", "", 
                str_replace("\\n", "\n", $txt));

        return $txt;
    }
}
