<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
namespace Magento\Catalog\Model\ResourceModel;

/**
 * Catalog url rewrite resource model
 *
 * @author      Magento Core Team <core@magentocommerce.com>
 */
use Magento\CatalogUrlRewrite\Model\ProductUrlRewriteGenerator;
use Magento\Catalog\Api\Data\CategoryInterface;
use Magento\Framework\EntityManager\MetadataPool;
use Magento\Framework\App\ObjectManager;
use Magento\Catalog\Model\Indexer\Category\Product\TableMaintainer;

/**
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 */
class Url extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb
{
    /**
     * Stores configuration array
     *
     * @var array
     */
    protected $_stores;

    /**
     * Category attribute properties cache
     *
     * @var array
     */
    protected $_categoryAttributes = [];

    /**
     * Product attribute properties cache
     *
     * @var array
     */
    protected $_productAttributes = [];

    /**
     * Limit products for select
     *
     * @var int
     */
    protected $_productLimit = 250;

    /**
     * Cache of root category children ids
     *
     * @var array
     */
    protected $_rootChildrenIds = [];

    /**
     * @var \Psr\Log\LoggerInterface
     */
    protected $_logger;

    /**
     * @var \Magento\Catalog\Model\Category
     */
    protected $_catalogCategory;

    /**
     * @var \Magento\Catalog\Model\Product
     */
    protected $_catalogProduct;

    /**
     * @var \Magento\Eav\Model\Config
     */
    protected $_eavConfig;

    /**
     * @var \Magento\Store\Model\StoreManagerInterface
     */
    protected $_storeManager;

    /**
     * @var Product
     */
    protected $productResource;

    /**
     * @var MetadataPool
     */
    protected $metadataPool;

    /**
     * @var TableMaintainer
     */
    private $tableMaintainer;

    /**
     * Url constructor.
     * @param \Magento\Framework\Model\ResourceModel\Db\Context $context
     * @param \Magento\Store\Model\StoreManagerInterface $storeManager
     * @param \Magento\Eav\Model\Config $eavConfig
     * @param Product $productResource
     * @param \Magento\Catalog\Model\Category $catalogCategory
     * @param \Psr\Log\LoggerInterface $logger
     * @param string|null $connectionName
     * @param TableMaintainer|null $tableMaintainer
     */
    public function __construct(
        \Magento\Framework\Model\ResourceModel\Db\Context $context,
        \Magento\Store\Model\StoreManagerInterface $storeManager,
        \Magento\Eav\Model\Config $eavConfig,
        Product $productResource,
        \Magento\Catalog\Model\Category $catalogCategory,
        \Psr\Log\LoggerInterface $logger,
        $connectionName = null,
        TableMaintainer $tableMaintainer = null
    ) {
        $this->_storeManager = $storeManager;
        $this->_eavConfig = $eavConfig;
        $this->productResource = $productResource;
        $this->_catalogCategory = $catalogCategory;
        $this->_logger = $logger;
        parent::__construct($context, $connectionName);
        $this->tableMaintainer = $tableMaintainer ?: ObjectManager::getInstance()->get(TableMaintainer::class);
    }

    /**
     * Load core Url rewrite model
     *
     * @return void
     */
    protected function _construct()
    {
        $this->_init('url_rewrite', 'url_rewrite_id');
    }

    /**
     * Retrieve stores array or store model
     *
     * @param int $storeId
     * @return \Magento\Store\Model\Store|\Magento\Store\Model\Store[]
     */
    public function getStores($storeId = null)
    {
        if ($this->_stores === null) {
            $this->_stores = $this->_prepareStoreRootCategories($this->_storeManager->getStores());
        }
        if ($storeId && isset($this->_stores[$storeId])) {
            return $this->_stores[$storeId];
        }
        return $this->_stores;
    }

