<?php
/**
 * Copyright 2014 Adobe
 * All Rights Reserved.
 */
namespace Magento\Elasticsearch\SearchAdapter;

use Magento\Catalog\Model\Product;
use Magento\Catalog\Model\ResourceModel\Eav\Attribute;
use Magento\Eav\Model\ResourceModel\Entity\Attribute\Option\Collection;
use Magento\Framework\Search\EngineResolverInterface;
use Magento\TestFramework\Helper\Bootstrap;
use Magento\TestModuleCatalogSearch\Model\SearchEngineVersionReader;

/**
 * Class AdapterTest
 *
 * @magentoDbIsolation disabled
 * @magentoDataFixture Magento/Framework/Search/_files/products.php
 *
 * Important: Please make sure that each integration test file works with unique elastic search index. In order to
 * achieve this, use @ magentoConfigFixture to pass unique value for index_prefix for every test
 * method. E.g. '@ magentoConfigFixture current_store catalog/search/elasticsearch7_index_prefix adaptertest'
 *
 * In ElasticSearch, a reindex is required if the test includes a new data fixture with new items to search, see
 * testAdvancedSearchDateField().
 * phpstan:ignore
 *
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 */
class AdapterTest extends \PHPUnit\Framework\TestCase
{
    /**
     * @var \Magento\Framework\Search\AdapterInterface
     */
    private $adapter;

    /**
     * @var \Magento\Framework\Search\Request\Builder
     */
    private $requestBuilder;

    /**
     * @var \Magento\Framework\ObjectManagerInterface
     */
    private $objectManager;

    /**
     * @inheritdoc
     */
    protected function setUp(): void
    {
        $this->objectManager = Bootstrap::getObjectManager();

        /** @var \Magento\Framework\Search\Request\Config\Converter $converter */
        $converter = $this->objectManager->create(\Magento\Framework\Search\Request\Config\Converter::class);

        $document = new \DOMDocument();
        $document->load($this->getRequestConfigPath());
        $requestConfig = $converter->convert($document);

        /** @var \Magento\Framework\Search\Request\Config $config */
        $config = $this->objectManager->create(\Magento\Framework\Search\Request\Config::class);
        $config->merge($requestConfig);

        $this->requestBuilder = $this->objectManager->create(
            \Magento\Framework\Search\Request\Builder::class,
            ['config' => $config]
        );

        $this->adapter = $this->createAdapter();

        $indexer = $this->objectManager->create(\Magento\Indexer\Model\Indexer::class);
        $indexer->load('catalogsearch_fulltext');
        $indexer->reindexAll();
    }

    /**
     * Get request config path
     *
     * @return string
     */
    protected function getRequestConfigPath()
    {
        return __DIR__ . '/../_files/requests.xml';
    }

    /**
     * @return \Magento\Framework\Search\AdapterInterface
     */
    protected function createAdapter()
    {
        return $this->objectManager->create(\Magento\Search\Model\AdapterFactory::class)->create();
    }

    /**
     * Make sure that correct engine is set
     */
    protected function assertPreConditions(): void
    {
        $currentEngine = $this->objectManager->get(EngineResolverInterface::class)->getCurrentSearchEngine();
        $installedEngine = $this->objectManager->get(SearchEngineVersionReader::class)->getFullVersion();
        $this->assertEquals(
            $installedEngine,
            $currentEngine,
            sprintf(
                'Search engine configuration "%s" is not compatible with the installed version',
                $currentEngine
            )
        );
    }

    /**
     * @return \Magento\Framework\Search\Response\QueryResponse
     */
    private function executeQuery()
    {
        /** @var \Magento\Framework\Search\RequestInterface $queryRequest */
        $queryRequest = $this->requestBuilder->create();

        $queryResponse = $this->adapter->query($queryRequest);

        return $queryResponse;
    }

    /**
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testMatchQuery()
    {
        $this->requestBuilder->bind('fulltext_search_query', 'socks');
        $this->requestBuilder->setRequestName('one_match');

        $queryResponse = $this->executeQuery();

        $this->assertEquals(1, $queryResponse->count());
    }

    /**
     * @magentoAppIsolation enabled
     */
    public function testMatchOrderedQuery()
    {
        $this->markTestSkipped(
            'Elasticsearch not expected to order results by default. Test is skipped intentionally.'
        );
        $expectedIds = [8, 7, 6, 5, 2];

        //Verify that MySql randomized result of equal-weighted results
        //consistently ordered by entity_id after multiple calls
        $this->requestBuilder->bind('fulltext_search_query', 'shorts');
        $this->requestBuilder->setRequestName('one_match');
        $queryResponse = $this->executeQuery();

        $this->assertEquals(5, $queryResponse->count());
        $this->assertOrderedProductIds($queryResponse, $expectedIds);
    }

