<?php
namespace PrestaShop\Module\LCVPrestaConnector;

use Exception;
use StockAvailable;
use PrestaShop\Module\LCVPrestaConnector\Models\Stock;
use PrestaShop\Module\LCVPrestaConnector\Models\Article;

/**
 * Synchronisation des stocks
 */
class SyncerStocks
{
    /**
     * Synchroniseur
     * 
     */
    private Syncer $syncer;
    /**
     * Indicateur de progression
     */
    private SyncProgress $progress;

    /**
     * Constructeur
     * 
     * @param Syncer $syncer Synchroniseur
     * @param SyncProgress|null $progress Indicateur de progression
     */
    public function __construct(Syncer $syncer, SyncProgress|null $progress = null)
    {
        $this->syncer = $syncer;
        $this->progress = $progress ?? new SyncProgress($syncer);
    }

    /**
     * Synchronisation des stocks
     */
    public function sync()
    {
        $bridge = $this->syncer->bridge;

        $startTime = microtime(true);
        $nbRefsStocks = 0;

        $this->progress->startStep("import_stocks", "Synchronisation des stocks et des prix...", "%d stocks et prix synchronisés");
        try
        {
          // On récupère les stocks depuis le pont, en boucle
            while ($stocks = $bridge->pullStocks())
                $nbRefsStocks += $this->syncStocks($stocks);

            $stopTime = microtime(true);
            if ($nbRefsStocks > 0)
                $this->syncer->audit(sprintf("[INF] Synchronisation des stocks de %d références effectuée en %.2f secondes", $nbRefsStocks, $stopTime - $startTime));    
        }
        catch (Exception $e)
        {
            $this->syncer->audit(sprintf(
                    "[ERR] Erreur lors de la synchronisation des stocks : %s\n  dans %s ligne %d\nStack trace:\n%s",
                    $e->getMessage(),
                    $e->getFile(),
                    $e->getLine(),
                    $e->getTraceAsString()
                ));
            //$this->syncer->audit(sprintf("[ERR] Erreur lors de la synchronisation des stocks : %s", $e->getMessage()));
            $this->progress->error('import_stocks', $e->getMessage());
        }
        finally
        {
            $this->progress->endStep("import_stocks", $nbRefsStocks);
        }
    }

