<?php
namespace PrestaShop\Module\PolarisPrestaConnector;

use PolarisPrestaConnector;
use PrestaShop\Module\PolarisPrestaConnector\SyncProgress;
use Throwable;

/**
 * Configuration du module
 */
class Syncer
{
    /**
     * Module
     */
    public PolarisPrestaConnector $module;

    /**
     * Indique si nous sommes en mode CRON
     */
    private $isCron = false;

    /**
     * Pont vers le backoffice
     */
    public BridgeAdapter $bridge;

    /**
     * Lien vers le fichier d'audit
     */
    private mixed $auditFileHandler = null;

    /**
     * Cache de références fournisseur
     * null quand pas encore chargé
     * 
     * @var SupplierRefManager
     */
    private ?SupplierRefManager $refMngr = null;

    /**
     * Instanciation
     * 
     * @param PolarisPrestaConnector $module Module
     * @param bool $isCron Indique si nous sommes en mode CRON
     */
    public function __construct(PolarisPrestaConnector $module, $isCron = false)
    {
        // Déclenche la mise à jour de la base si besoin
        $module->dbUpgrade();

        $this->module = $module;        
        $this->bridge = $module->getBridge();
        $this->isCron = $isCron;        
    }

    /**
     * Obtient le nom du verrou
     * 
     */
    public function getLockName() : string
    {
        return 'syncer-' . $this->module->name . '-' . str_replace('.', '_', $_SERVER['HTTP_HOST']);
    }

    /**
     * Obtient le nom du verrou
     * 
     */
    public static function constructLockName(\PolarisPrestaConnector $module) : string
    {
        return 'syncer-' . $module->name . '-' . str_replace('.', '_', $_SERVER['HTTP_HOST']);
    }

    /**
     * Essaye de prendre le verrou de traitement ou lève une exception
     * 
     * @param bool $throwException Lève une exception si le verrou est déjà pris
     * @return bool Vrai si le verrou est pris, faux sinon
     */
    private function lock(bool $throwException = true) : bool
    {
        $db = \Db::getInstance();

        $result = $db->getValue('SELECT GET_LOCK(\'' . $this->getLockName() . '\', 0)');
        if ($result === false)
            throw new \Exception("Impossible de prendre le verrou de synchronisation : ".$db->getMsgError());
        
        // On a pris le verrou ?
        if (!$result)
        {
            // le verrou est déjà pris, on ne fait rien
            if ($throwException)                
                throw new \Exception("Une synchronisation est déjà en cours !");
            return false;
        }

        return true;    // verrou pris !
    }

    /**
     * Déverrouille le traitement
     */
    private function unlock()
    {
        $db = \Db::getInstance();
        // on libère le verrou
        $lockName = $this->getLockName();
        // et si on y arrive pas, on le mentionne dans le journal de Prestashop
        if (!$db->getValue('SELECT RELEASE_LOCK(\'' . $lockName . '\')'))
            \PrestaShopLogger::addLog('Impossible de libérer le verrou ' . $lockName . ' : ' . $db->getMsgError(), 3);
    }


    /**
     * Obtient le gestionnaire de références fournisseur
     * 
     * @return SupplierRefManager
     */
    public function getReferencesManager() : SupplierRefManager
    {
        if (is_null($this->refMngr))
            $this->refMngr = new SupplierRefManager($this->module->getCfg()->get(SyncConfiguration::CFG_SUPPLIER));
        return $this->refMngr;
    }

    /**
     * Resynchronise un seul produit particulier
     * 
     * @param int $id_product Identifiant du produit
     */
    public function syncProduct($id_product)
    {
        $cfg = $this->module->getCfg();

        // On précharge certaines données de configuration que nous allons qui nous seront utiles par la suite
        $cfg->preload([
            SyncConfiguration::CFG_SUPPLIER,
            SyncConfiguration::CFG_ACTIVE,
            SyncConfiguration::CFG_WORKMODE,
        ]);

        // Si le module n'est pas actif : pas de synchro !
        if (!$cfg->get(SyncConfiguration::CFG_ACTIVE))
            throw new \Exception('La synchronisation désactivée.');
         
        // on va passer par un verrou pour éviter de lancer plusieurs fois le traitement        
        /*
            Pour une synchro produit unique, on ne bloque pas tout le traitement
            if (!$this->lock())
            {
                // le verrou est déjà pris, on ne fait rien
                throw new \Exception("Une syncronisation est déjà en cours !");
            }
        */

        try
        {
            $sync_progress = new SyncProgress($this, $cfg);
            $sync_progress->startStep('import_products', 'Synchronisation du produit #' . $id_product, "%d produit synchronisé");

            // On va chercher les références fournisseurs
            $refMngr = new SupplierRefManager($cfg->get(SyncConfiguration::CFG_SUPPLIER), [$id_product]);
            $productRef = $refMngr->translateProductId($id_product);
            if (!$productRef)
                throw new \Exception("Produit non géré par le backoffice " . $this->bridge->getId());
            
            $syncerProducts = new SyncerProducts($this, $sync_progress);
            $syncerProducts->syncProducts(
                $this->module->getBridge()->pullProducts($productRef)
            );

            $sync_progress->endStep('import_products');
        }
        finally
        {
            // on libère le verrou
            /* $this->unlock(); */
        }
    }