    /**
     * @param \Magento\Framework\Search\Response\QueryResponse $queryResponse
     * @param array $expectedIds
     */
    private function assertOrderedProductIds($queryResponse, $expectedIds)
    {
        $actualIds = [];
        foreach ($queryResponse as $document) {
            /** @var \Magento\Framework\Api\Search\Document $document */
            $actualIds[] = $document->getId();
        }
        $this->assertEquals($expectedIds, $actualIds);
    }

    /**
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testMatchQueryFilters()
    {
        $this->requestBuilder->bind('fulltext_search_query', 'socks');
        $this->requestBuilder->bind('pidm_from', 11);
        $this->requestBuilder->bind('pidm_to', 17);
        $this->requestBuilder->bind('pidsh', 18);
        $this->requestBuilder->setRequestName('one_match_filters');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(1, $queryResponse->count());
    }

    /**
     * Range filter test with all fields filled
     *
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testRangeFilterWithAllFields()
    {
        $this->requestBuilder->bind('range_filter_from', 11);
        $this->requestBuilder->bind('range_filter_to', 17);
        $this->requestBuilder->setRequestName('range_filter');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(3, $queryResponse->count());
    }

    /**
     * Range filter test with all fields filled
     *
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testRangeFilterWithoutFromField()
    {
        $this->requestBuilder->bind('range_filter_to', 18);
        $this->requestBuilder->setRequestName('range_filter_without_from_field');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(4, $queryResponse->count());
    }

    /**
     * Range filter test with all fields filled
     *
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testRangeFilterWithoutToField()
    {
        $this->requestBuilder->bind('range_filter_from', 14);
        $this->requestBuilder->setRequestName('range_filter_without_to_field');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(4, $queryResponse->count());
    }

    /**
     * Term filter test
     *
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testTermFilter()
    {
        $this->requestBuilder->bind('request.price', 18);
        $this->requestBuilder->setRequestName('term_filter');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(1, $queryResponse->count());
        $this->assertEquals(4, $queryResponse->getIterator()->offsetGet(0)->getId());
    }

    /**
     * Term filter test
     *
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testTermFilterArray()
    {
        $this->requestBuilder->bind('request.price', [17, 18]);
        $this->requestBuilder->setRequestName('term_filter');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(2, $queryResponse->count());
    }

    /**
     * @param \Magento\Framework\Search\Response\QueryResponse $queryResponse
     * @param array $expectedIds
     */
    private function assertProductIds($queryResponse, $expectedIds)
    {
        $actualIds = [];
        foreach ($queryResponse as $document) {
            /** @var \Magento\Framework\Api\Search\Document $document */
            $actualIds[] = $document->getId();
        }
        sort($actualIds);
        sort($expectedIds);
        $this->assertEquals($expectedIds, $actualIds);
    }

