<?php declare(strict_types=1);

namespace Newland\NeosFiltering\Items;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Neos\ContentRepository\Domain\Model\NodeInterface;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Configuration\Exception\InvalidConfigurationException;
use Neos\Flow\Mvc\ActionRequest;
use Neos\Flow\Mvc\Exception\InfiniteLoopException;
use Neos\Flow\Mvc\Routing\UriBuilder;
use Neos\Flow\ObjectManagement\Exception\InvalidObjectException;
use Neos\Flow\Persistence\Generic\Query as NeosQuery;
use Neos\Neos\Domain\Service\ContentContext;
use Newland\Contracts\Neos\Filter\CompositeFilterItem;
use Newland\Contracts\Neos\Filter\FilterItem;
use Newland\Contracts\Neos\Filter\FilterRoot;
use Newland\Contracts\Neos\Filter\ModifiesDatabaseQueryDirectly;
use Newland\Contracts\Neos\Filter\ProvidesHiddenFields;
use Newland\Contracts\Neos\Filter\QueryBoundFilterItem;
use Newland\Contracts\Neos\Filter\StatusIndicatingFilterItem;
use Newland\NeosCommon\Service\NodeService;
use Newland\NeosFiltering\StatusIndicator\StatusIndicator;
use Newland\NeosFiltering\StatusIndicator\StatusIndicatorCollection;
use TYPO3Fluid\Fluid\Core\Rendering\RenderableInterface;
use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;

/**
 * The root object of a whole filter.
 * Additionally to rendering the frame around a filter this class has a bit more
 * functionality:
 *
 * - Applies the expressions returned by it's children to a given query builder.
 * - Maintains a mapping of query string => filter item which is used to set the state on
 *   it's children.
 */
class Root extends Group implements FilterRoot
{

    /** @var FilterItem[] */
    protected $queryStringMapping = [];

    /** @var array */
    protected $joins = [];

    /** @var string */
    protected $order = NeosQuery::ORDER_ASCENDING;

    /** @var string */
    protected $orderBy;

    /** @var bool */
    protected $sortNullAtTheEnd;

    /** @var string */
    protected $actionUri;

    /** @var string[] */
    protected $actionUriArguments = [];

    /**
     * @var UriBuilder
     * @Flow\Inject()
     */
    protected $uriBuilder;

    /**
     * @var EntityManagerInterface
     * @Flow\Inject()
     */
    protected $entityManager;

    /**
     * @var NodeService
     * @Flow\Inject()
     */
    protected $nodeService;

    /** @var array */
    protected $formAttributes = [];

    /** @var NodeInterface|null */
    protected $pluginNode;

    /** @var string[] */
    protected $hiddenFields = [];

    public function __construct(
        array $items = [],
        NodeInterface $node = null
    ) {
        parent::__construct($items);
        $this->pluginNode = $node;
    }

    public function getResultCount(QueryBuilder $query): int
    {
        $countQuery = (new QueryBuilder($query->getEntityManager()))
            ->select('COUNT(count_entity)')
            ->from($query->getRootEntities()[0], 'count_entity')
            ->where(
                $query->expr()->in(
                    'count_entity.Persistence_Object_Identifier',
                    (clone $query)->select('entity.Persistence_Object_Identifier')->getDQL()
                )
            )->setParameters($query->getParameters());

        return (int) $countQuery->getQuery()->getSingleScalarResult();
    }

    public function applyToQuery(QueryBuilder $query): QueryBuilder
    {
        $alias = $query->getRootAliases()[0];
        $entityClass = $query->getDQLPart('from')[0]->getFrom();
        $metadata = $this->entityManager->getMetadataFactory()->getMetadataFor($entityClass);
        $identifierFieldName = $metadata->getIdentifierFieldNames()[0];

        $query->groupBy(sprintf('%s.%s', $alias, $identifierFieldName));

        $query = $this->applyJoins($query);
        $query = $this->applyExpressions($query);
        $query = $this->applyDirectQueryModifications($query);
        $query = $this->applyOrder($query);

        return $query;
    }

    private function applyJoins(QueryBuilder $query): QueryBuilder
    {
        foreach ($this->joins as $join) {
            if ($this->hasJoin($query, $join['alias'])) {
                continue;
            }

            switch ($join['type'] ?? null) {
                case 'inner':
                    $query->innerJoin($join['column'], $join['alias']);
                    break;
                case 'left':
                default:
                    $query->leftJoin($join['column'], $join['alias']);
                    break;
            }
        }
        return $query;
    }

    protected function applyOrder(QueryBuilder $query): QueryBuilder
    {
        if ($this->orderBy === null) {
            return $query;
        }

        $order = $this->sanitizeOrder();
        $query->orderBy($this->orderBy, $order);

        return $query;
    }