    /**
     * Exporte une commande particulière
     * 
     * @param string $order_reference Référence de la commande
     */
    public function exportOrder(string $order_reference)
    {
        $cfg = $this->module->getCfg();

        // On précharge certaines données de configuration que nous allons qui nous seront utiles par la suite
        $cfg->preload([
            SyncConfiguration::CFG_SUPPLIER,
            SyncConfiguration::CFG_ACTIVE,
            SyncConfiguration::CFG_VOUCHER_IMPORT_TYPES,
            SyncConfiguration::CFG_EXPORT_CUSTOMERS,   
            SyncConfiguration::CFG_ORDER_ERROR_EXPORT_STATE,
            SyncConfiguration::CFG_ORDER_EXPORT_STATE,
            SyncConfiguration::CFG_ORDER_PAY_MAPPAGE,
            SyncConfiguration::CFG_ORDER_BARRIER,
            SyncConfiguration::CFG_ORDER_MAX_TIME,
        ]);

        // Si le module n'est pas actif : pas de synchro !
        if (!$cfg->get(SyncConfiguration::CFG_ACTIVE))
            throw new \Exception('La synchronisation désactivée.');
 
        /*// on va passer par un verrou pour éviter de lancer plusieurs fois le traitement
        if (!$this->lock())
        {
            // le verrou est déjà pris, on ne fait rien
            throw new \Exception("Une syncronisation est déjà en cours !");
        }*/

        try
        {
            $sync_progress = new SyncProgress($this, $cfg);
            $sync_progress->startStep('export_orders', 'Synchronisation de la commande #' . $order_reference, "%d commande synchronisée");

            $syncerOrders = new SyncerOrders($this, $sync_progress); 
            $syncerOrders->exportOrder(
                $order_reference
            );

            $sync_progress->endStep('export_orders');
        }
        finally
        {
            // on libère le verrou
            /*$this->unlock();*/
        }
    }
    
    /**
     * Obtient la progression de la synchronisation
     */
    public static function getSyncProgress(\PolarisPrestaConnector $module) : SyncProgress
    {
        // On vérifie si le verrou est positionné
        $db = \Db::getInstance();        

        $result = $db->getValue('SELECT IS_USED_LOCK(\'' . self::constructLockName($module) . '\')');
        if ($result === false)
            throw new \Exception("Impossible de vérifier le verrou de synchronisation : ".$db->getMsgError());            

        $status = SyncProgress::fromArray($module->getCfg()->get(SyncConfiguration::CFG_SYNC_STATE));
        $status->setRunning($result ?? false);

        return $status;
    }

    /**
     * Renvoi vrai s'il est temps de procéder à une nouvelle synchronisation
     */
    private function isTimeIssued(string $cfg_interval_key) : bool
    {
        $cfg = $this->module->getCfg();
        $key = str_replace('_interval', '', $cfg_interval_key);

        $last_syncs_date = $cfg->get(SyncConfiguration::CFG_SYNCS_LAST);
        if (!isset($last_syncs_date[$key]) || empty($last_syncs_date[$key]))
            return true; // on force la synchro
        
        $last = new \Datetime($last_syncs_date[$key]);

        switch ($cfg->get($cfg_interval_key) ?? '')
        {
            case '10min':
                // On doit synchroniser toutes les 10 minutes
                return ($last->getTimestamp() + 600) < time();
            case '20min':
                // On doit synchroniser toutes les 20 minutes
                return ($last->getTimestamp() + 1200) < time();
            case '30min':
                // On doit synchroniser toutes les 30 minutes
                return ($last->getTimestamp() + 1800) < time();
            case '1h':
                // On doit synchroniser toutes les heures
                return ($last->getTimestamp() + 3600) < time();
            case '2h':
                // On doit synchroniser toutes les 2 heures
                return ($last->getTimestamp() + 7200) < time();
            case '4h':
                // On doit synchroniser toutes les 2 heures
                return ($last->getTimestamp() + 14400) < time();
            case 'daily':
                // On doit synchroniser à chaque fois que la date change
                return $last->format('Y-m-d') != (new \DateTime())->format('Y-m-d');
            case 'weekly':  
                // On doit synchroniser tous les lundi matins
                return $last->format('W') != (new \DateTime())->format('W');
            case 'bi-weekly':
                // On doit synchroniser tous les 15 jours
                return $last->format('W') != (new \DateTime())->format('W') && 
                        $last->format('W') != ((new \DateTime())->format('W')+1);
            case 'monthly':
                // On doit synchroniser tous les 1er du mois
                return $last->format('m') != (new \DateTime())->format('m');

            default:
                // Instant !
                return true;
        }
    }

