<?php

declare(strict_types=1);

namespace KupShop\CatalogElasticBundle\Util;

use Elastica\Aggregation\AbstractAggregation;
use Elastica\Aggregation as Agg;
use Elastica\Multi\ResultSet as MultiResultSet;
use KupShop\CatalogBundle\ProductList\DynamicFilterAttributes;
use KupShop\CatalogElasticBundle\DataContext\ElasticFilterValuesContext as ValuesContext;
use KupShop\CatalogElasticBundle\Exception\AggregationUnfoldingException;
use KupShop\CatalogElasticBundle\Filters\ElasticFilter;
use KupShop\CatalogElasticBundle\Wrapper\TargetAggregation;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;

/**
 * Response handling.
 *
 * Get values for {@see \Filter::valuesCache} from ElasticSearch aggregations.
 */
class ElasticFilterValues
{
    /**
     * The "meta" key for aggregations in which the path to the aggregation to be _unfolded_ is stored.
     *
     * e.g. <code>
     *     {
     *         ...,
     *         "aggs": {
     *             "variations.9": {
     *                 "filter": {
     *                     "meta": { "unfold_nested": "variations.ids" },
     *                     "filter": {...}
     *                     "aggs": {
     *                         "variations": {
     *                             ...,
     *                             "aggs": { "ids": { ... } }        // <-- this is what we care for
     *                         }
     *                     }
     *                 }
     *             }
     *         }
     *     }
     * </code>
     *
     * In this scenario the unfolding method should on the output return the innermost aggregation with the key "ids".
     */
    public const UNFOLD_NESTED = 'unfold_nested';

    /**
     * A depth limit for aggregation unfolding. A safeguard to prevent infinite loops.
     *
     * @see ElasticFilterValues::unfoldAggregation()
     */
    private const AGGREGATION_UNFOLDING_MAX_DEPTH = 5;

    /**
     * @var string[] types of aggregations which are supported in {@see ElasticFilterValues::mergeAggregations()}
     */
    public const SUPPORTED_BASE_AGGREGATIONS = [
        Agg\Terms::class,
        Agg\Cardinality::class,
    ];

    /** @var array<string, ElasticFilter> */
    private array $filters;

    public function __construct(
        #[TaggedIterator(tag: ElasticFilter::ELASTIC_FILTERS_TAG, defaultIndexMethod: 'filterIdentifier')]
        iterable $filters,
    ) {
        $this->filters = \iterator_to_array($filters);
    }

    public function getFromResponse(MultiResultSet $multiResultSet, DynamicFilterAttributes $filterAttributes): array
    {
        $ctx = new ValuesContext($this, $multiResultSet, $filterAttributes);

        foreach ($this->filters as $aggregationMetaName => $filter) {
            $filter->collectValues($ctx = $ctx->with($aggregationMetaName));
        }

        return $ctx->get();
    }

    /**
     * Resolves the correct query = If a certain filter is applied to the main query, then the product counts are garbage.
     * We have to create a separate query just for resolution of the product counts for the filter.
     *
     * @return array<string, array<string, TargetAggregation>> target aggregations grouped by the filter type (= meta aggregation name)
     *
     * @throws AggregationUnfoldingException
     */
    public function getTargetAggregations(MultiResultSet $multiResultSet): array
    {
        $aggregations = [];

        foreach ($multiResultSet->getResultSets() as $resultSetName => $resultSet) {
            if ($resultSetName === ElasticQueryClient::$baseQueryName) {
                continue;
            }

            foreach ($resultSet->getAggregations() as $aggregationOriginalName => $aggregation) {
                // = the name of the aggregation group; usually would be the result of ElasticFilter::filterIdentifier()
                $aggregationMetaName = explode('.', $aggregationOriginalName)[0];

                $aggregationShortName = $aggregationOriginalName;
                if (str_starts_with($aggregationShortName, "{$aggregationMetaName}.")) {
                    $aggregationShortName = substr($aggregationShortName, strlen($aggregationMetaName) + 1);
                }

                assert(
                    !isset($aggregations[$aggregationMetaName][$aggregationShortName]),
                    sprintf(
                        'Někde vytváříš 2x agregaci "%s"! To je zbytečná práce navíc, ne?',
                        $aggregationOriginalName,
                    ),
                );

                $aggregations[$aggregationMetaName][$aggregationShortName] = $this->createTargetAggregation(
                    $aggregationOriginalName,
                    $aggregation,
                );
            }
        }

        // Pokud mám v sekci zafiltrováno, tak se mi samotřejmě z aggregace na filtr nevrátí všechny
        // možné hodnoty, které produkty v sekci mají.
        // př. Zafiltruju podle čehokoliv a nevrátí se mi všechni výrobci (potřebuju, abych měl ve filtru všechny výrobce a ty,
        // kteří jsou "odfiltrovaní" musí mít počet produktů 0 - ne aby nepřišli vůbec).

        // A k tomu mám právě tu baseQuery. Ta vrací agregace z nezafiltrované sekce
        //  -> vezmu z ní všechny možné hodnoty (nastavím jí doc_count = 0) a mergnu k nim počty produktů z ostatních dotazů.
        if (isset($multiResultSet[ElasticQueryClient::$baseQueryName])) {
            $baseAggregations = [];

            foreach ($multiResultSet[ElasticQueryClient::$baseQueryName]->getAggregations() as $aggregationOriginalName => $aggregation) {
                $aggregationMetaName = explode('.', $aggregationOriginalName)[0];
                $aggregationShortName = $aggregationOriginalName;
                if (str_starts_with($aggregationShortName, "{$aggregationMetaName}.")) {
                    $aggregationShortName = substr($aggregationShortName, strlen($aggregationMetaName) + 1);
                }

                $baseAggregations[$aggregationMetaName][$aggregationShortName] = $this->createTargetAggregation(
                    $aggregationOriginalName,
                    $aggregation,
                );
            }

            foreach ($aggregations as $aggregationMetaName => &$subAggregations) {
                if (!isset($baseAggregations[$aggregationMetaName])) {
                    continue;
                }

                $subAggregations = $this->mergeAggregations(
                    $aggregationMetaName,
                    base: $baseAggregations[$aggregationMetaName],
                    other: $subAggregations,
                );
            }
        }

        foreach ($aggregations as &$aggregationGroup) {
            $this->postProcessAggregations($aggregationGroup);
        }

        return $aggregations;
    }