    private function applyExpressions(QueryBuilder $query): QueryBuilder
    {
        $expression = $this->queryExpression($query->expr());
        if ($expression->where) {
            $query->andWhere($expression->where);
        }
        if ($expression->having) {
            $query->andHaving($expression->having);
        }
        return $query;
    }

    private function applyDirectQueryModifications(QueryBuilder $query): QueryBuilder
    {
        foreach ($this->flattenChildren($this) as $child) {
            if ($child instanceof ModifiesDatabaseQueryDirectly) {
                $query = $child->modifyDatabaseQuery($query);
            }
        }
        return $query;
    }

    public function setJoins(array $joins): void
    {
        $this->joins = $joins;
    }

    public function setOrder(array $orderConfiguration): void
    {
        $this->order = $orderConfiguration['default']['order'] ?? $orderConfiguration['order'] ?? null;
        $this->orderBy = $orderConfiguration['default']['orderBy'] ??
            $orderConfiguration['orderBy'] ?? null;
        $this->sortNullAtTheEnd =
            $orderConfiguration['default']['sortNullAtTheEnd'] ?? $orderConfiguration['sortNullAtTheEnd'] ?? false;
    }

    public function setState($state): void
    {
        if (!\is_array($state)) {
            throw new \InvalidArgumentException('Given state must be an array.');
        }

        foreach ($state as $key => $value) {
            if (array_key_exists($key, $this->queryStringMapping)) {
                $this->queryStringMapping[$key]->setState($value);
            }
        }
    }

    public function getState(): array
    {
        $state = [];
        foreach ($this->flattenChildren($this) as $child) {
            if ($child instanceof QueryBoundFilterItem) {
                $state[$child->getQueryString()] = $child->getState();
            }
        }
        return $state;
    }

    public function addItem(FilterItem $item): void
    {
        foreach ($this->flattenChildren($item) as $child) {
            if ($child instanceof ObstructionWrapper) {
                $child = $child->getObstructed();
            }
            if ($child instanceof QueryBoundFilterItem) {
                $queryString = $child->getQueryString();
                if ($queryString === null) {
                    continue;
                }

                if (array_key_exists($queryString, $this->queryStringMapping)) {
                    throw new InvalidConfigurationException(
                        sprintf(
                            '2 filter items cannot have the same queryString "%s"',
                            $queryString
                        )
                    );
                }
                $this->queryStringMapping[$queryString] = $child;
            }
        }
        parent::addItem($item);
    }

    public function render(RenderingContextInterface $renderingContext)
    {
        if ($this->actionUri === null) {
            throw new \InvalidArgumentException('Please set an action uri on the filter root.');
        }

        $attributes = '';
        foreach ($this->formAttributes as $key => $value) {
            $attributes .= sprintf(' %s="%s" ', $key, $value);
        }

        $view = $this->initializeView();
        $view->assignMultiple(
            [
                'items' => $this->items,
                'actionUri' => $this->actionUri,
                'actionArguments' => $this->actionUriArguments,
                'formAttributes' => $attributes,
                'hiddenFields' => $this->getHiddenFields(),
            ]
        );
        return $view->render('Root');
    }

    public function fillStateFromRootRequestArguments(ActionRequest $request): self
    {
        $this->setState($request->getMainRequest()->getArguments());
        return $this;
    }

    protected function flattenChildren(FilterItem $item): \Generator
    {
        yield $item;
        if (!($item instanceof CompositeFilterItem)) {
            return;
        }

        /** @var CompositeFilterItem[] $queue */
        $queue = [ $item ];
        $i = 0;
        // phpcs:ignore
        while ($item = array_pop($queue)) {
            foreach ($item->getChildrenItems() as $child) {
                yield $child;

                if ($child instanceof CompositeFilterItem) {
                    $queue[] = $child;
                }
                if (++$i > 1000) {
                    throw new InfiniteLoopException(
                        'Stopped flattening children after 1000 iterations. Suspected loop in tree'
                    );
                }
            }
        }
    }

    public function setActionUri(string $actionUri): void
    {
        $this->actionUri = $actionUri;
    }

    public function setActionUriArguments(array $arguments): self
    {
        $this->actionUriArguments = $arguments;
        return $this;
    }


    public function setAction(array $arguments, ActionRequest $request): self
    {
        if ($this->pluginNode === null) {
            throw new InvalidObjectException('Plugin node must be supplied for this method');
        }

        $context = $this->pluginNode->getContext();
        if (($context instanceof ContentContext) && $context->isInBackend()) {
            // The neos backend has problems rendering plugin uris because sometimes(!) the parent request arguments
            // get in the way. Since we don't rely on the form action uri in backend context, let's use a default.
            $this->actionUri = '#';
            return $this;
        }

        $this->uriBuilder->setRequest($request);
        $this->uriBuilder->reset();
        $this->uriBuilder->setCreateAbsoluteUri(true);
        $this->actionUri = $this->uriBuilder->uriFor(
            'show',
            [ 'node' => $this->nodeService->getDocumentNode($this->pluginNode) ],
            'Frontend\Node',
            'Neos.Neos'
        );

        $namespace = $request->getArgumentNamespace();
        foreach ($arguments as $key => $value) {
            $prefixedKey = $namespace ? ($namespace . '[' . $key . ']') : $key;
            $this->actionUriArguments[$prefixedKey] = $value;
        }

        return $this;
    }

