<?php

namespace WapplerSystems\Meilisearch\Domain\Search\ResultSet;

/***************************************************************
 *  Copyright notice
 *
 *  (c) 2015-2016 Timo Schmidt <timo.schmidt@dkd.de>
 *  All rights reserved
 *
 *  This script is part of the TYPO3 project. The TYPO3 project is
 *  free software; you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation; either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  The GNU General Public License can be found at
 *  http://www.gnu.org/copyleft/gpl.html.
 *
 *  This script is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  This copyright notice MUST APPEAR in all copies of the script!
 ***************************************************************/

use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\QueryFields;
use WapplerSystems\Meilisearch\Domain\Search\Query\QueryBuilder;
use WapplerSystems\Meilisearch\Domain\Search\Query\Query;
use WapplerSystems\Meilisearch\Domain\Search\Query\SearchQuery;
use WapplerSystems\Meilisearch\Domain\Search\ResultSet\Result\Parser\ResultParserRegistry;
use WapplerSystems\Meilisearch\Domain\Search\ResultSet\Result\SearchResult;
use WapplerSystems\Meilisearch\Domain\Search\ResultSet\Result\SearchResultCollection;
use WapplerSystems\Meilisearch\Domain\Search\SearchRequest;
use WapplerSystems\Meilisearch\Domain\Search\SearchRequestAware;
use WapplerSystems\Meilisearch\Domain\Variants\VariantsProcessor;
use WapplerSystems\Meilisearch\Query\Modifier\Modifier;
use WapplerSystems\Meilisearch\Search;
use WapplerSystems\Meilisearch\Search\QueryAware;
use WapplerSystems\Meilisearch\Search\SearchAware;
use WapplerSystems\Meilisearch\Search\SearchComponentManager;
use WapplerSystems\Meilisearch\System\Configuration\TypoScriptConfiguration;
use WapplerSystems\Meilisearch\System\Logging\MeilisearchLogManager;
use WapplerSystems\Meilisearch\System\Meilisearch\Document\Document;
use WapplerSystems\Meilisearch\System\Meilisearch\ResponseAdapter;
use WapplerSystems\Meilisearch\System\Meilisearch\MeilisearchIncompleteResponseException;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use WapplerSystems\Meilisearch\Domain\Search\ResultSet\Result\SearchResultBuilder;
use TYPO3\CMS\Extbase\Object\ObjectManager;
use TYPO3\CMS\Extbase\Object\ObjectManagerInterface;

/**
 * The SearchResultSetService is responsible to build a SearchResultSet from a SearchRequest.
 * It encapsulates the logic to trigger a search in order to be able to reuse it in multiple places.
 *
 * @author Timo Schmidt <timo.schmidt@dkd.de>
 */
class SearchResultSetService
{

    /**
     * Track, if the number of results per page has been changed by the current request
     *
     * @var bool
     */
    protected $resultsPerPageChanged = false;

    /**
     * @var Search
     */
    protected $search;

    /**
     * @var SearchResultSet
     */
    protected $lastResultSet = null;

    /**
     * @var boolean
     */
    protected $isMeilisearchAvailable = false;

    /**
     * @var TypoScriptConfiguration
     */
    protected $typoScriptConfiguration;

    /**
     * @var MeilisearchLogManager
     */
    protected $logger = null;

    /**
     * @var SearchResultBuilder
     */
    protected $searchResultBuilder;

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

    /**
     * @var ObjectManagerInterface
     */
    protected $objectManager;

    /**
     * @param TypoScriptConfiguration $configuration
     * @param Search $search
     * @param MeilisearchLogManager $meilisearchLogManager
     * @param SearchResultBuilder $resultBuilder
     * @param QueryBuilder $queryBuilder
     */
    public function __construct(TypoScriptConfiguration $configuration, Search $search, MeilisearchLogManager $meilisearchLogManager = null, SearchResultBuilder $resultBuilder = null, QueryBuilder $queryBuilder = null)
    {
        $this->search = $search;
        $this->typoScriptConfiguration = $configuration;
        $this->logger = $meilisearchLogManager ?? GeneralUtility::makeInstance(MeilisearchLogManager::class, /** @scrutinizer ignore-type */ __CLASS__);
        $this->searchResultBuilder = $resultBuilder ?? GeneralUtility::makeInstance(SearchResultBuilder::class);
        $this->queryBuilder = $queryBuilder ?? GeneralUtility::makeInstance(QueryBuilder::class, /** @scrutinizer ignore-type */ $configuration, /** @scrutinizer ignore-type */ $meilisearchLogManager);
    }