    /**
     * @throws AggregationUnfoldingException
     */
    protected function createTargetAggregation(string $aggregationFullName, array $aggregation): TargetAggregation
    {
        $shortName = $aggregationFullName;
        if (count($exploded = explode('.', $aggregationFullName)) > 1) {
            $shortName = substr($shortName, strlen($exploded[0]) + 1);
        }

        return $this->unfoldAggregation(new TargetAggregation(
            $aggregation,
            $aggregationFullName,
            $shortName,
        ));
    }

    /**
     * @param array<TargetAggregation> $aggregations
     */
    protected function postProcessAggregations(array &$aggregations): void
    {
        if (empty($aggregations)) {
            return;
        }

        $bucketCountAggSuffix = '.'.ElasticSpecs::BUCKET_COUNT_KEY;

        $removeKeys = [];
        foreach ($aggregations as $aggregationKey => $aggregation) {
            if (!str_ends_with($aggregation->getOriginalName(), $bucketCountAggSuffix)) {
                continue;
            }

            $targetKey = substr(
                count(explode('.', $aggregationKey)) > 1
                    ? $aggregationKey
                    : $aggregation->getOriginalName(),
                0,
                -strlen($bucketCountAggSuffix),
            );

            assert(
                array_key_exists($targetKey, $aggregations),
                sprintf(
                    'Počítáš celkový počet buckets pro aggregaci ("%s"), která neexistuje? GG',
                    $targetKey,
                ),
            );

            $removeKeys[] = $aggregationKey;
            $aggregations[$targetKey]->setTotalBucketCount($aggregation['value']);
        }

        foreach ($removeKeys as $key) {
            unset($aggregations[$key]);
        }
    }

    /**
     * Kind of an abomination, but...
     *
     * I need all parameter values that are in category, but when first request is filtered
     * I don't have all values -> after filter update additional values will not be rendered - too complicated :).
     *
     * @param array<TargetAggregation> $base
     * @param array<TargetAggregation> $other
     *
     * @return array<TargetAggregation>
     */
    protected function mergeAggregations(string $aggregationMetaName, array $base, array $other): array
    {
        $result = $base;

        foreach ($result as $aggregationName => &$aggregation) {
            if (!isset($other[$aggregationName])) {
                continue;
            }

            $otherAggregation = $other[$aggregationName];

            // for bucket_count (Cardinality aggregation)
            if (!isset($aggregation['buckets']) && isset($aggregation['value'])) {
                $result[$aggregationName] = $aggregation;

                continue;
            }

            // ignore other types of aggregations
            if (!isset($aggregation['buckets'])) {
                $result[$aggregationName] = $otherAggregation;

                continue;
            }

            $buckets = &$aggregation['buckets'];
            assert(isset($otherAggregation['buckets']));

            if (($filter = $this->filters[$aggregationMetaName]) && $filter->supportsOrderBy()) {
                $currentBucketCount = count($otherAggregation['buckets']);

                // There is enough buckets in the aggregation
                //  -> no need to merge it with the base aggregation
                if ($currentBucketCount >= ElasticSpecs::$topSellingBucketsSize) {
                    $aggregation['buckets'] = $otherAggregation['buckets'];

                    continue;
                }

                // Keep the buckets from the main (or filter-specific) aggregation
                //  -> this is ordered by products sold and contains only the values
                //     that are present in the currently used filter (with dynamic_filter applied).
                $newBuckets = $otherAggregation['buckets'];
                $keysAlreadyAdded = array_fill_keys(array_column($newBuckets, 'key'), true);

                // The buckets from the base aggregation.
                //  -> These contain some of the values from the section (or producer, etc.)
                //     i.e. without applying the dynamic_filter to the ES query.
                //
                // (reverse -> so we can pop the last element instead of shifting it on every iteration)
                $baseBuckets = array_reverse($aggregation['buckets']);

                while (count($newBuckets) < ElasticSpecs::$topSellingBucketsSize && count($baseBuckets) > 0) {
                    $baseBucketToAdd = array_pop($baseBuckets);

                    // The values for the filter can be present in both the base aggregation and the main aggregation.
                    // Also, there is no guarantee that we can actually get to $maxBucketSize.
                    if (array_key_exists($baseBucketToAdd['key'], $keysAlreadyAdded)) {
                        continue;
                    }

                    // Since the bucket we are currently adding is not present in the main aggregation,
                    // the product count for the current dynamic_filter is 0.
                    $baseBucketToAdd['doc_count'] = 0;

                    $newBuckets[] = $baseBucketToAdd;
                    $keysAlreadyAdded[$baseBucketToAdd['key']] = true;
                }

                $aggregation['buckets'] = $newBuckets;

                continue;
            }

            foreach ($buckets as &$bucket) {
                $otherBucket = null;

                foreach ($otherAggregation['buckets'] as $perhapsOtherBucket) {
                    if (($perhapsOtherBucket['key'] ?? null) === $bucket['key']) {
                        $otherBucket = $perhapsOtherBucket;

                        break;
                    }
                }

                if (!$otherBucket) {
                    $bucket['doc_count'] = 0;
                } else {
                    $bucket['doc_count'] = $otherBucket['doc_count'];
                }
            }
        }

        foreach ($other as $aggName => $agg) {
            if (array_key_exists($aggName, $result)) {
                continue;
            }

            $result[$aggName] = $agg;
        }

        return $result;
    }

