<?php

declare(strict_types=1);

namespace KupShop\CatalogElasticBundle\Util;

use Elastica\Multi\Search as MultiSearch;
use Elastica\Query\BoolQuery;
use Elastica\Query\FunctionScore;
use Elastica\Query\MultiMatch;
use KupShop\CatalogBundle\ProductList\DynamicFilterAttributes;
use KupShop\CatalogBundle\Search\FulltextElastic;
use KupShop\CatalogBundle\Util\FilterUtil;
use KupShop\CatalogElasticBundle\Elastica\Query;
use KupShop\CatalogElasticBundle\Event\SectionAfterExecuteEvent;
use KupShop\CatalogElasticBundle\Event\SectionBeforeCompileEvent;
use KupShop\CatalogElasticBundle\Event\SectionBeforeExecuteEvent;
use KupShop\CatalogElasticBundle\Filters\Search;
use KupShop\CatalogElasticBundle\PreparedSectionQuery;
use KupShop\CatalogElasticBundle\SectionRequest;
use KupShop\RestrictionsBundle\Utils\Restrictions;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

class ElasticQueryClient
{
    /**
     * Default limit for the number of results that can be "paginated" over (with from + size fields).
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#index-max-result-window
     */
    public const ELASTIC_RESULTS_LIMIT = 10_000;

    /**
     * @var int Default maximum number of result returned from a search. Used when \Pager is not supplied.
     */
    public static int $maxResults = 100;

    /**
     * @var string The name of the query which contains productIds
     */
    public static string $mainQueryName = 'main';

    /**
     * @var string The name of an optional query with the aggregations of filters in a non-filtered category (for baseFilterParams)
     */
    public static string $baseQueryName = 'base';

    public function __construct(
        private readonly ElasticSpecs $elasticSpecs,
        private readonly ElasticaFactory $elasticaFactory,
        private readonly FulltextElastic $fulltextElastic,
        private readonly EventDispatcherInterface $dispatcher,
        private readonly ElasticFilterValues $filterValuesCompiler,
        private readonly ?Restrictions $restrictions,
    ) {
    }

    /**
     * Search an index with a string query.
     *
     * @param string             $searchTerm query string
     * @param string             $index      as defined by {@see FulltextElastic}::INDEX_* constants
     * @param array<string>|null $fields     fields to return
     */
    public function createGenericSearch(
        string $searchTerm,
        string $index,
        ?array $fields = null,
        ?\Pager $pager = null,
    ): \Elastica\Search {
        $query = new Query();
        $query->setSize($pager?->getOnPage() ?? self::$maxResults);
        if (isset($pager?->number)) {
            $query->setFrom($pager->from());
        }

        if (is_array($fields)) {
            $query->setSource(false);
            $query->setFields($fields);
        }

        if ($index === FulltextElastic::INDEX_PRODUCTS) {
            $filterParams = new \FilterParams();
            $filterParams->setSearch($searchTerm);

            // Run the search filter through ElasticFilters - unified place for searching products.
            $filters = $this->elasticSpecs->getFilters($filterParams);
            if (!$filters) {
                throw new \RuntimeException(sprintf(
                    'Failed to generate a search query (index: %s) for the query "%s".',
                    $index,
                    $searchTerm,
                ));
            }

            $productSearchQuery = new BoolQuery();
            foreach ($filters as $filter) {
                $productSearchQuery->addMust($filter);
            }

            $this->elasticSpecs->applyRestrictionFilters($this->restrictions?->getRestrictionFilterParams(), $productSearchQuery);

            $query->setQuery($productSearchQuery);
        } else {
            $score = new FunctionScore();
            $score->setQuery($searchQuery = new MultiMatch());
            $searchQuery->setQuery($searchTerm);
            $searchQuery->setType(MultiMatch::TYPE_CROSS_FIELDS);
            $searchQuery->setOperator(MultiMatch::OPERATOR_AND);

            $searchFields = $this->fulltextElastic->formatQueryFields($this->fulltextElastic->getSearchFields($index));
            $searchQuery->setFields($searchFields);

            $query->setQuery($score);
        }

        $search = $this->elasticaFactory->createSearch($index);
        $search->setQuery($query);

        return $search;
    }

