<?php

declare(strict_types=1);

/*
 * Chill is a software for social workers
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace Chill\MainBundle\Export;

use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Doctrine\ORM\QueryBuilder;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;

/**
 * Collects all agregators, filters and export from
 * the installed bundle, and performs the export logic.
 */
class ExportManager
{
    /**
     * The collected aggregators, injected by DI.
     *
     * @var array|AggregatorInterface[]
     */
    private array $aggregators = [];

    /**
     * Collected Exports, injected by DI.
     *
     * @var array|ExportInterface[]
     */
    private array $exports = [];

    /**
     * The collected filters, injected by DI.
     *
     * @var array|FilterInterface[]
     */
    private array $filters = [];

    /**
     * Collected Formatters, injected by DI.
     *
     * @var array|FormatterInterface[]
     */
    private array $formatters = [];

    public function __construct(
        private readonly LoggerInterface $logger,
        private readonly AuthorizationCheckerInterface $authorizationChecker,
        private readonly AuthorizationHelperInterface $authorizationHelper,
        private readonly TokenStorageInterface $tokenStorage,
        iterable $exports,
        iterable $aggregators,
        iterable $filters,
        // iterable $formatters,
        // iterable $exportElementProvider
    ) {
        $this->exports = iterator_to_array($exports);
        $this->aggregators = iterator_to_array($aggregators);
        $this->filters = iterator_to_array($filters);
        // NOTE: PHP crashes on the next line (exit error code 11). This is desactivated until further investigation
        // $this->formatters = iterator_to_array($formatters);

        // foreach ($exportElementProvider as $prefix => $provider) {
        //    $this->addExportElementsProvider($provider, $prefix);
        // }
    }

    /**
     * Return a \Generator containing filter which support type. If `$centers` is
     * not null, restrict the given filters to the center the user have access to.
     *
     * if $centers is null, the function will returns all filters where the user
     * has access in every centers he can reach (if the user can use the filter F in
     * center A, but not in center B, the filter F will not be returned)
     *
     * @param \Chill\MainBundle\Entity\Center[] $centers the centers where the user have access to
     *
     * @return FilterInterface[] a \Generator that contains filters. The key is the filter's alias
     */
    public function getFiltersApplyingOn(DirectExportInterface|ExportInterface $export, ?array $centers = null): array
    {
        if ($export instanceof DirectExportInterface) {
            return [];
        }

        $filters = [];

        foreach ($this->filters as $alias => $filter) {
            if (
                \in_array($filter->applyOn(), $export->supportsModifiers(), true)
                    && $this->isGrantedForElement($filter, $export, $centers)
            ) {
                $filters[$alias] = $filter;
            }
        }

        return $filters;
    }

    /**
     * Return a \Generator containing aggregators supported by the given export.
     *
     * @internal This class check the interface implemented by export, and, if ´ListInterface´ is used, return an empty array
     *
     * @return array<string, AggregatorInterface> an array that contains aggregators. The key is the filter's alias
     */
    public function getAggregatorsApplyingOn(DirectExportInterface|ExportInterface $export, ?array $centers = null): array
    {
        if ($export instanceof ListInterface || $export instanceof DirectExportInterface) {
            return [];
        }

        $aggregators = [];

        foreach ($this->aggregators as $alias => $aggregator) {
            if (
                \in_array($aggregator->applyOn(), $export->supportsModifiers(), true)
                    && $this->isGrantedForElement($aggregator, $export, $centers)
            ) {
                $aggregators[$alias] = $aggregator;
            }
        }

        return $aggregators;
    }

    public function addExportElementsProvider(ExportElementsProviderInterface $provider, string $prefix): void
    {
        foreach ($provider->getExportElements() as $suffix => $element) {
            $alias = $prefix.'_'.$suffix;

            if ($element instanceof ExportInterface) {
                $this->exports[$alias] = $element;
            } elseif ($element instanceof FilterInterface) {
                $this->filters[$alias] = $element;
            } elseif ($element instanceof AggregatorInterface) {
                $this->aggregators[$alias] = $element;
            } elseif ($element instanceof FormatterInterface) {
                $this->addFormatter($element, $alias);
            } else {
                throw new \LogicException('This element '.$element::class.' is not an instance of export element');
            }
        }
    }

    /**
     * add a formatter.
     *
     * @internal used by DI
     */
    public function addFormatter(FormatterInterface $formatter, string $alias): void
    {
        $this->formatters[$alias] = $formatter;
    }