    /**
     * Synchronisation des stocks précisés
     * 
     * @param array<Stock> $stocks Stock à synchroniser
     * @return int Nombre de stocks synchronisés 
     */
    public function syncStocks(array & $stocks) : int
    {
        // Collecte et nettoyage mémoire
        gc_collect_cycles();

        $cfg = $this->syncer->module->getCfg();

        // Identifions les références concernées
        $refMngr = $this->syncer->getReferencesManager();

        $noStockVisibility = $cfg->get(SyncConfiguration::CFG_NO_STOCK_PRODUCT_VISIBILITY);
        if (empty($noStockVisibility))
            $noStockVisibility = null;

        $securityStock = $cfg->get(SyncConfiguration::CFG_SECURED_STOCK);
        if (!$securityStock)
            $securityStock = 0;

        // Liste des codes magasins qui participent aux stocks des ventes en ligne
        $online_stocks_stores = $cfg->get(SyncConfiguration::CFG_ONLINE_STOCK_STORES);
        if (!is_array($online_stocks_stores))
            $online_stocks_stores = [];

        // Si aucun magasin n'est sélectionné, on prend tous les magasins
        $all_stores = count($online_stocks_stores) == 0;

        // Un tableau de stock par id_product_attribute
        $refs = [];
        $nbDone = 0;

        $emptyStockRefs = [];
        foreach ($stocks as $stock)
            if ($stock->isWildcard())
                $emptyStockRefs[] = $stock->refs[0];

        if (count($emptyStockRefs) > 0)
        {
            foreach ($emptyStockRefs as $emptyStockRef)
            {
                // On ajoute une référence générique pour le stock vide
                $matches = $refMngr->getReferencesFromWildcard($emptyStockRef);
                if (count($matches) > 0)
                {
                    // On ajoute la référence à la liste des références à traiter
                    foreach ($matches as $match)
                        $stocks[] = new Stock([$match], []);
                }
            }
        }

        foreach ($stocks as $stock)
        {
            if (!$stock->isWildcard())
            {                         
                foreach ($stock->refs as $ref)
                {
                    $combination = $refMngr->translateCombinationRefs($ref);
                    if ($combination)
                    {
                        foreach ($combination as $c)
                        {
                            // Trouvé !
                            $stock->id_product = $c[0]; // Pour simplifier la suite
                            $refs[$c[1]] = $stock;
                        }
                    }
                }
            }
        }

        if (count($refs) == 0)
            return 0;   // Pas de stock à synchroniser

        // Chargeons les stocks concernés depuis la table des stocks
        $currentStocks = [];
        $db = \Db::getInstance();
        $sql = 'SELECT id_product_attribute, store_code, qty FROM `'.$this->syncer->module->TblPrefix.'stocks` WHERE `id_product_attribute` IN ('.implode(',', array_map('intval', array_keys($refs))).')';
        $res = $db->executeS($sql);
        if ($res === FALSE)
            throw new Exception('Erreur lors de la récupération des stocks par magasin : '.$db->getMsgError());
        foreach ($res as $row)
        {
            $id_product_attribute = (int) $row['id_product_attribute'];
            $qty = (int) $row['qty'];

            $currentStocks[$id_product_attribute][$row['store_code']] = $qty;
        }

        $ctx = \Context::getContext();
        // Pays du magasin
        $id_country = $ctx->country->id;
        
        // Chargeons les stocks des produits actuels depuis la table des stocksAvailable
        // dans une structure $id_product => [ visibility => 'both|search...', stock => [id_product_attribute => qty] ]        
        $currentSituation = [];
        $sql = 'SELECT a.`id_product`, a.`id_product_attribute`, a.`quantity`, ps.`visibility`, ps.`price`, pas.`price` AS pa_price, ';
        // Dans la requête, on calcul le taux de tva depuis ps.id_tax_rules_group
        $sql .= '(SELECT t.rate FROM `'._DB_PREFIX_.'tax` AS t LEFT JOIN `'._DB_PREFIX_.'tax_rule` as r ON (t.`id_tax` = r.`id_tax`) WHERE r.`id_tax_rules_group` = ps.`id_tax_rules_group` AND r.`id_country` = '.$id_country.' LIMIT 1) AS tax_rate';
        $sql .= ' FROM `'._DB_PREFIX_.'stock_available` a';
        // On jointe avec la table product pour avoir la visibilité
        $sql .= ' INNER JOIN `'._DB_PREFIX_.'product` p ON (p.`id_product` = a.`id_product`)';
        // Jointure avec la table product_shop
        $sql .= ' INNER JOIN `'._DB_PREFIX_.'product_shop` ps ON (ps.`id_product` = a.`id_product` AND ps.`id_shop` = '.(int) $ctx->shop->id.')';
        // Jointure avec la table product_attribute_shop
        $sql .= ' INNER JOIN `'._DB_PREFIX_.'product_attribute_shop` pas ON (pas.`id_product_attribute` = a.`id_product_attribute` AND pas.`id_shop` = '.(int) $ctx->shop->id.')';
        $sql .= ' WHERE a.`id_product_attribute` IN ('.implode(',', array_map('intval', array_keys($refs))).')';
        $res = $db->executeS($sql);
        if ($res === FALSE)
            throw new Exception('Erreur lors de la récupération des stocks StockAvailable : '.$db->getMsgError());
        foreach ($res as $row)
        {
            $id_product = (int) $row['id_product'];
            $id_product_attribute = (int) $row['id_product_attribute'];
            $qty = (int) $row['quantity'];
            $visibility = $row['visibility'];
            $productPrice = $row['price'];
            $attrProductPrice = $row['pa_price'];

            if (!isset($currentSituation[$id_product]))
                $currentSituation[$id_product] = [
                    'visibility' => $visibility, 
                    'price' => $productPrice,
                    'tax_rate' => $row['tax_rate'] ?? 0,
                    'attr_prices' => [],
                    'stock' => []];
            
            $currentSituation[$id_product]['stock'][$id_product_attribute] = $qty;
            $currentSituation[$id_product]['attr_prices'][$id_product_attribute] = $attrProductPrice;
        }

        // On va maintenant comparer les stocks et les mettre à jour lorsqu'ils sont différents
        foreach ($refs as $id_product_attribute => $stock)
        {            
            $nbDone++;
            $this->progress->progress('import_stocks');

            $id_product = $stock->id_product;
            if (!isset($currentStocks[$id_product_attribute]))
                $currentStocks[$id_product_attribute] = []; // Aucune info de stock pour le moment
  
            // On va faire une première passe pour vérifie si le stock a changé ou non
            // on en profite pour calculer le stock global de la déclinaison
            $online_stocks = 0;
            $hasBeenChanged = false;
            foreach ($stock->qties as $store_code => $qty)
            {
                if (!isset($currentStocks[$id_product_attribute][$store_code]))
                    $currentStocks[$id_product_attribute][$store_code] = 0; // pas de stock enregistré !

                if ($currentStocks[$id_product_attribute][$store_code] != $qty)
                    $hasBeenChanged = true;

                // Si le $store_code est dans la liste des magasins qui participent aux stocks des ventes en ligne
                if ($all_stores || isset($online_stocks_stores[$store_code]))
                {
                    // On additionne le stock
                    $online_stocks += $qty;
                }
            }            

            // Le stock a changé ?
            if ($hasBeenChanged)
            {            
                // On commence par supprimer toutes les informations de stock dans la base de données                
                try 
                {
                    $res = $db->execute('DELETE FROM `'.$this->syncer->module->TblPrefix.'stocks` WHERE `id_product_attribute` = '.(int) $id_product_attribute);

                    if ($res === FALSE)
                        throw new \Exception($db->getMsgError());
                } 
                catch (\Exception $e) 
                {
                    $this->syncer->audit(sprintf("[ERR] Erreur du nettoyage du stock pour le produit #%d / déclinaison #%d : %s", 
                                        $id_product, 
                                        $id_product_attribute, 
                                        $db->getMsgError()));
                    continue;
                }

                // Cas du $id_product_attribute = 0 qu'il ne faut pas traiter davantage !
                if ($id_product_attribute == 0)
                    continue;

                // Puis on reconstruit le stock
                foreach ($stock->qties as $store_code => $qty)
                {                
                    // Stock différent => on le sauve
                    $currentStocks[$id_product_attribute][$store_code] = $qty;                            

                    try 
                    {
                        $sql = 'INSERT INTO `'.$this->syncer->module->TblPrefix.'stocks` (`id_product`, `id_product_attribute`, `store_code`, `qty`) VALUES ('.(int) $id_product.', ' . (int) $id_product_attribute . ', \''.$store_code.'\', '.(int) $qty.')';

                        $res = $db->execute($sql);
                        if ($res === FALSE)
                            throw new \Exception($db->getMsgError());
                    } 
                    catch (\Exception $e) 
                    {
                        $currentStocks[$id_product_attribute][$store_code] = 0;
                        $this->syncer->audit(sprintf(
                            "[ERR] Erreur lors de la mise à jour du stock pour le produit #%d / déclinaison #%d : %s",
                            $id_product,
                            $id_product_attribute,
                            $e->getMessage()
                        ));
                    }
                }
            }

            // Ok, on va recalculer le stock global de la déclinaison
            $afterQty = $online_stocks;

            // Application du stock de sécurité !
            $afterQty -= $securityStock;
            if ($afterQty < 0) $afterQty = 0;

            // Stock actuel
            $beforeStock = isset($currentSituation[$id_product]['stock'][$id_product_attribute]) ? 
                                $currentSituation[$id_product]['stock'][$id_product_attribute] : 0;

            // Mise à jour du stock
            if ($beforeStock != $afterQty)
            {
                StockAvailable::setQuantity($id_product, $id_product_attribute, $afterQty);
                $this->syncer->audit(sprintf("[INF] Stock article #%d/%d (%s) : %d → %d",
                    $id_product, 
                    $id_product_attribute,
                    $stock->refs[0] ?? '?',
                    $beforeStock,
                    $afterQty));

                // Nouvelle situation
                $currentSituation[$id_product]['stock'][$id_product_attribute] = $afterQty;

                // Contrôle de la visibilité du produit
                if ($noStockVisibility)
                {
                    // Totalisation
                    $totalQty = 0;
                    foreach ($currentSituation[$id_product]['stock'] as $qty)
                        $totalQty += $qty;

                    // On va maintenant vérifier si le produit doit être caché ou non
                    $mustHaveVisibility = $totalQty > 0 ? 'both' : $noStockVisibility;

                    if (!isset($currentSituation[$id_product]['visibility']) || $currentSituation[$id_product]['visibility'] != $mustHaveVisibility)
                    {
                        // Visibilité différente => on la sauve
                        $p = new \Product($id_product);
                        if ($p->id == $id_product)
                        {
                            $p->visibility = $mustHaveVisibility;
                            $p->update();

                            // sauvegarde de l'info dans l'audit
                            $this->syncer->audit("[INF] Visibilité du produit ".$id_product." → '".$mustHaveVisibility."'");
                            $currentSituation[$id_product]['visibility'] = $p->visibility;
                        }
                    }
                }
            }

            // Contrôle des prix
            if ($stock->prixTTC !== null)
            {
                $currentProductPriceWT = $currentSituation[$id_product]['price'];
                if (isset($currentProductPriceWT))
                {
                    $newPriceWT = round($stock->prixTTC / (1 + $currentSituation[$id_product]['tax_rate'] / 100.0), Article::PRICE_PRECISION);

                    $currentAttributePriceWT = round($currentSituation[$id_product]['attr_prices'][$id_product_attribute] ?? 0, Article::PRICE_PRECISION);
                    $totalPriceWT = round($currentProductPriceWT + $currentAttributePriceWT, Article::PRICE_PRECISION);

                    // Prix différent, on calcule la différence                    
                    $diff = round($newPriceWT - $totalPriceWT, Article::PRICE_PRECISION);
                    if ($diff != 0)
                    {
                        $this->syncer->audit(sprintf("[INF] Changement de prix pour le produit #%s / %s (#%s) : %f (ht) → %f (ht)",
                            $id_product, 
                            $stock->refs[0],
                            $id_product_attribute, $totalPriceWT, $newPriceWT)
                        );

                        if ($diff < 0)
                        {
                            // Prix négatif, on aligne les prix du produit                            
                            $infos = $this->alignProductPrices($id_product, $id_product_attribute, $diff);
                            if ($infos === false)
                            {
                                // en vrai, pas de modif de prix !
                                continue;
                            }

                            // Les prix ont été recalculés, on refait les caches
                            $currentSituation[$id_product]['price'] = $infos['p']->price;
                            foreach ($infos['c'] as $c)
                                $currentSituation[$id_product]['attr_prices'][$c->id] = $c->price;
                        }
                        else if ($newPriceWT != $totalPriceWT)
                        {
                            // Nous devons changer le prix du product attribute                        
                            $c = new \Combination($id_product_attribute);
                            if ($c->id == $id_product_attribute)
                            {
                                $this->syncer->audit(
                                    sprintf("[INF] Changement de prix pour le produit #%s / %s (#%s) : %f (ht) → %f (ht)",
                                    $id_product, 
                                    $stock->refs[0],
                                    $id_product_attribute, $totalPriceWT, $newPriceWT)
                                );

                                $c->price = round($currentAttributePriceWT + $diff, Article::PRICE_PRECISION);
                                if (empty($c->price) || $c->price < 0)
                                    $c->price = 0.0;

                                $c->price = sprintf("%.0" . Article::PRICE_PRECISION . "f", $c->price);   // Formatage pour éviter les problèmes de validation de PS !!!
                                $c->update();

                                // Nouveau prix enregistré !
                                $currentSituation[$id_product]['attr_prices'][$id_product_attribute] = $c->price;                            
                                $changedPricesofProducts[$id_product] = ['min' => false, 'id' => []];
                            }
                        }
                    }
                }
            }
        }        

        return $nbDone;
    }

