<?php
/**
 * DISCLAIMER
 *
 * Do not edit or add to this file if you wish to upgrade Smile ElasticSuite to newer
 * versions in the future.
 *
 * @category  Smile
 * @package   Smile\ElasticsuiteVirtualCategory
 * @author    Aurelien FOUCRET <aurelien.foucret@smile.fr>
 * @copyright 2020 Smile
 * @license   Open Software License ("OSL") v. 3.0
 */
namespace Smile\ElasticsuiteVirtualCategory\Model;

use Magento\Catalog\Model\Category;
use Magento\Customer\Model\Session;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Store\Model\StoreManagerInterface;
use Smile\ElasticsuiteCatalogRule\Model\Data\ConditionFactory as ConditionDataFactory ;
use Smile\ElasticsuiteCore\Search\Request\QueryInterface;
use Magento\Catalog\Api\Data\CategoryInterface;
use Smile\ElasticsuiteVirtualCategory\Api\Data\VirtualRuleInterface;
use Smile\ElasticsuiteCatalogRule\Model\Rule\Condition\Product\QueryBuilder;
use Smile\ElasticsuiteVirtualCategory\Helper\Config;
use Smile\ElasticsuiteVirtualCategory\Model\ResourceModel\VirtualCategory\CollectionFactory;
use Smile\ElasticsuiteCore\Search\Request\Query\QueryFactory;
use Magento\Catalog\Model\CategoryFactory;
use Magento\Catalog\Model\ResourceModel\Category\Collection;
use Smile\ElasticsuiteCatalogRule\Model\Rule\Condition\ProductFactory as ProductConditionFactory;
use Smile\ElasticsuiteCatalogRule\Model\Rule\Condition\CombineFactory as CombineConditionFactory;
use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
use Magento\Framework\Data\FormFactory;
use Magento\Framework\Registry;
use Magento\Framework\Model\Context;

/**
 * Virtual category rule.
 *
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 * @SuppressWarnings(PHPMD.CyclomaticComplexity)
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
 *
 * @category Smile
 * @package  Smile\ElasticsuiteVirtualCategory
 * @author   Aurelien FOUCRET <aurelien.foucret@smile.fr>
 */
class Rule extends \Smile\ElasticsuiteCatalogRule\Model\Rule implements VirtualRuleInterface
{
    /**
     * @var QueryFactory
     */
    private $queryFactory;

    /**
     * @var ProductConditionFactory
     */
    private $productConditionsFactory;

    /**
     * @var CategoryFactory
     */
    private $categoryFactory;

    /**
     * @var CollectionFactory
     */
    private $categoryCollectionFactory;

    /**
     * @var QueryBuilder
     */
    private $queryBuilder;

    /**
     * @var StoreManagerInterface
     */
    private $storeManager;

    /**
     * @var Session
     */
    private $customerSession;

    /**
     * @var CacheInterface
     */
    private $sharedCache;

    /**
     * @var Config
     */
    private $config;

    /**
     * @var Category[]
     */
    protected $instances = [];

    /**
     * @var array
     */
    protected static $localCache = [];

    /**
     * Constructor.
     * @SuppressWarnings(PHPMD.ExcessiveParameterList)
     *
     * @param Context                 $context                   Context.
     * @param Registry                $registry                  Registry.
     * @param FormFactory             $formFactory               Form factory.
     * @param TimezoneInterface       $localeDate                Locale date.
     * @param CombineConditionFactory $combineConditionsFactory  Search engine rule (combine) condition factory.
     * @param ConditionDataFactory    $conditionDataFactory      Condition Data factory.
     * @param ProductConditionFactory $productConditionsFactory  Search engine rule (product) condition factory.
     * @param QueryFactory            $queryFactory              Search query factory.
     * @param CategoryFactory         $categoryFactory           Product category factorty.
     * @param CollectionFactory       $categoryCollectionFactory Virtual categories collection factory.
     * @param QueryBuilder            $queryBuilder              Search rule query builder.
     * @param StoreManagerInterface   $storeManagerInterface     Store Manager
     * @param Session                 $customerSession           Customer session.
     * @param CacheInterface          $cache                     Cache.
     * @param Config                  $config                    Virtual category configuration.
     * @param array                   $data                      Additional data.
     */
    public function __construct(
        Context $context,
        Registry $registry,
        FormFactory             $formFactory,
        TimezoneInterface       $localeDate,
        CombineConditionFactory $combineConditionsFactory,
        ConditionDataFactory    $conditionDataFactory,
        ProductConditionFactory $productConditionsFactory,
        QueryFactory            $queryFactory,
        CategoryFactory         $categoryFactory,
        CollectionFactory       $categoryCollectionFactory,
        QueryBuilder            $queryBuilder,
        StoreManagerInterface   $storeManagerInterface,
        Session                 $customerSession,
        CacheInterface          $cache,
        Config                  $config,
        array                   $data = []
    ) {
        $this->queryFactory              = $queryFactory;
        $this->productConditionsFactory  = $productConditionsFactory;
        $this->categoryFactory           = $categoryFactory;
        $this->categoryCollectionFactory = $categoryCollectionFactory;
        $this->queryBuilder              = $queryBuilder;
        $this->storeManager              = $storeManagerInterface;
        $this->customerSession           = $customerSession;
        $this->sharedCache               = $cache;
        $this->config                    = $config;

        parent::__construct($context, $registry, $formFactory, $localeDate, $combineConditionsFactory, $conditionDataFactory, $data);
    }