    /**
     * Retrieve category attributes
     *
     * @param string $attributeCode
     * @param int|array $categoryIds
     * @param int $storeId
     * @return array
     */
    protected function _getCategoryAttribute($attributeCode, $categoryIds, $storeId)
    {
        $linkField = $this->getMetadataPool()->getMetadata(CategoryInterface::class)->getLinkField();
        $identifierFiled = $this->getMetadataPool()->getMetadata(CategoryInterface::class)->getIdentifierField();

        $connection = $this->getConnection();
        if (!isset($this->_categoryAttributes[$attributeCode])) {
            $attribute = $this->_catalogCategory->getResource()->getAttribute($attributeCode);

            $this->_categoryAttributes[$attributeCode] = [
                'entity_type_id' => $attribute->getEntityTypeId(),
                'attribute_id' => $attribute->getId(),
                'table' => $attribute->getBackend()->getTable(),
                'is_global' => $attribute->getIsGlobal(),
                'is_static' => $attribute->isStatic(),
            ];
            unset($attribute);
        }

        if (!is_array($categoryIds)) {
            $categoryIds = [$categoryIds];
        }

        $attributeTable = $this->_categoryAttributes[$attributeCode]['table'];
        $select = $connection->select();
        $bind = [];
        if ($this->_categoryAttributes[$attributeCode]['is_static']) {
            $select->from(
                $this->getTable('catalog_category_entity'),
                ['value' => $attributeCode, 'entity_id' => 'entity_id']
            )->where(
                'entity_id IN(?)',
                $categoryIds,
                \Zend_Db::INT_TYPE
            );
        } elseif ($this->_categoryAttributes[$attributeCode]['is_global'] || $storeId == 0) {
            $select->from(
                ['t1' =>$this->getTable('catalog_category_entity')],
                [$identifierFiled]
            )->joinLeft(
                ['e' => $attributeTable],
                "t1.{$linkField} = e.{$linkField}",
                ['value']
            )->where(
                "t1.{$identifierFiled} IN(?)",
                $categoryIds,
                \Zend_Db::INT_TYPE
            )->where(
                'e.attribute_id = :attribute_id'
            )->where(
                'e.store_id = ?',
                0
            );

            $bind['attribute_id'] = $this->_categoryAttributes[$attributeCode]['attribute_id'];
        } else {
            $valueExpr = $connection->getCheckSql('t2.value_id > 0', 't2.value', 't1.value');
            $select->from(
                ['t1' => $attributeTable],
                [$identifierFiled => 'e.' . $identifierFiled, 'value' => $valueExpr]
            )->joinLeft(
                ['t2' => $attributeTable],
                "t1.{$linkField} = t2.{$linkField} AND t1.attribute_id = t2.attribute_id AND t2.store_id = :store_id",
                []
            )->joinLeft(
                ['e' => $this->getTable('catalog_category_entity')],
                "e.{$linkField} = t1.{$linkField}",
                []
            )->where(
                't1.store_id = ?',
                0
            )->where(
                't1.attribute_id = :attribute_id'
            )->where(
                "e.entity_id IN(?)",
                $categoryIds,
                \Zend_Db::INT_TYPE
            )->group('e.entity_id');

            $bind['attribute_id'] = $this->_categoryAttributes[$attributeCode]['attribute_id'];
            $bind['store_id'] = $storeId;
        }

        $rowSet = $connection->fetchAll($select, $bind);

        $attributes = [];
        foreach ($rowSet as $row) {
            $attributes[$row[$identifierFiled]] = $row['value'];
        }
        unset($rowSet);
        foreach ($categoryIds as $categoryId) {
            if (!isset($attributes[$categoryId])) {
                $attributes[$categoryId] = null;
            }
        }

        return $attributes;
    }

    /**
     * Retrieve product attribute
     *
     * @param string $attributeCode
     * @param int|array $productIds
     * @param string $storeId
     * @return array
     */
    public function _getProductAttribute($attributeCode, $productIds, $storeId)
    {
        $connection = $this->getConnection();
        if (!isset($this->_productAttributes[$attributeCode])) {
            $attribute = $this->productResource->getAttribute($attributeCode);

            $this->_productAttributes[$attributeCode] = [
                'entity_type_id' => $attribute->getEntityTypeId(),
                'attribute_id' => $attribute->getId(),
                'table' => $attribute->getBackend()->getTable(),
                'is_global' => $attribute->getIsGlobal(),
            ];
            unset($attribute);
        }

        if (!is_array($productIds)) {
            $productIds = [$productIds];
        }
        $bind = ['attribute_id' => $this->_productAttributes[$attributeCode]['attribute_id']];
        $select = $connection->select();
        $attributeTable = $this->_productAttributes[$attributeCode]['table'];
        if ($this->_productAttributes[$attributeCode]['is_global'] || $storeId == 0) {
            $select->from(
                $attributeTable,
                ['entity_id', 'value']
            )->where(
                'attribute_id = :attribute_id'
            )->where(
                'store_id = ?',
                0
            )->where(
                'entity_id IN(?)',
                $productIds,
                \Zend_Db::INT_TYPE
            );
        } else {
            $valueExpr = $connection->getCheckSql('t2.value_id > 0', 't2.value', 't1.value');
            $select->from(
                ['t1' => $attributeTable],
                ['entity_id', 'value' => $valueExpr]
            )->joinLeft(
                ['t2' => $attributeTable],
                't1.entity_id = t2.entity_id AND t1.attribute_id = t2.attribute_id AND t2.store_id=:store_id',
                []
            )->where(
                't1.store_id = ?',
                0
            )->where(
                't1.attribute_id = :attribute_id'
            )->where(
                't1.entity_id IN(?)',
                $productIds,
                \Zend_Db::INT_TYPE
            );
            $bind['store_id'] = $storeId;
        }

        $rowSet = $connection->fetchAll($select, $bind);

        $attributes = [];
        foreach ($rowSet as $row) {
            $attributes[$row['entity_id']] = $row['value'];
        }
        unset($rowSet);
        foreach ($productIds as $productId) {
            if (!isset($attributes[$productId])) {
                $attributes[$productId] = null;
            }
        }

        return $attributes;
    }

