<?php
/** 
 * @package     VikRestaurants
 * @subpackage  core
 * @author      E4J s.r.l.
 * @copyright   Copyright (C) 2023 E4J s.r.l. All Rights Reserved.
 * @license     http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL
 * @link        https://vikwp.com
 */

namespace E4J\VikRestaurants\Upselling\Score;

// No direct access
defined('ABSPATH') or die('No script kiddies please!');

use E4J\VikRestaurants\Upselling\ScoreCalculator;
use E4J\VikRestaurants\Upselling\StatisticsRepository;

/**
 * Calculates the score according to the following formulas:
 * score = (α * confidence + β * ln(lift)) * (1 + γ * promo_boost) [MULTIPLICATIVE BOOST]
 * score = α * confidence + β * ln(lift) + γ * promo_boost [ADDITIVE BOOST]
 * 
 * @since 1.11
 */
class ConfidenceLiftCalculator implements ScoreCalculator
{
    /** @var StatisticsRepository */
    protected $statisticsRepository;

    /** @var array */
    protected $options;

    /**
     * Class constructor.
     * 
     * @param  StatisticsRepository  $statisticsRepository
     * @param  array  $options
     */
    public function __construct(StatisticsRepository $statisticsRepository, array $options = [])
    {
        $this->statisticsRepository = $statisticsRepository;
        $this->options = $options;
    }

    /**
     * @inheritDoc
     */
    public function computeScore(int $itemA, int $itemB)
    {
        // obtain confidence and lift weights
        $alpha = (float) ($this->options['alpha'] ?? 0.5);
        $beta = (float) ($this->options['beta'] ?? 0.5);
        $gamma = (float) ($this->options['gamma'] ?? 0.5);

        // calculate confidence and lift between A and B
        $confidence = $this->calculateConfidence($itemA, $itemB);
        $lift = $this->calculateLift($itemA, $itemB);

        // calculate promo boost for product B
        $promoBoost = $this->calculatePromoBoost($itemB);

        // calculate score
        $score = $alpha * $confidence + $beta * log($lift);

        if (is_infinite($score) || is_nan($score) || $score == 0)
        {
            // We have a negative infinite score when the lift is equal to 0, hence the
            // product hasn't been yet sold. Return a result equal to the promo boost.
            // We may also have a NAN in case the negative infinite value is multiplied
            // by a weight equal to 0.
            // Divide the promo boost to avoid outperforming the score.
            return ($gamma * $promoBoost) / 100;
        }

        // apply a multiplicative promo boost
        return $score * (1 + $gamma * $promoBoost);
    }

    /**
     * Calculates the confidence between A and B items.
     * Confidence is the probability that two products will be ordered together.
     * 
     * @param   int  $itemA
     * @param   int  $itemB
     * 
     * @return  float  Returns a value between 0 and 1.
     */
    public function calculateConfidence(int $itemA, int $itemB)
    {
        // count the total number of orders
        $totalOrders = $this->statisticsRepository->getTotalOrders();

        // count the total sales of item A
        $countA = $this->statisticsRepository->getItemTotal($itemA);

        if (!$totalOrders || !$countA)
        {
            return 0;
        }

        // count the A+B occurrences
        $pairsCount = $this->statisticsRepository->getItemsPair($itemA, $itemB);

        // compute confidence
        return ($pairsCount / $totalOrders) / ($countA / $totalOrders);
    }

    /**
     * Calculates the lift between A and B items.
     * The lift is the strength of the pairing relative to chance.
     * 
     * @param   int  $itemA
     * @param   int  $itemB
     * 
     * @return  float  Returns a value between 0 and infinite (total orders).
     */
    public function calculateLift(int $itemA, int $itemB)
    {
        // count the total number of orders
        $totalOrders = $this->statisticsRepository->getTotalOrders();

        // count the total sales of item A
        $countA = $this->statisticsRepository->getItemTotal($itemA);

        // count the total sales of item B
        $countB = $this->statisticsRepository->getItemTotal($itemB);

        if (!$totalOrders || !$countA || !$countB)
        {
            return 0;
        }

        // count the A+B occurrences
        $pairsCount = $this->statisticsRepository->getItemsPair($itemA, $itemB);

        // compute lift
        return ($pairsCount / $totalOrders) / (
            ($countA / $totalOrders) * ($countB / $totalOrders)
        );
    }