    /**
     * Marquer le temps de la dernière synchronisation
     */
    private function markIssued(string $cfg_interval_key)
    {
        $key = str_replace('_interval', '', $cfg_interval_key);

        $cfg = $this->module->getCfg();
        $last = $cfg->get(SyncConfiguration::CFG_SYNCS_LAST);
        $last[$key] = (new \DateTime())->format('c');
        $cfg->set(SyncConfiguration::CFG_SYNCS_LAST, $last);
    }    

    /**
     * Synchronise les données
     * 
     * @param bool $forceFullSync Force une synchronisation totale
     * 
     * @return string|null Null si la synchronisation s'est déclenchée, raison si ce n'est pas le cas
     */
    public function sync(bool $forceFullSync = false) : string|null
    {       
        // On va passer par un verrou pour éviter de lancer plusieurs fois le traitement
        if (!$this->lock(false)) {
            // le verrou est déjà pris, on ne fait rien
            echo "Traitement déjà en cours !";
            return 'ALREADY_RUNNING';
        } 

        set_time_limit(4 * 3600); // 4h de temps d'exécution maximum de traitement cumulé pour la synchronisation
        \ObjectModel::disableCache(); // on désactive le cache des objets pour éviter les problèmes de mémoire

        try
        {
            // Configuration
            $cfg = $this->module->getCfg();

            if ($forceFullSync)
            {
                $this->bridge->resetSyncPosition();
                // On marque toutes les synchronisations comme jamais effectuées
                $cfg->set(SyncConfiguration::CFG_SYNCS_LAST, []);
            }

            // Mise à jour de l'état
            $sync_progress = new SyncProgress($this, $cfg);
            $sync_progress->init();

            // Etape préliminaire : tâches quotidiennes !
            // Toutes les 24h, on regarde s'il n'y a pas une mise à jour du module
            $lastCheck = \Configuration::get($this->module->name . '_last_update_check');
            if (!$lastCheck || (time() - $lastCheck) > 86400)
            {                
                $sync_progress->startStep('update', 'Vérification des mises à jour...', 'Mises à jour vérifiées');
                $cfg->set(SyncConfiguration::CFG_SYNC_STATE, $sync_progress->toArray());

                // On enregistre la date de la dernière vérification
                \Configuration::updateValue($this->module->name . '_last_update_check', time());

                $updater = new AutoUpdater($this->module);
                $updater->searchUpdate();

                $sync_progress->endStep('update');
            }

            // Si le module n'est pas actif : pas de synchro !
            if (!$cfg->get(SyncConfiguration::CFG_ACTIVE))
            {
                $this->audit('[WRN] La synchronisation désactivée.');
                return 'DISABLED';
            }

            // preload cfg
            $cfg->preload([
                SyncConfiguration::CFG_ACTIVE,               
                SyncConfiguration::CFG_WORKMODE,
                SyncConfiguration::CFG_SUPPLIER, 
                SyncConfiguration::CFG_SYNC_STATE,
                SyncConfiguration::CFG_SYNC_POSITIONS,
                SyncConfiguration::CFG_VOUCHER_IMPORT_TYPES,
                SyncConfiguration::CFG_IMPORT_CUSTOMERS,
                SyncConfiguration::CFG_EXPORT_CUSTOMERS,

                SyncConfiguration::CFG_SYNCS_LAST,
                SyncConfiguration::CFG_SYNC_PRODUCT_INTERVAL,
                SyncConfiguration::CFG_SYNC_STOCK_INTERVAL,
                SyncConfiguration::CFG_SYNC_FULL_PRODUCT_INTERVAL,
                SyncConfiguration::CFG_SYNC_FULL_STOCK_INTERVAL,
            ]);
            
            // Ensuite, on regarde si dans la configuration, nous avons l'identifiant du fournisseur pour ce module
            // Sinon autocréation
            $this->module->getOrCreateSupplierId();

            // On regarde si le fichier discounts.

            // Fin d'initialisation
            $sync_progress->endStep('init');
            
            // Etape 1 : les clients ?
            if (
                $cfg->get(SyncConfiguration::CFG_IMPORT_CUSTOMERS) ||
                $cfg->get(SyncConfiguration::CFG_EXPORT_CUSTOMERS))
            {                
                $syncerCustomers = new SyncerCustomers($this, $sync_progress);
                $syncerCustomers->sync();
            }            
            
            // Etape 2 : on transmet les commandes            
            $syncerOrders = new SyncerOrders($this, $sync_progress);
            $syncerOrders->sync();
            
            // Etape 3 : on aspire le catalogue si workmode 1
            if ($cfg->get(SyncConfiguration::CFG_WORKMODE) == 1)
            {                
                if ($this->isTimeIssued(SyncConfiguration::CFG_SYNC_PRODUCT_INTERVAL))
                {
                    if ($this->isTimeIssued(SyncConfiguration::CFG_SYNC_FULL_PRODUCT_INTERVAL))
                    {
                        // reset des positions
                        $this->bridge->resetSyncProductPosition();
                        $this->markIssued(SyncConfiguration::CFG_SYNC_FULL_PRODUCT_INTERVAL);
                    }

                    $syncerProducts = new SyncerProducts($this, $sync_progress);
                    $syncerProducts->sync();

                    // La dernière synchronisation de produits est terminée !
                    $this->markIssued(SyncConfiguration::CFG_SYNC_PRODUCT_INTERVAL);
                }
            }

            if ($this->isTimeIssued(SyncConfiguration::CFG_SYNC_STOCK_INTERVAL))
            {
                // Full ?
                if ($this->isTimeIssued(SyncConfiguration::CFG_SYNC_FULL_STOCK_INTERVAL))
                {
                    // reset des positions
                    $this->bridge->resetSyncStockPosition();
                    $this->markIssued(SyncConfiguration::CFG_SYNC_FULL_STOCK_INTERVAL);
                }

                // Etape 4 : on aspire les stocks et les prix
                $syncerStocks = new SyncerStocks($this, $sync_progress);
                $syncerStocks->sync();

                // Etape 5 : les promotions ?
                $syncerDiscounts = new SyncerDiscounts($this, $sync_progress);
                $syncerDiscounts->sync();

                // Etape 6 : les bons de réduction ?
                // Seulement si on importe les bons cadeaux ou les avoirs
                $voucherTypes = [];
                foreach ($cfg->get(SyncConfiguration::CFG_VOUCHER_IMPORT_TYPES) ?? [] as $type => $actif)
                    if ($actif)
                        $voucherTypes[$type] = true;
                if (count($voucherTypes) > 0)
                {
                    $syncerVouchers = new SyncerVouchers($this, $sync_progress);
                    $syncerVouchers->sync();
                }

                // Etape 7 : les cartes prépayées ?
                if ($cfg->get(SyncConfiguration::CFG_SUPPORT_PREPAID_CARDS))
                {
                    $syncerPrepaidCards = new SyncerPrepaidCards($this, $sync_progress);
                    $syncerPrepaidCards->sync();
                }

                // La dernière synchronisation de stocks est terminée !
                $this->markIssued(SyncConfiguration::CFG_SYNC_STOCK_INTERVAL);
            }

            // Ménage une fois par semaine            
            $lastCheck = \Configuration::get($this->module->name . '_last_cleanup');
            if (!$lastCheck || (time() - $lastCheck) > 86400 * 7)
            {                
                $sync_progress->startStep('cleanup', 'Nettoyage hebdomadaire', 'Nettoyage effectué');
                $cfg->set(SyncConfiguration::CFG_SYNC_STATE, $sync_progress->toArray());

                $this->cleanup();

                // On enregistre la date de la dernière vérification
                \Configuration::updateValue($this->module->name . '_last_cleanup', time());                

                $sync_progress->endStep('cleanup');
            }

            // Fin !            
            $this->audit('[INF] Synchronisation terminée avec succès');
        }
        catch (Throwable $e)  // Catched at higher level
        {
            // Journalisation de l'erreur
            \PrestaShopLogger::addLog('Erreur à la synchronisation : '.$e->getMessage(), 3, null, $this->module->name); 
            $this->audit('[FATAL] ' . $e->getMessage());

            $sync_progress->end($e->getMessage());            

            throw $e; // on remonte l'exception
        }
        finally        
        {
            // On marque la fin de la synchro
            $sync_progress->end();
            
            // on libère le verrou
            $this->unlock();            
        }

        return null;
    }

