<?php declare(strict_types=1);

namespace Newland\Toubiz\Map\Neos\Provider;

use Neos\Cache\Frontend\FrontendInterface as CacheFrontendInterface;
use Neos\ContentRepository\Domain\Model\Node;
use Neos\ContentRepository\Domain\Projection\Content\NodeInterface;
use Neos\ContentRepository\Domain\Projection\Content\TraversableNodeInterface;
use Neos\ContentRepository\Exception\NodeException;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Cache\CacheManager;
use Neos\Flow\ObjectManagement\ObjectManager;
use Neos\Flow\Reflection\ReflectionService;
use Newland\Toubiz\Map\Neos\Provider\Contract\FilterItem;
use Newland\Toubiz\Map\Neos\Provider\Contract\FilterItemProvider;
use Newland\Toubiz\Map\Neos\Provider\Contract\Marker;
use Newland\Toubiz\Map\Neos\Provider\Contract\MarkerProvider;
use Newland\Toubiz\Map\Neos\Provider\Contract\ProviderContext;
use Newland\Toubiz\Map\Neos\Provider\DefaultProviders\NodeBasedFilterItems;
use Newland\Toubiz\Map\Neos\Provider\DefaultProviders\SubnodeBasedMarkerProvider;
use Newland\Toubiz\Map\Neos\Provider\Pagination\Paginator;

/**
 * @Flow\Scope("singleton")
 */
class MapDataProviderService
{
    const CACHE_NAME = 'Newland_Toubiz_Map_Neos-Data';
    public const TYPE_MARKERS_WILDCARD = 'Newland.Toubiz.Map.Neos:Map.Markers';
    public const TYPE_MAP = 'Newland.Toubiz.Map.Neos:Map';

    /** @var CacheFrontendInterface */
    protected $cache;
    public function injectCache(CacheManager $cacheManager): void
    {
        $this->cache = $cacheManager->getCache(static::CACHE_NAME);
    }

    /**
     * @var ObjectManager
     * @Flow\Inject()
     */
    protected $objectManager;

    /**
     * @var int
     * @Flow\InjectConfiguration(path="pagination.pageSize")
     */
    protected $pageSize;

    /**
     * @Flow\CompileStatic()
     * @param ObjectManager $objectManager
     * @return string[]
     */
    public static function getProviderClassNames($objectManager): array
    {
        return $objectManager
            ->get(ReflectionService::class)
            ->getClassNamesByAnnotation(MapDataProvider::class);
    }

    /**
     * @return array<string, Marker> $markers
     */
    public function markersForSubnode(Node $node, int $offset = 0, int $limit = 100): array
    {
        $markers = [ [] ];

        foreach ($this->markerProviders() as $provider) {
            if (!($provider instanceof SubnodeBasedMarkerProvider) || !$provider->applies($node)) {
                continue;
            }

            $markers[] = $this->cacheOrGenerate(
                sprintf('%s__offset-%d__limit-%d', md5($node->getContextPath()), $offset, $limit),
                function () use ($provider, $node, $offset, $limit) {
                    return $provider->markersForNode($node, $offset, $limit);
                }
            );
        }

        return array_merge(...$markers);
    }

    public function getMarkers(ProviderContext $context): Paginator
    {
        $paginator = new Paginator($this->pageSize);
        foreach ($this->markerProviders() as $provider) {
            /** @var MarkerProvider $provider */

            $paginator->addProvider(
                $provider->getNumberOfMarkers($context),
                function (int $offset, int $limit) use ($context, $provider) {
                    $key = $provider->markerCacheKey($context);
                    if ($key) {
                        $key .= sprintf('__offset-%d__limit-%d', $offset, $limit);
                    }
                    return $this->cacheOrGenerate(
                        $key,
                        function () use ($provider, $context, $offset, $limit) {
                            return array_values($provider->getMarkers($context, $offset, $limit));
                        }
                    );
                }
            );
        }

        return $paginator;
    }

    public function getFilterItems(ProviderContext $context): array
    {
        /** @var array<string, FilterItem> $items */
        $items = [];
        $hasOpenByDefault = false;

        foreach ($this->filterItemProviders() as $provider) {
            /** @var FilterItem[] $itemsFromSource */
            $itemsFromSource = $this->cacheOrGenerate(
                $provider->filterItemCacheKey($context),
                function () use ($provider, $context) {
                    return $provider->getFilterItems($context);
                }
            );

            foreach ($itemsFromSource as $item) {
                if (!($item instanceof FilterItem)) {
                    throw new \InvalidArgumentException(sprintf(
                        'Filter item sources must return an array of %s instances. %s does not.',
                        FilterItem::class,
                        get_class($provider)
                    ));
                }
                if ($item->openByDefault) {
                    $item->openByDefault = !$hasOpenByDefault;
                    $hasOpenByDefault = true;
                }
                if (!array_key_exists($item->id, $items)) {
                    $items[$item->id] = $item;
                }
            }
        }

        return $items;
    }

    public function clearCache(): void
    {
        $this->cache->flush();
    }

    public function getMapNode(NodeInterface $node): ?TraversableNodeInterface
    {
        $allTypes = [
            self::TYPE_MAP,
            self::TYPE_MARKERS_WILDCARD,
            NodeBasedFilterItems::FILTER_ITEM,
        ];
        if (!$this->isOfOneOfGivenTypes($node, $allTypes)) {
            return null;
        }

        $mapNode = $this->getParentNodeOfType($node, self::TYPE_MAP);
        if ($mapNode === null || !($mapNode instanceof TraversableNodeInterface)) {
            return null;
        }

        return $mapNode;
    }


    /**
     * @return \Generator<MarkerProvider>
     */
    private function markerProviders(): \Generator
    {
        foreach (static::getProviderClassNames($this->objectManager) as $className) {
            $instance = $this->objectManager->get($className);
            if ($instance instanceof MarkerProvider) {
                yield $instance;
            }
        }
    }

    /**
     * @return \Generator<FilterItemProvider>
     */
    private function filterItemProviders(): \Generator
    {
        foreach (static::getProviderClassNames($this->objectManager) as $className) {
            $instance = $this->objectManager->get($className);
            if ($instance instanceof FilterItemProvider) {
                yield $instance;
            }
        }
    }

    private function cacheOrGenerate(?string $key, callable $generator): array
    {
        if (!$key) {
            return $generator();
        }

        if ($this->cache->has($key)) {
            return $this->cache->get($key);
        }

        $result = $generator();
        $this->cache->set($key, $result);
        return $result;
    }


    /** @param string[] $types */
    protected function isOfOneOfGivenTypes(NodeInterface $node, array $types): bool
    {
        foreach ($types as $type) {
            if ($node->getNodeType()->isOfType($type)) {
                return true;
            }
        }
        return false;
    }

    private function getParentNodeOfType(NodeInterface $node, string $type): ?NodeInterface
    {
        try {
            do {
                if (!($node instanceof TraversableNodeInterface)) {
                    return null;
                }
                if ($node->getNodeType()->isOfType($type)) {
                    return $node;
                }

                /** @var TraversableNodeInterface|null $node */
                $node = $node->findParentNode();
            } while ($node);
        } catch (NodeException $e) {
            return null;
        }

        return null;
    }
}