    /**
     * Implementation of __toString().
     * This one is mandatory to ensure the object is properly handled when it get sent "as is" to a DB query.
     * This occurs especially in @see \Magento\Catalog\Model\Indexer\Category\Flat\Action\Rows::reindex() (line 86 & 98)
     *
     * @return string
     */
    public function __toString(): string
    {
        return json_encode($this->getConditions()->asArray());
    }

    /**
     * Get search query by category from cache or build it.
     *
     * @param CategoryInterface|int $category           Search category.
     * @param array                 $excludedCategories Categories that should not be used into search query building.
     *                                                  Used to avoid infinite recursion while building virtual categories rules.
     *
     * @codingStandardsIgnoreStart
     * @TODO: manage cache in this file for getSearchQueriesByChildren,
     * remove the \Smile\ElasticsuiteVirtualCategory\Helper\Rule class,
     * do not use the $excludedCategories parameters to check if the category rule has been calculated, but use the local cache.
     * @codingStandardsIgnoreEnd
     * @SuppressWarnings(PHPMD.StaticAccess)
     * @SuppressWarnings(PHPMD.NPathComplexity)
     *
     * @return QueryInterface|null
     */
    public function getCategorySearchQuery($category, $excludedCategories = []): ?QueryInterface
    {
        \Magento\Framework\Profiler::start('ES:Virtual Rule ' . __FUNCTION__);
        $categoryId = (int) (!is_object($category) ? $category : $category->getId());
        $storeId = !is_object($category) ? $this->getStoreId() : $category->getStoreId();
        $storeId = $storeId ?: $this->storeManager->getStore()->getId();
        $cacheKey = implode(
            '|',
            [
                __FUNCTION__,
                $storeId,
                $categoryId,
                $this->customerSession->getCustomerGroupId(),
                (int) $this->config->isForceZeroResultsForDisabledCategoriesEnabled($storeId),
            ]
        );

        $query = $this->getFromLocalCache($categoryId);

        // If the category is not an object, it can't be in a "draft" mode.
        if ($query === false && (!is_object($category) || !$category->getHasDraftVirtualRule())) {
            // If the category virtual root is one of the excluded categories, we must recalculate the query.
            // We can't use the cached one in order to avoid loop and very big queries.
            if (!is_object($category) || !$this->isVirtualCategoryRootInExcludeCategories($category, $excludedCategories)) {
                // Due to the fact we serialize/unserialize completely pre-built queries as object.
                // We cannot use any implementation of SerializerInterface.
                $query = $this->sharedCache->load($cacheKey);
                $query = $query ? unserialize($query) : false;
            }
        }

        if ($query === false) {
            if (!is_object($category)) {
                $category = $this->categoryFactory->create()->setStoreId($this->getStoreId())->load($category);
            }
            $query = $this->buildCategorySearchQuery($category, $excludedCategories);

            if (!$category->getHasDraftVirtualRule() && $query !== null && !in_array($categoryId, $excludedCategories)) {
                $cacheData   = serialize($query);
                $this->sharedCache->save($cacheData, $cacheKey, $category->getCacheTags());
            }
        }

        if (!in_array($categoryId, $excludedCategories)) {
            $this->saveInLocalCache($categoryId, $query);
        }

        \Magento\Framework\Profiler::stop('ES:Virtual Rule ' . __FUNCTION__);

        return $query;
    }