    /**
     * Prepares an executable ES search (lazy - will be executed when some field is requested from the response).
     * Also registers event listeners.
     *
     * @param SectionRequest          $request          parameters needed to create the ES query
     * @param \ProductList            $productList      target product list,
     *                                                  which will be filtered by product ids when the ES search executes
     * @param \Filter                 $filter           will be filled with filter values from ES aggregations
     * @param ?\Pager                 $pager            the size will be set to the value of the number of products found by ES
     * @param DynamicFilterAttributes $filterAttributes a list of allowed filters in the section (as well as some data to be added to the $filter valuesCache)
     */
    public function prepareSectionQuery(
        SectionRequest $request,
        \ProductList $productList,
        \Filter $filter,
        ?\Pager $pager,
        DynamicFilterAttributes $filterAttributes,
    ): PreparedSectionQuery {
        $preparedRequest = new PreparedSectionQuery(
            $request,
            $this->compileCategoryQuery(...),
            $this->filterValuesCompiler,
            $productList,
            $filter,
            $pager,
            $filterAttributes,
        );

        $eventDispatcher = $this->dispatcher;
        $preparedRequest->onBeforeCompile(static function () use ($eventDispatcher) {
            $event = new SectionBeforeCompileEvent(...func_get_args());

            $eventDispatcher->dispatch($event);
        });

        $preparedRequest->onBeforeExecute(static function () use ($eventDispatcher) {
            $event = new SectionBeforeExecuteEvent(...func_get_args());

            $eventDispatcher->dispatch($event);
        });

        $preparedRequest->onAfterExecute(static function () use ($eventDispatcher) {
            $event = new SectionAfterExecuteEvent(...func_get_args());

            $eventDispatcher->dispatch($event);
        });

        return $preparedRequest;
    }

    private function compileCategoryQuery(SectionRequest $request): MultiSearch
    {
        $multiSearch = $this->elasticaFactory->createMultiSearch();

        if ($request->pager) {
            assert(
                $request->pager->to() < self::ELASTIC_RESULTS_LIMIT,
                'Elasticsearch indexy mají limit 10 000 výsledků. Stránkování nad 10 000 výsledků není možné.'
            );

            while ($request->pager->to() >= self::ELASTIC_RESULTS_LIMIT) {
                $request->pager->number--;
            }
        }

        $productsSearch = $this->elasticaFactory->createSearch();
        $productsSearch->setQuery($this->createMainQuery($request));

        $multiSearch->addSearch($productsSearch, key: self::$mainQueryName);

        foreach ($this->createFilterQueries($request) as $filterName => $filterQuery) {
            if (!$filterQuery->getAggregations()) {
                continue;
            }

            $filterSearch = $this->elasticaFactory->createSearch();
            $filterSearch->setQuery($filterQuery);

            $multiSearch->addSearch($filterSearch, key: $filterName);
        }

        $shouldCreateBaseSearch = $request->fetchAllFilters && $this->isFiltered($request->baseFilterParams, $request->filterParams);

        if ($shouldCreateBaseSearch) {
            $baseSearchQuery = $this->createBaseFiltersQuery($request);

            if ($baseSearchQuery->getAggregations()) {
                $baseSearch = $this->elasticaFactory->createSearch();
                $baseSearch->setQuery($baseSearchQuery);

                $multiSearch->addSearch($baseSearch, key: self::$baseQueryName);
            }
        }

        return $multiSearch;
    }