    /**
     * @param ObjectManagerInterface $objectManager
     */
    public function injectObjectManager(ObjectManagerInterface $objectManager)
    {
        $this->objectManager = $objectManager;
    }

    /**
     * @param bool $useCache
     * @return bool
     */
    public function getIsMeilisearchAvailable($useCache = true)
    {
        $this->isMeilisearchAvailable = $this->search->ping($useCache);
        return $this->isMeilisearchAvailable;
    }

    /**
     * Retrieves the used search instance.
     *
     * @return Search
     */
    public function getSearch()
    {
        return $this->search;
    }

    /**
     * @param Query $query
     * @param SearchRequest $searchRequest
     */
    protected function initializeRegisteredSearchComponents(Query $query, SearchRequest $searchRequest)
    {
        $searchComponents = $this->getRegisteredSearchComponents();

        foreach ($searchComponents as $searchComponent) {
            /** @var Search\SearchComponent $searchComponent */
            $searchComponent->setSearchConfiguration($this->typoScriptConfiguration->getSearchConfiguration());

            if ($searchComponent instanceof QueryAware) {
                $searchComponent->setQuery($query);
            }

            if ($searchComponent instanceof SearchRequestAware) {
                $searchComponent->setSearchRequest($searchRequest);
            }

            $searchComponent->initializeSearchComponent();
        }
    }

    /**
     * @return string
     */
    protected function getResultSetClassName()
    {
        return isset($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['meilisearch']['searchResultSetClassName ']) ?
            $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['meilisearch']['searchResultSetClassName '] : SearchResultSet::class;
    }

    /**
     * Performs a search and returns a SearchResultSet.
     *
     * @param SearchRequest $searchRequest
     * @return SearchResultSet
     */
    public function search(SearchRequest $searchRequest)
    {
        $resultSet = $this->getInitializedSearchResultSet($searchRequest);
        $this->lastResultSet = $resultSet;

        $resultSet = $this->handleSearchHook('beforeSearch', $resultSet);
        if ($this->shouldReturnEmptyResultSetWithoutExecutedSearch($searchRequest)) {
            $resultSet->setHasSearched(false);
            return $resultSet;
        }

        $query = $this->queryBuilder->buildSearchQuery($searchRequest->getRawUserQuery(), (int)$searchRequest->getResultsPerPage(), $searchRequest->getAdditionalFilters());
        $this->initializeRegisteredSearchComponents($query, $searchRequest);
        $resultSet->setUsedQuery($query);

        // performing the actual search, sending the query to the Meilisearch server
        $query = $this->modifyQuery($query, $searchRequest, $this->search);
        $response = $this->doASearch($query, $searchRequest);

        if ((int)$searchRequest->getResultsPerPage() === 0) {
            // when resultPerPage was forced to 0 we also set the numFound to 0 to hide results, e.g.
            // when results for the initial search should not be shown.
            // @extensionScannerIgnoreLine
            $response->response->numFound = 0;
        }

        $resultSet->setHasSearched(true);
        $resultSet->setResponse($response);

        $this->getParsedSearchResults($resultSet);

        $resultSet->setUsedAdditionalFilters($this->queryBuilder->getAdditionalFilters());

        /** @var $variantsProcessor VariantsProcessor */
        $variantsProcessor = GeneralUtility::makeInstance(
            VariantsProcessor::class,
            /** @scrutinizer ignore-type */ $this->typoScriptConfiguration,
            /** @scrutinizer ignore-type */ $this->searchResultBuilder
        );
        $variantsProcessor->process($resultSet);

        /** @var $searchResultReconstitutionProcessor ResultSetReconstitutionProcessor */
        $searchResultReconstitutionProcessor = GeneralUtility::makeInstance(ResultSetReconstitutionProcessor::class);
        $searchResultReconstitutionProcessor->process($resultSet);

        $resultSet = $this->getAutoCorrection($resultSet);

        return $this->handleSearchHook('afterSearch', $resultSet);
    }