    /**
     * Generate a response which contains the requested data.
     */
    public function generate(string $exportAlias, array $pickedCentersData, array $data, array $formatterData): Response
    {
        $export = $this->getExport($exportAlias);
        $centers = $this->getPickedCenters($pickedCentersData);

        if ($export instanceof DirectExportInterface) {
            return $export->generate(
                $this->buildCenterReachableScopes($centers, $export),
                $data[ExportType::EXPORT_KEY]
            );
        }

        $query = $export->initiateQuery(
            $this->retrieveUsedModifiers($data),
            $this->buildCenterReachableScopes($centers, $export),
            $data[ExportType::EXPORT_KEY]
        );

        if ($query instanceof \Doctrine\ORM\NativeQuery) {
            // throw an error if the export require other modifier, which is
            // not allowed when the export return a `NativeQuery`
            if (\count($export->supportsModifiers()) > 0) {
                throw new \LogicException("The export with alias `{$exportAlias}` return ".'a `\Doctrine\ORM\NativeQuery` and supports modifiers, which is not allowed. Either the method `supportsModifiers` should return an empty array, or return a `Doctrine\ORM\QueryBuilder`');
            }
        } elseif ($query instanceof QueryBuilder) {
            // handle filters
            $this->handleFilters($export, $query, $data[ExportType::FILTER_KEY], $centers);

            // handle aggregators
            $this->handleAggregators($export, $query, $data[ExportType::AGGREGATOR_KEY], $centers);

            $this->logger->notice('[export] will execute this qb in export', [
                'dql' => $query->getDQL(),
            ]);
        } else {
            throw new \UnexpectedValueException('The method `intiateQuery` should return a `\Doctrine\ORM\NativeQuery` or a `Doctrine\ORM\QueryBuilder` object.');
        }

        $result = $export->getResult($query, $data[ExportType::EXPORT_KEY]);

        if (!is_iterable($result)) {
            throw new \UnexpectedValueException(sprintf('The result of the export should be an iterable, %s given', \gettype($result)));
        }

        /** @var FormatterInterface $formatter */
        $formatter = $this->getFormatter($this->getFormatterAlias($data));
        $filtersData = [];
        $aggregatorsData = [];

        if ($query instanceof QueryBuilder) {
            $aggregators = $this->retrieveUsedAggregators($data[ExportType::AGGREGATOR_KEY]);

            foreach ($aggregators as $alias => $aggregator) {
                $aggregatorsData[$alias] = $data[ExportType::AGGREGATOR_KEY][$alias]['form'];
            }
        }

        $filters = $this->retrieveUsedFilters($data[ExportType::FILTER_KEY]);

        foreach ($filters as $alias => $filter) {
            $filtersData[$alias] = $data[ExportType::FILTER_KEY][$alias]['form'];
        }

        return $formatter->getResponse(
            $result,
            $formatterData,
            $exportAlias,
            $data[ExportType::EXPORT_KEY],
            $filtersData,
            $aggregatorsData
        );
    }

    /**
     * @param string $alias
     *
     * @return AggregatorInterface
     *
     * @throws \RuntimeException if the aggregator is not known
     */
    public function getAggregator($alias)
    {
        if (!\array_key_exists($alias, $this->aggregators)) {
            throw new \RuntimeException("The aggregator with alias {$alias} is not known.");
        }

        return $this->aggregators[$alias];
    }

    /**
     * @return iterable<string, AggregatorInterface>
     */
    public function getAggregators(array $aliases): iterable
    {
        foreach ($aliases as $alias) {
            yield $alias => $this->getAggregator($alias);
        }
    }

    /**
     * Get the types for known exports.
     *
     * @return list<string> the existing type for known exports
     */
    public function getExistingExportsTypes(): array
    {
        $existingTypes = [];

        foreach ($this->exports as $export) {
            if (!\in_array($export->getType(), $existingTypes, true)) {
                $existingTypes[] = $export->getType();
            }
        }

        return $existingTypes;
    }

    /**
     * Return an export by his alias.
     *
     * @param string $alias
     *
     * @throws \RuntimeException
     */
    public function getExport($alias): DirectExportInterface|ExportInterface
    {
        if (!\array_key_exists($alias, $this->exports)) {
            throw new \RuntimeException("The export with alias {$alias} is not known.");
        }

        return $this->exports[$alias];
    }

    /**
     * Return all exports. The exports's alias are the array's keys.
     *
     * @param bool $whereUserIsGranted if true (default), restrict to user which are granted the right to execute the export
     *
     * @return iterable<string, ExportInterface|DirectExportInterface> an array where export's alias are keys
     */
    public function getExports($whereUserIsGranted = true): iterable
    {
        foreach ($this->exports as $alias => $export) {
            if ($whereUserIsGranted) {
                if ($this->isGrantedForElement($export, null, null)) {
                    yield $alias => $export;
                }
            } else {
                yield $alias => $export;
            }
        }
    }