    private function hasJoin(QueryBuilder $query, string $alias): bool
    {
        foreach ($query->getDQLPart('join')['entity'] ?? [] as $join) {
            /** @var Join $join */
            if ($join->getAlias() === $alias) {
                return true;
            }
        }
        return false;
    }

    public function getStatusIndicators(): RenderableInterface
    {
        $indicators = [ [] ];
        foreach ($this->flattenChildren($this) as $child) {
            if ($child instanceof StatusIndicatingFilterItem && $child !== $this) {
                $indicators[] = $child->getStatusIndicators();
            }
        }

        /** @var StatusIndicator[] $indicatorInstances */
        $indicatorInstances = array_merge(...$indicators);
        return new StatusIndicatorCollection($indicatorInstances);
    }

    public function addTemplateRootPaths(
        array $templateRootPaths = [],
        array $layoutRootPaths = [],
        array $partialRootPaths = []
    ): void {
        parent::addTemplateRootPaths($templateRootPaths, $layoutRootPaths, $partialRootPaths);

        foreach ($this->flattenChildren($this) as $child) {
            if ($child !== $this && method_exists($child, 'addTemplateRootPaths')) {
                $child->addTemplateRootPaths(
                    $templateRootPaths,
                    $layoutRootPaths,
                    $partialRootPaths
                );
            }
        }
    }

    public function addHiddenFields(array $fields): void
    {
        $this->hiddenFields = (array) array_replace($this->hiddenFields, $fields);
    }


    public function setFormAttributes(array $formAttributes): void
    {
        $this->formAttributes = $formAttributes;
    }

    public function getPluginNode(): ?NodeInterface
    {
        return $this->pluginNode;
    }

    /**
     * Adds form attributes to support ajax handling of lists through the JS ResultsListController.
     * This assumes that the rest of the required attributes exist in the page around it and
     * also that a pagination input exists.
     *
     * @param string $ajaxAction
     * @param string $ajaxMethod
     * @return Root
     */
    public function addFormAttributesForAjax(string $ajaxAction, string $ajaxMethod = 'GET'): self
    {
        $this->formAttributes['data-toubiz-results-list.filter-form'] = '1';
        $this->formAttributes['data-toubiz-results-list.filter-form.ajax-action'] = $ajaxAction;
        $this->formAttributes['data-toubiz-results-list.filter-form.ajax-method'] = $ajaxMethod;
        $this->formAttributes['data-toubiz-results-list.filter-form.ajax-update-url'] = '1';

        $queryStrings = [];
        foreach ($this->flattenChildren($this) as $item) {
            if ($item instanceof QueryBoundFilterItem) {
                $queryStrings[] = $item->getQueryString();
            }
        }

        $this->formAttributes['data-toubiz-results-list.filter-form.ajax-update-url-parameters'] =
            implode(',', $queryStrings);

        return $this;
    }

    public function buildQueryStringFromOverrides(array $overrides): array
    {
        $queryOverrides = [ [] ];
        /** @var FilterItem|QueryBoundFilterItem $item */
        foreach ($this->flattenChildren($this) as $item) {
            if ($item instanceof ObstructionWrapper) {
                $item = $item->getObstructed();
            }

            if (!($item instanceof QueryBoundFilterItem)) {
                continue;
            }

            $queryOverrides[] = $item->getQueryForOverrides($overrides);
        }

        return array_merge(... $queryOverrides);
    }

    private function getHiddenFields(): array
    {
        $fields = [ $this->hiddenFields ];
        foreach ($this->flattenChildren($this) as $item) {
            if ($item instanceof ProvidesHiddenFields) {
                $fields[] = $item->getHiddenFields();
            }
        }
        return array_merge(...$fields);
    }

    /**
     * @return $this
     */
    public function enablePagination(bool $enabled): self
    {
        foreach ($this->flattenChildren($this) as $child) {
            if ($child instanceof PaginationInput) {
                $child->enablePagination($enabled);
            }
        }
        return $this;
    }

    protected function sanitizeOrder(): string
    {
        $order = NeosQuery::ORDER_ASCENDING;
        if (in_array(
            $this->order,
            [
                NeosQuery::ORDER_ASCENDING,
                NeosQuery::ORDER_DESCENDING,
            ],
            true
        )) {
            $order = $this->order;
        }
        return $order;
    }
}