    /**
     * Retrieve search queries of children categories.
     *
     * @param CategoryInterface $rootCategory Root category.
     *
     * @return QueryInterface[]
     */
    public function getSearchQueriesByChildren(CategoryInterface $rootCategory): array
    {
        $queries     = [];
        $childrenIds = $rootCategory->getResource()->getChildren($rootCategory, false);

        if (!empty($childrenIds)) {
            $storeId            = $this->getStoreId();
            $categoryCollection = $this->categoryCollectionFactory->create()->setStoreId($storeId);

            $categoryCollection
                ->setStoreId($this->getStoreId())
                ->addIsActiveFilter()
                ->addIdFilter($childrenIds)
                ->addAttributeToSelect(['virtual_category_root', 'is_virtual_category', 'virtual_rule']);

            foreach ($categoryCollection as $category) {
                $childQuery = $this->getCategorySearchQuery($category);
                if ($childQuery !== null) {
                    $queries[$category->getId()] = $childQuery;
                }
            }
        }

        return $queries;
    }

    /**
     * {@inheritDoc}
     */
    public function getCondition()
    {
        $conditions = $this->getConditions()->asArray();

        return $this->arrayToConditionDataModel($conditions);
    }

    /**
     * {@inheritDoc}
     */
    public function setCondition($condition)
    {
        $this->getConditions()->setConditions([])->loadArray($this->dataModelToArray($condition));

        return $this;
    }

    /**
     * Combine several category queries
     *
     * @param CategoryInterface[] $categories The categories
     *
     * @return QueryInterface
     */
    public function mergeCategoryQueries(array $categories)
    {
        $queries = [];

        foreach ($categories as $category) {
            $queries[] = $this->getCategorySearchQuery($category);
        }

        return $this->queryFactory->create(QueryInterface::TYPE_BOOL, ['must' => $queries]);
    }

    /**
     * Build search query by category.
     *
     * @param CategoryInterface|int $category           Search category.
     * @param array                 $excludedCategories Categories that should not be used into search query building.
     *
     * @return QueryInterface|null
     */
    private function buildCategorySearchQuery($category, $excludedCategories = []): ?QueryInterface
    {
        $query = null;

        if (!in_array($category->getId(), $excludedCategories)) {
            $excludedCategories[] = $category->getId();

            if (!$category->getIsActive()
                && $this->config->isForceZeroResultsForDisabledCategoriesEnabled($this->getStoreId())) {
                return $this->getNoResultsQuery();
            }

            if ((bool) $category->getIsVirtualCategory() && $category->getIsActive()) {
                $query = $this->getVirtualCategoryQuery($category, $excludedCategories);
            } elseif ($category->getId() && $category->getIsActive()) {
                $query = $this->getStandardCategoryQuery($category, $excludedCategories);
            }
            if ($query && $category->hasChildren()) {
                $query = $this->addChildrenQueries($query, $category, $excludedCategories);
            }
        }

        return $query;
    }

    /**
     * Load the root category used for a virtual category.
     *
     * @param CategoryInterface $category Virtual category.
     *
     * @return CategoryInterface|null
     * @throws NoSuchEntityException
     */
    private function getVirtualRootCategory(CategoryInterface $category): ?CategoryInterface
    {
        $storeId      = $this->getStoreId();
        $rootCategory = null;

        if ($category->getVirtualCategoryRoot() !== null && !empty($category->getVirtualCategoryRoot())) {
            $rootCategoryId = $category->getVirtualCategoryRoot();
            try {
                $rootCategory = $this->getRootCategory($rootCategoryId, $storeId);
            } catch (NoSuchEntityException $e) {
                $rootCategory = null;
            }
        }

        if ($rootCategory && $rootCategory->getId()
            && ($rootCategory->getLevel() < 1 || (int) $rootCategory->getId() === (int) $this->getTreeRootCategoryId($category))
        ) {
            $rootCategory = null;
        }

        return $rootCategory;
    }

    /**
     * Get info about category by category id.
     * This code uses a local cache for better performance.
     *
     * @SuppressWarnings(PHPMD.StaticAccess)
     *
     * @param int      $rootCategoryId Root category id.
     * @param int|null $storeId        Store id.
     * @return CategoryInterface
     * @throws NoSuchEntityException
     */
    private function getRootCategory(int $rootCategoryId, ?int $storeId = null)
    {
        $cacheKey = $storeId ?? 'all';
        if (!isset($this->instances[$rootCategoryId][$cacheKey])) {
            $rootCategory = $this->categoryFactory->create();
            if (null !== $storeId) {
                $rootCategory->setStoreId($storeId);
            }
            $rootCategory->load($rootCategoryId);
            if (!$rootCategory->getId()) {
                throw NoSuchEntityException::singleField('id', $rootCategoryId);
            }
            $this->instances[$rootCategoryId][$cacheKey] = $rootCategory;
        }

        return $this->instances[$rootCategoryId][$cacheKey];
    }