    /**
     * Prepare category parentId
     *
     * @param \Magento\Framework\DataObject $category
     * @return $this
     */
    protected function _prepareCategoryParentId(\Magento\Framework\DataObject $category)
    {
        if ($category->getPath() != $category->getId()) {
            $split = explode('/', $category->getPath() ?? '');
            $category->setParentId($split[count($split) - 2]);
        } else {
            $category->setParentId(0);
        }
        return $this;
    }

    /**
     * Prepare stores root categories
     *
     * @param \Magento\Store\Model\Store[] $stores
     * @return \Magento\Store\Model\Store[]
     */
    protected function _prepareStoreRootCategories($stores)
    {
        $rootCategoryIds = [];
        foreach ($stores as $store) {
            /* @var $store \Magento\Store\Model\Store */
            $rootCategoryIds[$store->getRootCategoryId()] = $store->getRootCategoryId();
        }
        if ($rootCategoryIds) {
            $categories = $this->_getCategories($rootCategoryIds);
        }
        foreach ($stores as $store) {
            /* @var $store \Magento\Store\Model\Store */
            $rootCategoryId = $store->getRootCategoryId();
            if (isset($categories[$rootCategoryId])) {
                $store->setRootCategoryPath($categories[$rootCategoryId]->getPath());
                $store->setRootCategory($categories[$rootCategoryId]);
            } else {
                unset($stores[$store->getId()]);
            }
        }
        return $stores;
    }