    /**
     * Get all exports grouped in an array.
     *
     * @return array<string, array<string, ExportInterface|DirectExportInterface>> where keys are the groups's name and value is an array of exports
     */
    public function getExportsGrouped(bool $whereUserIsGranted = true): array
    {
        $groups = ['_' => []];

        foreach ($this->getExports($whereUserIsGranted) as $alias => $export) {
            if ($export instanceof GroupedExportInterface) {
                $groups[$export->getGroup()][$alias] = $export;
            } else {
                $groups['_'][$alias] = $export;
            }
        }

        return $groups;
    }

    /**
     * @throws \RuntimeException if the filter is not known
     */
    public function getFilter(string $alias): FilterInterface
    {
        if (!\array_key_exists($alias, $this->filters)) {
            throw new \RuntimeException("The filter with alias {$alias} is not known.");
        }

        return $this->filters[$alias];
    }

    public function getAllFilters(): array
    {
        $filters = [];

        foreach ($this->filters as $alias => $filter) {
            $filters[$alias] = $filter;
        }

        return $filters;
    }

    /**
     * get all filters.
     *
     * @param array<string> $aliases
     *
     * @return iterable<string, FilterInterface> $aliases
     */
    public function getFilters(array $aliases): iterable
    {
        foreach ($aliases as $alias) {
            yield $alias => $this->getFilter($alias);
        }
    }

    public function getFormatter(string $alias): FormatterInterface
    {
        if (!\array_key_exists($alias, $this->formatters)) {
            throw new \RuntimeException("The formatter with alias {$alias} is not known.");
        }

        return $this->formatters[$alias];
    }

    /**
     * get the formatter alias from the form export data.
     *
     * @param array $data the data from the export form
     *
     * @string the formatter alias|null
     */
    public function getFormatterAlias(array $data): ?string
    {
        if (\array_key_exists(ExportType::PICK_FORMATTER_KEY, $data)) {
            return $data[ExportType::PICK_FORMATTER_KEY]['alias'];
        }

        return null;
    }

    /**
     * Get all formatters which supports one of the given types.
     *
     * @return iterable<string, FormatterInterface>
     */
    public function getFormattersByTypes(array $types): iterable
    {
        foreach ($this->formatters as $alias => $formatter) {
            if (\in_array($formatter->getType(), $types, true)) {
                yield $alias => $formatter;
            }
        }
    }

    /**
     * Get the Center picked by the user for this export. The data are
     * extracted from the PickCenterType data.
     *
     * @param array $data the data from a PickCenterType
     *
     * @return \Chill\MainBundle\Entity\Center[] the picked center
     */
    public function getPickedCenters(array $data): array
    {
        return $data;
    }

    /**
     * get the aggregators typse used in the form export data.
     *
     * @param array $data the data from the export form
     *
     * @return list<string>
     */
    public function getUsedAggregatorsAliases(array $data): array
    {
        $aggregators = $this->retrieveUsedAggregators($data[ExportType::AGGREGATOR_KEY]);

        return array_keys(iterator_to_array($aggregators));
    }

    /**
     * Return true if the current user has access to the ExportElement for every
     * center, false if the user hasn't access to element for at least one center.
     */
    public function isGrantedForElement(
        DirectExportInterface|ExportInterface|ModifierInterface $element,
        DirectExportInterface|ExportInterface|null $export = null,
        ?array $centers = null,
    ): bool {
        if ($element instanceof ExportInterface || $element instanceof DirectExportInterface) {
            $role = $element->requiredRole();
        } else {
            if (null === $element->addRole()) {
                if (null === $export) {
                    throw new \LogicException('The export should not be null: as the ModifierInstance element is not an export, we should be aware of the export to determine which role is required');
                }
                $role = $export->requiredRole();
            } else {
                $role = $element->addRole();
            }
        }

        if (null === $centers || [] === $centers) {
            // we want to try if at least one center is reachable
            return [] !== $this->authorizationHelper->getReachableCenters(
                $this->tokenStorage->getToken()->getUser(),
                $role
            );
        }

        foreach ($centers as $center) {
            if (false === $this->authorizationChecker->isGranted($role, $center)) {
                // debugging
                $this->logger->debug('user has no access to element', [
                    'method' => __METHOD__,
                    'type' => $element::class,
                    'center' => $center->getName(),
                    'role' => $role,
                ]);

                return false;
            }
        }

        return true;
    }