    /**
     * Procédure de nettoyage hebdomadaire
     */
    private function cleanup()
    {
        $db = \Db::getInstance();
        $cfg = $this->module->getCfg();

        /**
         * ------------------------------------------------------------------------------------------------------------
         * DEBUT : Nettoyage des produits supprimés
         * ------------------------------------------------------------------------------------------------------------
         */
        // On nettoie les modèles supprimés des tables de lockage ...
        // Pour cela, on va chercher tous les id_product de la base de données
        $results = $db->executeS('SELECT id_product FROM ' . _DB_PREFIX_ . 'product');
        if ($results === false)
            throw new \Exception('Impossible de récupérer les produits : '.$db->getMsgError());

        $ids = [];
        foreach ($results as $result)
            $ids[$result['id_product']] = $result['id_product'];

        $locked = $cfg->get(SyncConfiguration::CFG_LOCKED_PRODUCT);

        // On charge la table des produits verrouillés. SyncConfiguration::CFG_LOCKED_PRODUCT contient un tableau avec la liste des id_product verrouillés
        // On retire de $locked tous les produits qui n'existent pas dans $ids
        $locked_products = $cfg->get(SyncConfiguration::CFG_LOCKED_PRODUCT);
        if ($locked_products && is_array($locked_products))
        {
            foreach (array_keys($cfg->get(SyncConfiguration::CFG_LOCKED_PRODUCT)) as $id_product)
            {
                if (!isset($ids[$id_product]))
                    unset($locked[$id_product]);
            }
        }

        // On met à jour la configuration
        $cfg->set(SyncConfiguration::CFG_LOCKED_PRODUCT, $locked);
        /**
         * ------------------------------------------------------------------------------------------------------------
         * FIN : Nettoyage des produits supprimés
         * ------------------------------------------------------------------------------------------------------------
         */

        /**
         * ------------------------------------------------------------------------------------------------------------
         * DEBUT : Suppression des attributs non utilisés
         * ------------------------------------------------------------------------------------------------------------
         */
        if ($cfg->get(SyncConfiguration::CFG_ATTR_AUTO_CLEAN))  // Si autoclean
        {
            // Listons les id des groupes d'attributs utilisés
            $groups = [];
            
            foreach ([$cfg->get(SyncConfiguration::CFG_MAP_SIZEGRIDS), $cfg->get(SyncConfiguration::CFG_PR_ATTRIBUTES_MAPPING)["2"], ] as &$data)
            if ($data)
                foreach ($data as $noGroup)
                    if ($noGroup > 0)
                        $groups[(int) $noGroup] = (int) $noGroup;

            // On va donc écrire la requête de purge des données
            $results = $db->executeS(sprintf("SELECT id_attribute FROM " . _DB_PREFIX_ . "attribute WHERE id_attribute_group IN (%s) AND id_attribute NOT IN (SELECT id_attribute FROM " . _DB_PREFIX_ . "product_attribute_combination)",
                implode(',', $groups)));
            if ($results === false)
                throw new \Exception('Impossible de purger les attributs non utilisés : '.$db->getMsgError());            

            // Suppression des attributs non utilisés
            foreach ($results as $row)
            {
                $id_attribute = (int) $row['id_attribute'];
                $attr = new \ProductAttribute($id_attribute, \Context::getContext()->language->id);                
                if ($attr->id == $id_attribute)
                {
                    // On supprime l'attribut
                    $attr->delete();
                    // On log dans l'audit
                    $this->audit(sprintf('[INF] Suppression de l\'attribut non utilisé : %s (%d)', $attr->name, $attr->id));
                }
            }
        }        
        /**
         * ------------------------------------------------------------------------------------------------------------
         * FIN : Attributs non utilisés
         * ------------------------------------------------------------------------------------------------------------
         */
    }

    /**
     * Audit des actions
     * 
     * @param string $message Message à journaliser     
     */
    public function audit(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->auditFileHandler)
        {
            // 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/audits/'.$this->module->name;
            if (!is_dir($path))
                mkdir($path, 0777, true);

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

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

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

        // Si nous sommes en mode DEBUG, alors on sort le message sur la sortie standard si nous sommes un tâche CRON
        if ($this->isCron)
            echo $currentDate . ' - ' . $message . "\n"; 
    }    
}