    /**
     * Transform a category in query rule.
     *
     * @param CategoryInterface $category           Category.
     * @param array             $excludedCategories Categories ignored in subquery filters.
     *
     * @return QueryInterface
     */
    private function getStandardCategoryQuery(CategoryInterface $category, $excludedCategories = []): QueryInterface
    {
        $query = $this->getStandardCategoriesQuery([$category->getId()], $excludedCategories);
        $query->setName(sprintf('(%s) standard category [%s]:%d', $category->getPath(), $category->getName(), $category->getId()));

        return $query;
    }

    /**
     * Transform a category ids list in query rule.
     *
     * @param array $categoryIds        Categories included in the query.
     * @param array $excludedCategories Categories ignored in subquery filters.
     *
     * @return QueryInterface
     */
    private function getStandardCategoriesQuery(array $categoryIds, $excludedCategories): QueryInterface
    {
        $conditionsParams  = ['data' => ['attribute' => 'category_ids', 'operator' => '()', 'value' => $categoryIds]];
        $categoryCondition = $this->productConditionsFactory->create($conditionsParams);

        return $this->queryBuilder->getSearchQuery($categoryCondition, $excludedCategories);
    }

    /**
     * Transform the virtual category into a QueryInterface used for filtering.
     *
     * @param CategoryInterface $category           Virtual category.
     * @param array             $excludedCategories Category already used into the building stack. Avoid short circuit.
     *
     * @return QueryInterface
     */
    private function getVirtualCategoryQuery(
        CategoryInterface $category,
        $excludedCategories = []
    ): ?QueryInterface {
        if ($this->isVirtualCategoryRootInExcludeCategories($category, $excludedCategories)) {
            return null;
        }

        $rootCategory = $this->getVirtualRootCategory($category);
        $query = $category->getVirtualRule()->getConditions()->getSearchQuery($excludedCategories);
        if ($query instanceof QueryInterface) {
            $queryName = sprintf('(%s) virtual category [%s]:%d', $category->getPath(), $category->getName(), $category->getId());
            $query->setName(($query->getName() !== '') ? $queryName . " => " . $query->getName() : $queryName);
        }
        if ($rootCategory && $rootCategory->getId()) {
            $rootQuery = $this->getCategorySearchQuery($rootCategory, $excludedCategories);
            if ($rootQuery) {
                $query = $this->queryFactory->create(QueryInterface::TYPE_BOOL, ['must' => array_filter([$query, $rootQuery])]);
                $query->setName(
                    sprintf(
                        '(%s) virtual category [%s]:%d and its virtual root',
                        $category->getPath(),
                        $category->getName(),
                        $category->getId()
                    )
                );
            }
        }

        return $query;
    }

    /**
     * Append children queries to the rule.
     *
     * @SuppressWarnings(PHPMD.ElseExpression)
     *
     * @param QueryInterface|NULL $query              Base query.
     * @param CategoryInterface   $category           Current category.
     * @param array               $excludedCategories Category already used into the building stack. Avoid short circuit.
     *
     * @return \Smile\ElasticsuiteCore\Search\Request\QueryInterface
     */
    private function addChildrenQueries($query, CategoryInterface $category, $excludedCategories = []): QueryInterface
    {
        $childrenCategories    = $this->getChildrenCategories($category, $excludedCategories);
        $childrenCategoriesIds = [];

        if ($query !== null && $childrenCategories->getSize() > 0) {
            $queryParams = ['should' => [$query], 'cached' => empty($excludedCategories)];

            foreach ($childrenCategories as $childrenCategory) {
                if (((bool) $childrenCategory->getIsVirtualCategory()) === true) {
                    $childrenQuery = $this->getCategorySearchQuery($childrenCategory, $excludedCategories);
                    if ($childrenQuery !== null) {
                        $childrenQuery->setName(
                            sprintf(
                                '(%s) child virtual category [%s]:%d',
                                $childrenCategory->getPath(),
                                $childrenCategory->getName(),
                                $childrenCategory->getId()
                            )
                        );
                        $queryParams['should'][] = $childrenQuery;
                    }
                } else {
                    $childrenCategoriesIds[] = $childrenCategory->getId();
                }
            }

            if (!empty($childrenCategoriesIds)) {
                $standardChildrenQuery = $this->getStandardCategoriesQuery($childrenCategoriesIds, $excludedCategories);
                $standardChildrenQuery->setName(
                    sprintf(
                        '(%s) standard children of virtual category [%s]:%d',
                        $category->getPath(),
                        $category->getName(),
                        $category->getId()
                    )
                );

                $queryParams['should'][] = $standardChildrenQuery;
            }

            if (count($queryParams['should']) > 1) {
                $query = $this->queryFactory->create(QueryInterface::TYPE_BOOL, $queryParams);
                $query->setName(
                    sprintf(
                        '(%s) category [%s]:%d and its children',
                        $category->getPath(),
                        $category->getName(),
                        $category->getId()
                    )
                );
            }
        }

        return $query;
    }