    /**
     * Retrieve categories objects
     *
     * Either $categoryIds or $path (with ending slash) must be specified
     *
     * @param int|array $categoryIds
     * @param int $storeId
     * @param string $path
     * @return array
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     * @SuppressWarnings(PHPMD.NPathComplexity)
     */
    protected function _getCategories($categoryIds, $storeId = null, $path = null)
    {
        $isActiveAttribute = $this->_eavConfig->getAttribute(\Magento\Catalog\Model\Category::ENTITY, 'is_active');
        $categories = [];
        $connection = $this->getConnection();

        $meta = $this->getMetadataPool()->getMetadata(CategoryInterface::class);
        $linkField = $meta->getLinkField();

        if (!is_array($categoryIds)) {
            $categoryIds = [$categoryIds];
        }
        $isActiveExpr = $connection->getCheckSql('c.value_id > 0', 'c.value', 'c.value');
        $select = $connection->select()->from(
            ['main_table' => $this->getTable('catalog_category_entity')],
            [
                'main_table.entity_id',
                'main_table.parent_id',
                'main_table.level',
                'is_active' => $isActiveExpr,
                'main_table.path'
            ]
        );

        // Prepare variables for checking whether categories belong to store
        if ($path === null) {
            $select->where('main_table.entity_id IN(?)', $categoryIds, \Zend_Db::INT_TYPE);
        } else {
            // Ensure that path ends with '/', otherwise we can get wrong results - e.g. $path = '1/2' will get '1/20'
            if (substr($path, -1) != '/') {
                $path .= '/';
            }

            $select->where('main_table.path LIKE ?', $path . '%')->order('main_table.path');
        }
        $table = $this->getTable('catalog_category_entity_int');
        $select->joinLeft(
            ['d' => $table],
            "d.attribute_id = :attribute_id AND d.store_id = 0 AND d.{$linkField} = main_table.{$linkField}",
            []
        )->joinLeft(
            ['c' => $table],
            "c.attribute_id = :attribute_id AND c.store_id = :store_id AND c.{$linkField} = main_table.{$linkField}",
            []
        );

        if ($storeId !== null) {
            $rootCategoryPath = $this->getStores($storeId)->getRootCategoryPath();
            $rootCategoryPathLength = $rootCategoryPath !== null ? strlen($rootCategoryPath) : 0;
        }
        $bind = ['attribute_id' => (int)$isActiveAttribute->getId(), 'store_id' => (int)$storeId];
        $rowSet = $connection->fetchAll($select, $bind);

        foreach ($rowSet as $row) {
            if ($storeId !== null) {
                $rowPath = $row['path'] ?? '';
                // Check the category to be either store's root or its descendant
                // First - check that category's start is the same as root category
                // @phpstan-ignore-next-line
                if (substr($rowPath, 0, $rootCategoryPathLength) != $rootCategoryPath) {
                    continue;
                }
                // Second - check non-root category - that it's really a descendant, not a simple string match
                // @phpstan-ignore-next-line
                if (strlen($rowPath) > $rootCategoryPathLength && $row['path'][$rootCategoryPathLength] != '/') {
                    continue;
                }
            }

            $category = new \Magento\Framework\DataObject($row);
            $category->setId($row['entity_id']);
            $category->setEntityId($row['entity_id']);
            $category->setStoreId($storeId);
            $this->_prepareCategoryParentId($category);

            $categories[$category->getId()] = $category;
        }
        unset($rowSet);

        if ($storeId !== null && $categories && isset($category)) {
            foreach (['name', 'url_key', 'url_path'] as $attributeCode) {
                $attributes = $this->_getCategoryAttribute(
                    $attributeCode,
                    array_keys($categories),
                    $category->getStoreId()
                );
                foreach ($attributes as $categoryId => $attributeValue) {
                    $categories[$categoryId]->setData($attributeCode, $attributeValue);
                }
            }
        }

        return $categories;
    }

    /**
     * Retrieve category data object
     *
     * @param int $categoryId
     * @param int $storeId
     * @return \Magento\Framework\DataObject|false
     */
    public function getCategory($categoryId, $storeId)
    {
        if (!$categoryId || !$storeId) {
            return false;
        }

        $categories = $this->_getCategories($categoryId, $storeId);
        if (isset($categories[$categoryId])) {
            return $categories[$categoryId];
        }
        return false;
    }

    /**
     * Retrieve categories data objects by their ids. Return only categories that belong to specified store.
     *
     * @param int|array $categoryIds
     * @param int $storeId
     * @return array|false
     */
    public function getCategories($categoryIds, $storeId)
    {
        if (!$categoryIds || !$storeId) {
            return false;
        }

        return $this->_getCategories($categoryIds, $storeId);
    }

    /**
     * Retrieve Product data objects
     *
     * @param int|array $productIds
     * @param int       $storeId
     * @param int       $entityId
     * @param int      &$lastEntityId
     * @return array
     */
    protected function _getProducts($productIds, $storeId, $entityId, &$lastEntityId)
    {
        $products = [];
        $websiteId = $this->_storeManager->getStore($storeId)->getWebsiteId();
        $connection = $this->getConnection();
        if ($productIds !== null) {
            if (!is_array($productIds)) {
                $productIds = [$productIds];
            }
        }
        $bind = ['website_id' => (int)$websiteId, 'entity_id' => (int)$entityId];
        $select = $connection->select()->useStraightJoin(
            true
        )->from(
            ['e' => $this->getTable('catalog_product_entity')],
            ['entity_id']
        )->join(
            ['w' => $this->getTable('catalog_product_website')],
            'e.entity_id = w.product_id AND w.website_id = :website_id',
            []
        )->where(
            'e.entity_id > :entity_id'
        )->order(
            'e.entity_id'
        )->limit(
            $this->_productLimit
        );
        if ($productIds !== null) {
            $select->where('e.entity_id IN(?)', $productIds, \Zend_Db::INT_TYPE);
        }

        $rowSet = $connection->fetchAll($select, $bind);
        foreach ($rowSet as $row) {
            $product = new \Magento\Framework\DataObject($row);
            $product->setId($row['entity_id']);
            $product->setEntityId($row['entity_id']);
            $product->setCategoryIds([]);
            $product->setStoreId($storeId);
            $products[$product->getId()] = $product;
            $lastEntityId = $product->getId();
        }

        unset($rowSet);

        if ($products) {
            $select = $connection->select()->from(
                $this->getTable('catalog_category_product'),
                ['product_id', 'category_id']
            )->where(
                'product_id IN(?)',
                array_keys($products),
                \Zend_Db::INT_TYPE
            );
            $categories = $connection->fetchAll($select);
            foreach ($categories as $category) {
                $productId = $category['product_id'];
                $categoryIds = $products[$productId]->getCategoryIds();
                $categoryIds[] = $category['category_id'];
                $products[$productId]->setCategoryIds($categoryIds);
            }

            foreach (['name', 'url_key', 'url_path'] as $attributeCode) {
                $attributes = $this->_getProductAttribute($attributeCode, array_keys($products), $storeId);
                foreach ($attributes as $productId => $attributeValue) {
                    $products[$productId]->setData($attributeCode, $attributeValue);
                }
            }
        }

        return $products;
    }