    /**
     * build the array required for defining centers and circles in the initiate
     * queries of ExportElementsInterfaces.
     *
     * @param \Chill\MainBundle\Entity\Center[] $centers
     */
    private function buildCenterReachableScopes(array $centers, ExportElementInterface $element)
    {
        $r = [];

        $user = $this->tokenStorage->getToken()->getUser();

        if (!$user instanceof User) {
            return [];
        }

        foreach ($centers as $center) {
            $r[] = [
                'center' => $center,
                'circles' => $this->authorizationHelper->getReachableScopes(
                    $user,
                    $element->requiredRole(),
                    $center
                ),
            ];
        }

        return $r;
    }

    /**
     * Alter the query with selected aggregators.
     *
     * Check for acl. If an user is not authorized to see an aggregator, throw an
     * UnauthorizedException.
     *
     * @throw UnauthorizedHttpException if the user is not authorized
     */
    private function handleAggregators(
        ExportInterface $export,
        QueryBuilder $qb,
        array $data,
        array $center,
    ): void {
        $aggregators = $this->retrieveUsedAggregators($data);

        foreach ($aggregators as $alias => $aggregator) {
            if (false === $this->isGrantedForElement($aggregator, $export, $center)) {
                throw new UnauthorizedHttpException('You are not authorized to use the aggregator'.$aggregator->getTitle());
            }

            $formData = $data[$alias];
            $aggregator->alterQuery($qb, $formData['form']);
        }
    }

    /**
     * alter the query with selected filters.
     *
     * This function check the acl.
     *
     * @param \Chill\MainBundle\Entity\Center[] $centers the picked centers
     *
     * @throw UnauthorizedHttpException if the user is not authorized
     */
    private function handleFilters(
        ExportInterface $export,
        QueryBuilder $qb,
        mixed $data,
        array $centers,
    ): void {
        $filters = $this->retrieveUsedFilters($data);

        foreach ($filters as $alias => $filter) {
            if (false === $this->isGrantedForElement($filter, $export, $centers)) {
                throw new UnauthorizedHttpException('You are not authorized to use the filter '.$filter->getTitle());
            }

            $formData = $data[$alias];

            $this->logger->debug('alter query by filter '.$alias, [
                'class' => self::class, 'function' => __FUNCTION__,
            ]);
            $filter->alterQuery($qb, $formData['form']);
        }
    }

    /**
     * @return iterable<string, AggregatorInterface>
     */
    private function retrieveUsedAggregators(mixed $data): iterable
    {
        if (null === $data) {
            return [];
        }

        foreach ($data as $alias => $aggregatorData) {
            if (true === $aggregatorData['enabled']) {
                yield $alias => $this->getAggregator($alias);
            }
        }
    }

    /**
     * @return string[]
     */
    private function retrieveUsedAggregatorsType(mixed $data)
    {
        if (null === $data) {
            return [];
        }

        $usedTypes = [];

        foreach ($this->retrieveUsedAggregators($data) as $alias => $aggregator) {
            if (!\in_array($aggregator->applyOn(), $usedTypes, true)) {
                $usedTypes[] = $aggregator->applyOn();
            }
        }

        return $usedTypes;
    }

    private function retrieveUsedFilters(mixed $data): iterable
    {
        if (null === $data) {
            return [];
        }

        foreach ($data as $alias => $filterData) {
            if (true === $filterData['enabled']) {
                yield $alias => $this->getFilter($alias);
            }
        }
    }

    /**
     * Retrieve the filter used in this export.
     *
     * @return array an array with types
     */
    private function retrieveUsedFiltersType(mixed $data): iterable
    {
        if (null === $data) {
            return [];
        }

        $usedTypes = [];

        foreach ($data as $alias => $filterData) {
            if (true === $filterData['enabled']) {
                $filter = $this->getFilter($alias);

                if (!\in_array($filter->applyOn(), $usedTypes, true)) {
                    $usedTypes[] = $filter->applyOn();
                }
            }
        }

        return $usedTypes;
    }

    /**
     * parse the data to retrieve the used filters and aggregators.
     *
     * @return string[]
     */
    private function retrieveUsedModifiers(mixed $data): array
    {
        if (null === $data) {
            return [];
        }

        $usedTypes = array_merge(
            $this->retrieveUsedFiltersType($data[ExportType::FILTER_KEY]),
            $this->retrieveUsedAggregatorsType($data[ExportType::AGGREGATOR_KEY])
        );

        $this->logger->debug(
            'Required types are '.implode(', ', $usedTypes),
            ['class' => self::class, 'function' => __FUNCTION__]
        );

        return array_unique($usedTypes);
    }
}