    public function isAggregationAllowedAsBase(string $aggregationMetaName, Agg\AbstractAggregation $aggregation): bool
    {
        /* (void) */ unset($aggregationMetaName);

        if ($aggregation instanceof Agg\Nested
            || $aggregation instanceof Agg\Filter
            || $aggregation instanceof Agg\Filters
        ) {
            foreach ($aggregation->getAggs() as $name => $subAggregation) {
                if ($this->isAggregationAllowedAsBase((string) $name, $subAggregation)) {
                    continue;
                }

                return false;
            }

            return true;
        }

        $isAllowed = false;
        foreach (ElasticFilterValues::SUPPORTED_BASE_AGGREGATIONS as $allowedClass) {
            $isAllowed = $isAllowed || $aggregation instanceof $allowedClass;
        }

        return $isAllowed;
    }

    /**
     * @throws AggregationUnfoldingException
     */
    public function unfoldAggregation(TargetAggregation $aggregation): TargetAggregation
    {
        $depth = self::AGGREGATION_UNFOLDING_MAX_DEPTH;

        while (($nestedKey = $aggregation->getUnfoldNested()) && $depth > 0) {
            $pathToAggregation = explode('.', $nestedKey);

            foreach ($pathToAggregation as $key) {
                if ($depth < 0) {
                    break;
                }

                if (!isset($aggregation[$key])) {
                    throw new AggregationUnfoldingException($aggregation, sprintf(
                        'The %s key "%s" does not exist on aggregation "%s". Got keys: ["%s"].',
                        self::UNFOLD_NESTED,
                        $key,
                        $aggregation->getOriginalName(),
                        implode('", "', array_keys($aggregation->getArrayCopy())),
                    ));
                }

                $aggregation->exchangeArray($aggregation[$key]);
                $depth--;
            }
        }

        if ($depth < 0) {
            throw new AggregationUnfoldingException($aggregation, sprintf(
                'Aggregation "%s" nested too deeply (max depth: %d)',
                $aggregation->getOriginalName(),
                self::AGGREGATION_UNFOLDING_MAX_DEPTH,
            ));
        }

        return $aggregation;
    }

    /**
     * @throws AggregationUnfoldingException
     */
    public function unfoldAggregationDefinition(Agg\AbstractAggregation $aggregation): Agg\AbstractAggregation
    {
        if (!$aggregation->getMeta() || !isset($aggregation->getMeta()[ElasticFilterValues::UNFOLD_NESTED])) {
            return $aggregation;
        }

        $unfoldKey = (string) $aggregation->getMeta()[ElasticFilterValues::UNFOLD_NESTED];

        $target = $aggregation;
        foreach (explode('.', $unfoldKey) as $keyPart) {
            $found = false;

            /** @var AbstractAggregation $childAgg */
            foreach ($target->getAggs() as $childAgg) {
                if ($childAgg->getName() === $keyPart) {
                    $target = $childAgg;
                    $found = true;

                    break;
                }
            }

            if (!$found) {
                throw new AggregationUnfoldingException($aggregation, sprintf(
                    'The aggregation ("%s") defined an invalid UNFOLD_NESTED key "%s". Aggregation was not found.',
                    $aggregation->getName(),
                    $unfoldKey,
                ));
            }
        }

        return $target;
    }
}