    /**
     * @return Query a filtered and paged query with product IDs and <i>sometimes :)</i> filter aggregations
     */
    private function createMainQuery(SectionRequest $request): Query
    {
        $mainQuery = $this->elasticaFactory->createBoolQuery($filtersQuery = new BoolQuery());

        // add all filters (with both base and dynamic & indexed)
        foreach ($this->elasticSpecs->getFilters($request->filterParams) as $filterName => $filter) {
            if ($filterName === Search::filterIdentifier()) {
                $filtersQuery->addMust($filter);

                continue;
            }

            $filtersQuery->addFilter($filter);
        }

        $allActiveFilters = FilterUtil::getActiveFilters(
            filterParams: $request->filterParams,
            attributes: $request->dynamicFilterAttributes,
        );

        $aggregations = $this->elasticSpecs->getAggregations(
            $request->dynamicFilterAttributes,
            $request->filterParams,
        );
        foreach ($aggregations as $aggregationName => $aggregation) {
            $willCreateOwnSearch = false;

            foreach ($allActiveFilters as $activeFilterName) {
                if ($activeFilterName === $aggregationName || str_starts_with($aggregationName, "{$activeFilterName}.")) {
                    $willCreateOwnSearch = true;

                    break;
                }
            }

            if ($willCreateOwnSearch) {
                continue;
            }

            $mainQuery->addAggregation($aggregation);
        }

        // Explicit order from (GET parameter; or graphql field)
        if ($request->orderBy) {
            $this->elasticSpecs->applyOrderBy($mainQuery, $request->orderBy, $request->orderDir);
        }

        // The fulltext search _score needs to be explicitly defined when
        // using "sort" in the ES search.
        if (in_array('search', $allActiveFilters)) {
            $mainQuery->addSort('_score');
        }

        // Only apply default sort when the search filter is inactive.
        // It's a micro-optimization..... but the default sort usually generates
        // a sort script in order to combine all the order by options together -> potentially slow.
        //
        // The search hits will almost always have different scores,
        // in which case the default sort would not do anything.
        elseif (!$request->orderBy && $request->categorySort) {
            $this->elasticSpecs->applyDefaultSort($mainQuery, $request);
        }

        if ($pager = $request->pager) {
            $this->elasticSpecs->applyPagination($mainQuery, $pager);
        } else {
            // If pager is not provided, set a default limit.
            // The ES default is 10, which is too low for most use cases.
            $mainQuery->setSize(static::$maxResults);
        }

        return $mainQuery;
    }

    /**
     * @return array<string, Query> a list of queries with aggregations, one for each filter
     *                              (always containing all aggregations for all filters except for the current one)
     */
    private function createFilterQueries(SectionRequest $request): array
    {
        // !only dynamic_filter! - hence the inclusion of baseFilterParams
        $activeFilters = FilterUtil::getActiveFilters(
            filterParams: $request->filterParams,
            attributes: $request->dynamicFilterAttributes,
            baseFilterParams: $request->baseFilterParams,
        );

        $queries = [];

        foreach ($activeFilters as $filterName) {
            $queryForFilter = $this->elasticaFactory->createBoolQuery(
                $partialQueryFilters = new BoolQuery(),
                aggregationsOnly: true,
            );

            $aggregations = $this->elasticSpecs->getAggregations(
                $request->dynamicFilterAttributes,
                $request->filterParams,
                only: $filterName,
            );
            if (empty($aggregations)) {
                continue;
            }

            foreach ($aggregations as $aggregation) {
                $queryForFilter->addAggregation($aggregation);
            }

            foreach ($this->elasticSpecs->getFilters($request->filterParams, without: $filterName) as $filter) {
                $partialQueryFilters->addFilter($filter);
            }

            $queries[$filterName] = $queryForFilter;
        }

        return $queries;
    }

    /**
     * @return Query A query with only aggregations for all allowed dynamic_filters
     */
    private function createBaseFiltersQuery(SectionRequest $request): Query
    {
        $baseQuery = $this->elasticaFactory->createBoolQuery($filters = new BoolQuery(), aggregationsOnly: true);
        foreach ($this->elasticSpecs->getFilters($request->baseFilterParams) as $filter) {
            $filters->addFilter($filter);
        }

        $baseFilter = FilterUtil::getActiveFilters(
            filterParams: $request->baseFilterParams,
            attributes: $request->dynamicFilterAttributes,
        );

        foreach ($this->elasticSpecs->getAggregations($request->dynamicFilterAttributes, $request->filterParams) as $aggregationName => $aggregation) {
            if (in_array($aggregationName, $baseFilter)
                || !$this->filterValuesCompiler->isAggregationAllowedAsBase($aggregationName, $aggregation)) {
                continue;
            }

            $baseQuery->addAggregation($aggregation);
        }

        return $baseQuery;
    }

    private function isFiltered(\FilterParams $base, \FilterParams $all): bool
    {
        $baseActive = array_filter($base->getParams(), fn ($value) => !empty($value));
        $allActive = array_filter($all->getParams(), fn ($value) => !empty($value));

        return !empty(array_diff_key($allActive, $baseActive));
    }
}