    /**
     * Term filter test
     *
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testWildcardFilter()
    {
        $expectedIds = [1, 3, 5];
        $this->requestBuilder->bind('wildcard_filter', 're');
        $this->requestBuilder->setRequestName('one_wildcard');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(3, $queryResponse->count());
        $this->assertProductIds($queryResponse, $expectedIds);
    }

    /**
     * Request limits test
     *
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testSearchLimit()
    {
        $this->requestBuilder->bind('wildcard_filter', 're');
        $this->requestBuilder->setFrom(1);
        $this->requestBuilder->setSize(2);
        $this->requestBuilder->setRequestName('one_wildcard');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(2, $queryResponse->count());
    }

    /**
     * Bool filter test
     *
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testBoolFilter()
    {
        $expectedIds = [2, 3];
        $this->requestBuilder->bind('must_range_filter1_from', 13);
        $this->requestBuilder->bind('must_range_filter1_to', 22);
        $this->requestBuilder->bind('should_term_filter1', 13);
        $this->requestBuilder->bind('should_term_filter2', 15);
        $this->requestBuilder->bind('should_term_filter3', 17);
        $this->requestBuilder->bind('should_term_filter4', 18);
        $this->requestBuilder->bind('not_term_filter1', 13);
        $this->requestBuilder->bind('not_term_filter2', 18);
        $this->requestBuilder->setRequestName('bool_filter');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(count($expectedIds), $queryResponse->count());
        $this->assertProductIds($queryResponse, $expectedIds);
    }

    /**
     * Test bool filter with nested negative bool filter
     *
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testBoolFilterWithNestedNegativeBoolFilter()
    {
        $expectedIds = [1];
        $this->requestBuilder->bind('not_range_filter_from', 14);
        $this->requestBuilder->bind('not_range_filter_to', 20);
        $this->requestBuilder->bind('nested_not_term_filter', 13);
        $this->requestBuilder->setRequestName('bool_filter_with_nested_bool_filter');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(count($expectedIds), $queryResponse->count());
        $this->assertProductIds($queryResponse, $expectedIds);
    }

    /**
     * Test range inside nested negative bool filter
     *
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testBoolFilterWithNestedRangeInNegativeBoolFilter()
    {
        $expectedIds = [1, 5];
        $this->requestBuilder->bind('nested_must_range_filter_from', 14);
        $this->requestBuilder->bind('nested_must_range_filter_to', 18);
        $this->requestBuilder->setRequestName('bool_filter_with_range_in_nested_negative_filter');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(count($expectedIds), $queryResponse->count());
        $this->assertProductIds($queryResponse, $expectedIds);
    }

    /**
     * Sample Advanced search request test
     *
     * @dataProvider elasticSearchAdvancedSearchDataProvider
     * @magentoAppIsolation enabled
     * @param string $nameQuery
     * @param string $descriptionQuery
     * @param array $rangeFilter
     * @param int $expectedRecordsCount
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testSimpleAdvancedSearch(
        $nameQuery,
        $descriptionQuery,
        $rangeFilter,
        $expectedRecordsCount
    ) {
        $this->requestBuilder->bind('name_query', $nameQuery);
        $this->requestBuilder->bind('description_query', $descriptionQuery);
        $this->requestBuilder->bind('request.from_price', $rangeFilter['from']);
        $this->requestBuilder->bind('request.to_price', $rangeFilter['to']);
        $this->requestBuilder->setRequestName('advanced_search_test');

        $queryResponse = $this->executeQuery();
        $this->assertEquals($expectedRecordsCount, $queryResponse->count());
    }

    /**
     * Elastic Search specific data provider for advanced search test.
     *
     * The expected array is for Elastic Search is different that the one for MySQL
     * because sometimes more matches are returned. For instance, 3rd index below
     * will return 3 matches instead of 1 (which is what MySQL returns).
     *
     * @return array
     */
    public function elasticSearchAdvancedSearchDataProvider()
    {
        return [
            ['white', 'shorts', ['from' => '16', 'to' => '18'], 0],
            ['white', 'shorts',['from' => '12', 'to' => '18'], 1],
            ['black', 'tshirts', ['from' => '12', 'to' => '20'], 0],
            ['shorts', 'green', ['from' => '12', 'to' => '22'], 3],
            //Search with empty fields/values
            ['white', '  ', ['from' => '12', 'to' => '22'], 1],
            ['  ', 'green', ['from' => '12', 'to' => '22'], 2]
        ];
    }

    /**
     * @magentoAppIsolation enabled
     * @magentoDataFixture Magento/Framework/Search/_files/filterable_attribute.php
     */
    public function testCustomFilterableAttribute()
    {
        // Reindex Elastic Search since filterable_attribute data fixture added new fields to be indexed
        $this->reindexAll();
        /** @var Attribute $attribute */
        $attribute = $this->objectManager->get(Attribute::class)
            ->loadByCode(Product::ENTITY, 'select_attribute');
        /** @var Collection $selectOptions */
        $selectOptions = $this->objectManager
            ->create(Collection::class)
            ->setAttributeFilter($attribute->getId());

        $attribute->loadByCode(Product::ENTITY, 'multiselect_attribute');
        /** @var Collection $multiselectOptions */
        $multiselectOptions = $this->objectManager
            ->create(Collection::class)
            ->setAttributeFilter($attribute->getId());

        $this->requestBuilder->bind('select_attribute', $selectOptions->getLastItem()->getId());
        $this->requestBuilder->bind('multiselect_attribute', $multiselectOptions->getLastItem()->getId());
        $this->requestBuilder->bind('price.from', 98);
        $this->requestBuilder->bind('price.to', 100);
        $this->requestBuilder->bind('category_ids', 2);
        $this->requestBuilder->setRequestName('filterable_custom_attributes');
        $queryResponse = $this->executeQuery();
        $this->assertEquals(1, $queryResponse->count());
    }