    /**
     * Uses the configured parser and retrieves the parsed search resutls.
     *
     * @param SearchResultSet $resultSet
     */
    protected function getParsedSearchResults($resultSet)
    {
        /** @var ResultParserRegistry $parserRegistry */
        $parserRegistry = GeneralUtility::makeInstance(ResultParserRegistry::class, /** @scrutinizer ignore-type */ $this->typoScriptConfiguration);
        $useRawDocuments = (bool)$this->typoScriptConfiguration->getValueByPathOrDefaultValue('plugin.tx_meilisearch.features.useRawDocuments', false);
        $parserRegistry->getParser($resultSet)->parse($resultSet, $useRawDocuments);
    }

    /**
     * Evaluates conditions on the request and configuration and returns true if no search should be triggered and an empty
     * SearchResultSet should be returned.
     *
     * @param SearchRequest $searchRequest
     * @return bool
     */
    protected function shouldReturnEmptyResultSetWithoutExecutedSearch(SearchRequest $searchRequest)
    {
        if ($searchRequest->getRawUserQueryIsNull() && !$this->getInitialSearchIsConfigured()) {
            // when no rawQuery was passed or no initialSearch is configured, we pass an empty result set
            return true;
        }

        if ($searchRequest->getRawUserQueryIsEmptyString() && !$this->typoScriptConfiguration->getSearchQueryAllowEmptyQuery()) {
            // the user entered an empty query string "" or "  " and empty querystring is not allowed
            return true;
        }

        return false;
    }

    /**
     * Initializes the SearchResultSet from the SearchRequest
     *
     * @param SearchRequest $searchRequest
     * @return SearchResultSet
     */
    protected function getInitializedSearchResultSet(SearchRequest $searchRequest):SearchResultSet
    {
        /** @var $resultSet SearchResultSet */
        $resultSetClass = $this->getResultSetClassName();
        $resultSet = $this->objectManager->get($resultSetClass);

        $resultSet->setUsedSearchRequest($searchRequest);
        $resultSet->setUsedPage((int)$searchRequest->getPage());
        $resultSet->setUsedResultsPerPage((int)$searchRequest->getResultsPerPage());
        $resultSet->setUsedSearch($this->search);
        return $resultSet;
    }

    /**
     * Executes the search and builds a fake response for a current bug in Meilisearch 6.3
     *
     * @param Query $query
     * @param SearchRequest $searchRequest
     * @return ResponseAdapter
     */
    protected function doASearch($query, $searchRequest): ResponseAdapter
    {
        // the offset mulitplier is page - 1 but not less then zero
        $offsetMultiplier = max(0, $searchRequest->getPage() - 1);
        $offSet = $offsetMultiplier * (int)$searchRequest->getResultsPerPage();

        $response = $this->search->search($query, $offSet, null);
        if($response === null) {
            throw new MeilisearchIncompleteResponseException('The response retrieved from meilisearch was incomplete', 1505989678);
        }

        return $response;
    }

    /**
     * @param SearchResultSet $searchResultSet
     * @return SearchResultSet
     */
    protected function getAutoCorrection(SearchResultSet $searchResultSet)
    {
        // no secondary search configured
        if (!$this->typoScriptConfiguration->getSearchSpellcheckingSearchUsingSpellCheckerSuggestion()) {
            return $searchResultSet;
        }

        // more then zero results
        if ($searchResultSet->getAllResultCount() > 0) {
            return $searchResultSet;
        }

        // no corrections present
        if (!$searchResultSet->getHasSpellCheckingSuggestions()) {
            return $searchResultSet;
        }

        $searchResultSet = $this->peformAutoCorrection($searchResultSet);

        return $searchResultSet;
    }

    /**
     * @param SearchResultSet $searchResultSet
     * @return SearchResultSet
     */
    protected function peformAutoCorrection(SearchResultSet $searchResultSet)
    {
        $searchRequest = $searchResultSet->getUsedSearchRequest();
        $suggestions = $searchResultSet->getSpellCheckingSuggestions();

        $maximumRuns = $this->typoScriptConfiguration->getSearchSpellcheckingNumberOfSuggestionsToTry(1);
        $runs = 0;

        foreach ($suggestions as $suggestion) {
            $runs++;

            $correction = $suggestion->getSuggestion();
            $initialQuery = $searchRequest->getRawUserQuery();

            $searchRequest->setRawQueryString($correction);
            $searchResultSet = $this->search($searchRequest);
            if ($searchResultSet->getAllResultCount() > 0) {
                $searchResultSet->setIsAutoCorrected(true);
                $searchResultSet->setCorrectedQueryString($correction);
                $searchResultSet->setInitialQueryString($initialQuery);
                break;
            }

            if ($runs > $maximumRuns) {
                break;
            }
        }
        return $searchResultSet;
    }