    /***
     * Change le prix d'un produit et met le prix des combinaisons à jour en conséquence
     *
     * @param int $id_product Identifiant du produit
     * @param int $id_product_attribute Identifiant de la combinaison qui a un prix négatif
     * @param float $add Décalage à appliquer au prix du produit (peut être négatif)
     */
    private function alignProductPrices(int $id_product, int $id_product_attribute = 0, float $add = 0) : array|bool
    {
        $p = new \Product($id_product);
        if ($p->id == $id_product)
        {
            // Chargement des combinaisons de produits
            $db = \Db::getInstance();
            $sql = 'SELECT id_product_attribute FROM `'._DB_PREFIX_.'product_attribute` WHERE id_product = '.(int) $id_product;
            $res = $db->executeS($sql);
            if ($res === FALSE)
                throw new Exception('Erreur lors de la récupération des combinaisons du produit #'.$id_product.' : '.$db->getMsgError());

            $minPrice = false;
            $combinations = [];
            $old_prices = [];
            foreach ($res as $row)
            {
                $c = new \Combination($row['id_product_attribute']);
                if ($c->id == $row['id_product_attribute'])
                {
                    $old_prices[$c->id] = $c->price;

                    // Si c'est la combinaison concernée par l'appel, on ajuste le prix maintenant
                    if ($c->id == $id_product_attribute)
                        $c->price += $add;

                    if ($minPrice === false || $c->price < $minPrice)
                        $minPrice = $c->price;
                    $combinations[$c->id] = $c;
                }                
            }

            if ($minPrice !== false)
            {
                // On ignore les différences inférieures à une demi-unité de la dernière décimale
                // (le seuil suit PRICE_PRECISION, sinon les petites corrections sont jetées
                // et le prix ne converge jamais)
                if ($minPrice && abs($minPrice) > Article::PRICE_EPSILON)
                {
                    // On va déjà ajouter le minimum au prix actuel et aux combinaisons
                    // pour ramener tout > 0
                    $oldPrice = $p->price;
                    $p->price += $minPrice;
                    $this->syncer->audit(sprintf("[INF] Prix du produit #%d : %f → %f",
                                        $id_product,
                                        $oldPrice, $p->price));
                    $p->price = sprintf("%.0" . Article::PRICE_PRECISION . "f", $p->price);   // Formatage pour éviter les problèmes de validation de PS !!!
                    $p->update();

                    foreach ($combinations as $c)
                    {
                        $c->price -= $minPrice;
                        $this->syncer->audit(sprintf("[INF] Prix du produit #%d / déclinaison #%d : %f → %f",
                                            $id_product,
                                            $c->id,
                                            $old_prices[$c->id] ?? '?',
                                            $c->price));
                        if ($c->price < 0) $c->price = 0.0;   // PS interdit un prix de combinaison négatif
                        $c->price = sprintf("%.0" . Article::PRICE_PRECISION . "f", $c->price);   // Formatage pour éviter les problèmes de validation de PS !!!
                        $c->update();
                    }

                    $r = ['p' => $p, 'c' => $combinations];
                    return $r;
                }                        
            }
        }

        return false;     // Pas de combinaison, on ne fait rien
    }
}