    /**
     * Test filtering by two attributes.
     *
     * @magentoAppIsolation enabled
     * @magentoDataFixture Magento/Framework/Search/_files/filterable_attributes.php
     * @dataProvider filterByAttributeValuesDataProvider
     * @param string $requestName
     * @param array $additionalData
     * @return void
     */
    public function testFilterByAttributeValues($requestName, $additionalData)
    {
        // Reindex Elastic Search since filterable_attribute data fixture added new fields to be indexed
        $this->reindexAll();
        /** @var Attribute $attribute */
        $attribute = $this->objectManager->get(Attribute::class)
            ->loadByCode(Product::ENTITY, 'select_attribute_1');
        /** @var Collection $selectOptions1 */
        $selectOptions1 = $this->objectManager
            ->create(Collection::class)
            ->setAttributeFilter($attribute->getId());
        $attribute->loadByCode(Product::ENTITY, 'select_attribute_2');
        /** @var Collection $selectOptions2 */
        $selectOptions2 = $this->objectManager
            ->create(Collection::class)
            ->setAttributeFilter($attribute->getId());
        $this->requestBuilder->bind('select_attribute_1', $selectOptions1->getLastItem()->getId());
        $this->requestBuilder->bind('select_attribute_2', $selectOptions2->getLastItem()->getId());
        // Binds for specific containers.
        foreach ($additionalData as $key => $value) {
            $this->requestBuilder->bind($key, $value);
        }
        $this->requestBuilder->setRequestName($requestName);
        $queryResponse = $this->executeQuery();
        $this->assertEquals(1, $queryResponse->count());
    }

    /**
     * Advanced search request using date product attribute
     *
     * @param $rangeFilter
     * @param $expectedRecordsCount
     * @magentoDataFixture Magento/Framework/Search/_files/date_attribute.php
     * @magentoAppIsolation enabled
     * @dataProvider dateDataProvider
     */
    public function testAdvancedSearchDateField($rangeFilter, $expectedRecordsCount)
    {
        // Reindex Elastic Search since date_attribute data fixture added new fields to be indexed
        $this->reindexAll();
        $this->requestBuilder->bind('date.from', $rangeFilter['from']);
        $this->requestBuilder->bind('date.to', $rangeFilter['to']);
        $this->requestBuilder->setRequestName('advanced_search_date_field');

        $queryResponse = $this->executeQuery();
        $this->assertEquals($expectedRecordsCount, $queryResponse->count());
    }

    /**
     * @magentoDataFixture Magento/Framework/Search/_files/product_configurable.php
     * @magentoAppIsolation enabled
     * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod
     */
    public function testAdvancedSearchCompositeProductWithOutOfStockOption()
    {
        /** @var Attribute $attribute */
        $attribute = $this->objectManager->get(Attribute::class)
            ->loadByCode(Product::ENTITY, 'test_configurable');
        /** @var Collection $selectOptions */
        $selectOptions = $this->objectManager
            ->create(Collection::class)
            ->setAttributeFilter($attribute->getId());

        $visibility = [
            \Magento\Catalog\Model\Product\Visibility::VISIBILITY_IN_SEARCH,
            \Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH,
        ];

        $firstOption = $selectOptions->getFirstItem();
        $firstOptionId = $firstOption->getId();
        $this->requestBuilder->bind('test_configurable', $firstOptionId);
        $this->requestBuilder->bind('visibility', $visibility);
        $this->requestBuilder->setRequestName('filter_out_of_stock_child');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(0, $queryResponse->count());

        $secondOption = $selectOptions->getLastItem();
        $secondOptionId = $secondOption->getId();
        $this->requestBuilder->bind('test_configurable', $secondOptionId);
        $this->requestBuilder->bind('visibility', $visibility);
        $this->requestBuilder->setRequestName('filter_out_of_stock_child');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(1, $queryResponse->count());
    }

    /**
     * @magentoDataFixture Magento/Framework/Search/_files/product_configurable_with_disabled_child.php
     * @magentoAppIsolation enabled
     */
    public function testAdvancedSearchCompositeProductWithDisabledChild()
    {
        // Reindex Elastic Search since date_attribute data fixture added new fields to be indexed
        $this->reindexAll();
        /** @var Attribute $attribute */
        $attribute = $this->objectManager->get(Attribute::class)
            ->loadByCode(Product::ENTITY, 'test_configurable');
        /** @var Collection $selectOptions */
        $selectOptions = $this->objectManager
            ->create(Collection::class)
            ->setAttributeFilter($attribute->getId());

        $firstOption = $selectOptions->getFirstItem();
        $firstOptionId = $firstOption->getId();
        $this->requestBuilder->bind('test_configurable', $firstOptionId);
        $this->requestBuilder->setRequestName('filter_out_of_stock_child');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(0, $queryResponse->count());

        $secondOption = $selectOptions->getLastItem();
        $secondOptionId = $secondOption->getId();
        $this->requestBuilder->bind('test_configurable', $secondOptionId);
        $this->requestBuilder->setRequestName('filter_out_of_stock_child');

        $queryResponse = $this->executeQuery();
        $this->assertEquals(0, $queryResponse->count());
    }