    /**
     * Retrieve Product data object
     *
     * @param int $productId
     * @param int $storeId
     * @return \Magento\Framework\DataObject|false
     */
    public function getProduct($productId, $storeId)
    {
        $entityId = 0;
        $products = $this->_getProducts($productId, $storeId, 0, $entityId);
        if (isset($products[$productId])) {
            return $products[$productId];
        }
        return false;
    }

    /**
     * Retrieve Product data objects for store
     *
     * @param int  $storeId
     * @param int &$lastEntityId
     * @return array
     */
    public function getProductsByStore($storeId, &$lastEntityId)
    {
        return $this->_getProducts(null, $storeId, $lastEntityId, $lastEntityId);
    }

    /**
     * Get rewrite by product store
     *
     * Retrieve rewrites and visibility by store
     * Input array format:
     * product_id as key and store_id as value
     * Output array format (product_id as key)
     * store_id     int; store id
     * visibility   int; visibility for store
     * url_rewrite  string; rewrite URL for store
     *
     * @param array $products
     * @return array
     */
    public function getRewriteByProductStore(array $products)
    {
        $result = [];

        if (empty($products)) {
            return $result;
        }
        $connection = $this->getConnection();

        $storesProducts = [];
        foreach ($products as $productId => $storeId) {
            $storesProducts[$storeId][] = $productId;
        }

        foreach ($storesProducts as $storeId => $productIds) {
            $select = $connection->select()->from(
                ['i' => $this->tableMaintainer->getMainTable($storeId)],
                ['product_id', 'store_id', 'visibility']
            )->joinLeft(
                ['u' => $this->getMainTable()],
                'i.product_id = u.entity_id AND i.store_id = u.store_id'
                . ' AND u.entity_type = "' . ProductUrlRewriteGenerator::ENTITY_TYPE . '"',
                ['request_path']
            )->joinLeft(
                ['r' => $this->getTable('catalog_url_rewrite_product_category')],
                'u.url_rewrite_id = r.url_rewrite_id AND r.category_id is NULL',
                []
            );

            $bind = [];
            foreach ($productIds as $productId) {
                $catId = $this->_storeManager->getStore($storeId)->getRootCategoryId();
                $productBind = 'product_id' . $productId;
                $storeBind = 'store_id' . $storeId;
                $catBind = 'category_id' . $catId;
                $bindArray = [
                    'i.product_id = :' . $productBind,
                    'i.store_id = :' . $storeBind,
                    'i.category_id = :' . $catBind
                ];
                $cond = '(' . implode(' AND ', $bindArray) . ')';
                $bind[$productBind] = $productId;
                $bind[$storeBind] = $storeId;
                $bind[$catBind] = $catId;
                $select->orWhere($cond);
            }

            $rowSet = $connection->fetchAll($select, $bind);
            foreach ($rowSet as $row) {
                $result[$row['product_id']] = [
                    'store_id' => $row['store_id'],
                    'visibility' => $row['visibility'],
                    'url_rewrite' => $row['request_path'],
                ];
            }
        }

        return $result;
    }

    /**
     * Method to get metadata pool.
     *
     * @return \Magento\Framework\EntityManager\MetadataPool
     */
    private function getMetadataPool()
    {
        if (null === $this->metadataPool) {
            $this->metadataPool = \Magento\Framework\App\ObjectManager::getInstance()
                ->get(\Magento\Framework\EntityManager\MetadataPool::class);
        }
        return $this->metadataPool;
    }
}