    /**
     * Allows to modify a query before eventually handing it over to Meilisearch.
     *
     * @param Query $query The current query before it's being handed over to Meilisearch.
     * @param SearchRequest $searchRequest The searchRequest, relevant in the current context
     * @param Search $search The search, relevant in the current context
     * @throws \UnexpectedValueException
     * @return Query The modified query that is actually going to be given to Meilisearch.
     */
    protected function modifyQuery(Query $query, SearchRequest $searchRequest, Search $search)
    {
        // hook to modify the search query
        if (is_array($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['meilisearch']['modifySearchQuery'])) {
            foreach ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['meilisearch']['modifySearchQuery'] as $classReference) {
                $queryModifier = $this->objectManager->get($classReference);

                if ($queryModifier instanceof Modifier) {
                    if ($queryModifier instanceof SearchAware) {
                        $queryModifier->setSearch($search);
                    }

                    if ($queryModifier instanceof SearchRequestAware) {
                        $queryModifier->setSearchRequest($searchRequest);
                    }

                    $query = $queryModifier->modifyQuery($query);
                } else {
                    throw new \UnexpectedValueException(
                        get_class($queryModifier) . ' must implement interface ' . Modifier::class,
                        1310387414
                    );
                }
            }
        }

        return $query;
    }

    /**
     * Retrieves a single document from meilisearch by document id.
     *
     * @param string $documentId
     * @return SearchResult
     */
    public function getDocumentById($documentId)
    {
        /* @var $query SearchQuery */
        $query = $this->queryBuilder->newSearchQuery($documentId)->useQueryFields(QueryFields::fromString('id'))->getQuery();
        $response = $this->search->search($query, 0, 1);
        $parsedData = $response->getParsedData();
        // @extensionScannerIgnoreLine
        $resultDocument = isset($parsedData->response->docs[0]) ? $parsedData->response->docs[0] : null;

        if (!$resultDocument instanceof Document) {
            throw new \UnexpectedValueException("Response did not contain a valid Document object");
        }

        return $this->searchResultBuilder->fromApacheMeilisearchDocument($resultDocument);
    }

    /**
     * This method is used to call the registered hooks during the search execution.
     *
     * @param string $eventName
     * @param SearchResultSet $resultSet
     * @return SearchResultSet
     */
    private function handleSearchHook($eventName, SearchResultSet $resultSet)
    {
        if (!is_array($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['meilisearch'][$eventName])) {
            return $resultSet;
        }

        foreach ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['meilisearch'][$eventName] as $classReference) {
            $afterSearchProcessor = $this->objectManager->get($classReference);
            if ($afterSearchProcessor instanceof SearchResultSetProcessor) {
                $afterSearchProcessor->process($resultSet);
            }
        }

        return $resultSet;
    }

    /**
     * @return SearchResultSet
     */
    public function getLastResultSet()
    {
        return $this->lastResultSet;
    }

    /**
     * This method returns true when the last search was executed with an empty query
     * string or whitespaces only. When no search was triggered it will return false.
     *
     * @return bool
     */
    public function getLastSearchWasExecutedWithEmptyQueryString()
    {
        $wasEmptyQueryString = false;
        if ($this->lastResultSet != null) {
            $wasEmptyQueryString = $this->lastResultSet->getUsedSearchRequest()->getRawUserQueryIsEmptyString();
        }

        return $wasEmptyQueryString;
    }

    /**
     * @return bool
     */
    protected function getInitialSearchIsConfigured()
    {
        return $this->typoScriptConfiguration->getSearchInitializeWithEmptyQuery() || $this->typoScriptConfiguration->getSearchShowResultsOfInitialEmptyQuery() || $this->typoScriptConfiguration->getSearchInitializeWithQuery() || $this->typoScriptConfiguration->getSearchShowResultsOfInitialQuery();
    }

    /**
     * @return mixed
     */
    protected function getRegisteredSearchComponents()
    {
        return GeneralUtility::makeInstance(SearchComponentManager::class)->getSearchComponents();
    }
}