    /**
     * @magentoDataFixture Magento/Framework/Search/_files/search_weight_products.php
     * @magentoAppIsolation enabled
     */
    public function testSearchQueryBoost()
    {
        // Reindex Elastic Search since date_attribute data fixture added new fields to be indexed
        $this->reindexAll();
        $this->requestBuilder->bind('query', 'antarctica');
        $this->requestBuilder->setRequestName('search_boost');
        $queryResponse = $this->executeQuery();
        $this->assertEquals(2, $queryResponse->count());

        /** @var \Magento\Framework\Api\Search\DocumentInterface $products */
        $products = iterator_to_array($queryResponse);
        /*
         * Products now contain search query in two attributes which are boosted with the same value: 1
         * The search keyword (antarctica) is mentioned twice only in one of the products.
         * And, as both attributes have the same search weight and boost, we expect that
         * the product with doubled keyword should be prioritized by a search engine as a most relevant
         *  and therefore will be first in the search result.
         */
        $firstProduct = reset($products);
        $this->assertEquals(1222, $firstProduct->getId());
        $secondProduct = end($products);
        $this->assertEquals(1221, $secondProduct->getId());

        /** @var \Magento\Catalog\Api\ProductAttributeRepositoryInterface $productAttributeRepository */
        $productAttributeRepository = $this->objectManager->get(
            \Magento\Catalog\Api\ProductAttributeRepositoryInterface::class
        );

        /**
         * Now we're going to change search weight of one of the attributes to ensure that it will affect
         * how products are ordered in the search result
         */
        /** @var Attribute $attribute */
        $attribute = $productAttributeRepository->get('name');
        $attribute->setSearchWeight(20);
        $productAttributeRepository->save($attribute);

        $this->requestBuilder->bind('query', 'antarctica');
        $this->requestBuilder->setRequestName('search_boost_name');
        $queryResponse = $this->executeQuery();
        $this->assertEquals(2, $queryResponse->count());

        /** @var \Magento\Framework\Api\Search\DocumentInterface $products */
        $products = iterator_to_array($queryResponse);
        /*
         * As for the first case, we have two the same products.
         * One of them has search keyword mentioned twice in the field which has search weight 1.
         * However, we've changed the search weight of another attribute
         *   which has only one mention of the search keyword in another product.
         *
         * The case is mostly the same but search weight has been changed and we expect that
         *   less relevant (with only one mention) but more boosted (search weight = 20) product
         *   will be prioritized higher than more relevant, but less boosted product.
         */
        $firstProduct = reset($products);
        $this->assertEquals(1221, $firstProduct->getId());
        //$firstProduct
        $secondProduct = end($products);
        $this->assertEquals(1222, $secondProduct->getId());
    }

    /**
     * Perform full reindex
     *
     * @return void
     */
    private function reindexAll()
    {
        $indexer = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(
            \Magento\Indexer\Model\Indexer::class
        );
        $indexer->load('catalogsearch_fulltext');
        $indexer->reindexAll();
    }

    /**
     * Date data provider
     *
     * @return array
     */
    public function dateDataProvider()
    {
        return [
            [['from' => '1999-12-31T00:00:00Z', 'to' => '2000-01-01T00:00:00Z'], 1],
            [['from' => '2000-02-01T00:00:00Z', 'to' => ''], 0],
        ];
    }

    public function filterByAttributeValuesDataProvider()
    {
        return [
            'quick_search_container' => [
                'quick_search_container',
                [
                    // Make sure search uses "should" cause.
                    'search_term' => 'Simple Product',
                ],
            ],
            'advanced_search_container' => [
                'advanced_search_container',
                [
                    // Make sure "wildcard" feature works.
                    'sku' => 'simple_product',
                ]
            ],
            'catalog_view_container' => [
                'catalog_view_container',
                [
                    'category_ids' => 2
                ]
            ],
            'quick search by date' => [
                'quick_search_container',
                [
                    'search_term' => '2000-10-30',
                ],
            ],
        ];
    }
}