    /**
     * Returns the list of the virtual categories available under a category.
     *
     * @param CategoryInterface $category           Category.
     * @param array             $excludedCategories Category already used into the building stack. Avoid short circuit.
     *
     * @return Collection
     */
    private function getChildrenCategories(CategoryInterface $category, $excludedCategories = []): Collection
    {
        $storeId            = $category->getStoreId();
        $categoryCollection = $this->categoryCollectionFactory->create()->setStoreId($storeId);

        $categoryCollection->addIsActiveFilter()->addPathFilter(sprintf('%s/.*', $category->getPath()));

        if (((bool) $category->getIsVirtualCategory()) === false) {
            $categoryCollection->addFieldToFilter('is_virtual_category', '1');
        }

        if (!empty($excludedCategories)) {
            $categoryCollection->addAttributeToFilter('entity_id', ['nin' => $excludedCategories]);
        }

        $categoryCollection->addAttributeToSelect(['name', 'is_active', 'virtual_category_root', 'is_virtual_category', 'virtual_rule']);

        return $categoryCollection;
    }

    /**
     * Retrieve the root category id of the tree the category belongs to.
     *
     * @param CategoryInterface $category Category.
     *
     * @return int
     */
    private function getTreeRootCategoryId($category): int
    {
        $rootCategoryId = 0;

        $pathIds = $category->getPathIds();
        if (count($pathIds) > 1) {
            $rootCategoryId = (int) current(array_slice($pathIds, 1, 1));
        }

        return $rootCategoryId;
    }

    /**
     * Get category query from local cache.
     *
     * @param int $categoryId In of the category.
     * @return QueryInterface|bool|null
     */
    private function getFromLocalCache(int $categoryId)
    {
        return self::$localCache[$categoryId] ?? false;
    }

    /**
     * Save category query in local cache.
     *
     * @param int                      $categoryId Id of the category.
     * @param QueryInterface|bool|null $query      Query of the category.
     */
    private function saveInLocalCache(int $categoryId, $query): void
    {
        if ($query !== null) {
            self::$localCache[$categoryId] = $query;
        }
    }

    /**
     * Build a query that return zero products.
     *
     * @return QueryInterface
     */
    private function getNoResultsQuery(): QueryInterface
    {
        return $this->queryFactory->create(
            QueryInterface::TYPE_TERMS,
            ['field' => 'entity_id', 'values' => [0]]
        );
    }

    /**
     * If the root category of the current virtual category has already been computed (exist in $excludedCategories)
     * or if a parent of the root category of the current category has already been computed we don't need
     * to compute the rule. All the product will already been present.
     * For example, if you have the following category tree:
     * - Category A (static)
     * -   - Category B (static)
     * -       - Category C (virtual with category B as root)
     * When you compute the rule of the category A you do not need to compute the rule of the category C
     * as all the product will be there.
     *
     * @param CategoryInterface $category           category to check
     * @param array             $excludedCategories list of actual excluded categories
     * @return bool
     * @throws NoSuchEntityException
     */
    private function isVirtualCategoryRootInExcludeCategories(CategoryInterface $category, array $excludedCategories): bool
    {
        $rootCategory = $this->getVirtualRootCategory($category);

        return $category->getIsVirtualCategory()
            && $rootCategory
            && $rootCategory->getPath()
            && !empty(array_intersect(explode('/', (string) $rootCategory->getPath()), $excludedCategories));
    }
}