    /**
     * Calculates the promo boost applied to item B, useful to elevate the
     * overall accuracy score.
     * 
     * @param   int  $itemB
     * 
     * @return  float  Returns a value between 0 and 1.
     */
    public function calculatePromoBoost(int $itemB)
    {
        $boost = 0;

        /**
         * Extended formula to calculate an optimal promo boost:
         * 
         * promo_boost = w1 * promo_flag + w2 * percentage_discount + w3 * recency_boost
         */

        // load all the supported deals
        $deals = (new \E4J\VikRestaurants\Deals\DealsHandler)->getAvailableDeals();

        ///////////////////////////////
        ////////// PROMO FLAG /////////
        ///////////////////////////////

        // take the upselling promo deals
        $promoDeals = $deals->filter(new \E4J\VikRestaurants\Deals\Filters\RuleFilter('upsellingpromo'));

        // if item is flagged as "promo" use 1, 0 otherwise
        $flagged = 0;

        foreach ($promoDeals as $deal)
        {
            /** @var object */
            $dealProd = $deal->getProduct($itemB);

            // check whether the item has been flagged as upselling promo
            if ($dealProd->params->published ?? false)
            {
                $flagged = 1;
            }
        }

        // fetch the promo flag weight
        $w1 = (float) ($this->options['promo']['w1'] ?? 0.6);

        // increase promo boost by flag
        $boost += $flagged * $w1;

        /**
         * Boost the new products by calculcating the decay rate since the publishing date = e ^ (−λ * age_days). λ can be set to 0.05.
         */

        ///////////////////////////////
        ///// PERCENTAGE DISCOUNT /////
        ///////////////////////////////

        // take the deals that apply a discount to the items
        $discountDeals = $deals->filter(new \E4J\VikRestaurants\Deals\Filters\RuleFilter('discountitem'));

        $percentageDiscount = 0;

        // Calculate the percentage discount applied to the item. The higher the discount, the higher the score.
        foreach ($discountDeals as $deal)
        {
            /** @var object */
            $dealProd = $deal->getProduct($itemB);

            if ($dealProd && ($dealProd->params->published ?? 1))
            {
                if (($dealProd->params->percentot ?? 1) == 1)
                {
                    // percentage discount, calculate the discount amount per item
                    $discount = ($dealProd->params->amount ?? 0) / 100 / ($dealProd->params->units ?? 1);
                }
                else
                {
                    // use a fixed discount
                    $discount = 0.25;
                }

                // check whether we have a higher discount
                $percentageDiscount = max($percentageDiscount, abs($discount));
            }
        }

        // fetch the percentage discount weight
        $w2 = (float) ($this->options['promo']['w2'] ?? 0.3);

        // increase promo boost by percentage discount
        $boost += min($percentageDiscount, 1) * $w2;

        ///////////////////////////////
        //////// RECENCY BOOST ////////
        ///////////////////////////////

        // fetch the date when the item was purchased for the very first time
        $creation = $this->statisticsRepository->getItemCreation($itemB);

        if ($creation !== null)
        {
            // approximatively calculate the days passed since the creation
            $ageDays = round((\VikRestaurants::now() - $creation) / 60 / 60 / 24);
        }
        else
        {
            // not enough data, assume no days passed
            $ageDays = 0;
        }

        // Calculcate the decay rate since the publishing date = e ^ (−λ * age_days).
        // λ is configured to 0.05 to half the boost after 14 days.
        $recencyRate = pow(M_E, -0.05 * $ageDays);

        // fetch the recency rate weight
        $w3 = (float) ($this->options['promo']['w3'] ?? 0.4);

        // increase promo boost by recency rate
        $boost += $recencyRate * $w3;

        ////////////////////////////////

        // clamp boost between 0 and 1
        return min(1, max(0, $boost));
    }
}
