<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CmsBundle\EventListener\Routing;

use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\PageModel;
use Doctrine\DBAL\Connection;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Tenant\TenantContext;
use Ferienpass\CoreBundle\Tenant\TenantRootPage;
use Symfony\Cmf\Component\Routing\Event\RouterGenerateEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RequestStack;

#[AsEventListener('cmf_routing.pre_dynamic_generate')]
class CmsUrlGeneratorListener
{
    public function __construct(private readonly Connection $connection, private readonly RequestStack $requestStack, private readonly ContaoFramework $contaoFramework, private readonly TenantContext $tenantContext, private readonly TenantRootPage $rootPage)
    {
    }

    public function __invoke(RouterGenerateEvent $event): void
    {
        if ('cms' !== $event->getRoute()) {
            return;
        }

        $to = $event->getParameters()['to'] ?? '';
        if ('' === $to) {
            throw new \RuntimeException('Please specify the "to" parameter.');
        }

        $event->removeParameter('to');

        $dns = $event->getParameters()['uri'] ?? null;
        $dns ??= $this->requestStack->getMainRequest()?->getHost() ?? '';

        $event->removeParameter('uri');

        // On a multi-tenant installation the tenant is the boundary, not the
        // host: a cron or worker generating a link has whatever host the router
        // context carries, while the tenant is always known. Without a tenant
        // there is nothing to link into, so this fails closed rather than
        // reaching into another municipality's page tree.
        $allowedPages = $this->tenantContext->isEnabled()
            ? $this->pagesBelowRoot('tenant', (string) ($this->tenantContext->getTenant()?->getId() ?? 0))
            : $this->pagesBelowRoot('dns', $dns);

        $failClosed = $this->tenantContext->isEnabled() && [] === $allowedPages;

        $qb = $this->connection->createQueryBuilder()
            ->select('page.id')
            ->from('tl_page', 'page')
            ->where('type = :type')
            ->orderBy('page.published', 'DESC')
            ->setParameter('type', $to)
        ;

        if ([] !== $allowedPages) {
            $qb->andWhere($qb->expr()->in('page.id', $allowedPages));
        }

        $edition = $event->getParameters()['edition'] ?? null;
        if ($edition instanceof Edition) {
            $qb->andWhere($qb->expr()->or($qb->expr()->eq('page.edition', ':edition'), $qb->expr()->isNull('page.edition')));
            $qb->setParameter('edition', $edition->getId());
            $qb->orderBy('page.edition', 'DESC');
            $event->removeParameter('edition');
        }

        // Built even when it is never run: assembling it is what strips the
        // `edition` parameter off the event, which the fallback below must not
        // carry either.
        $pageId = $failClosed ? false : $qb
            ->executeQuery()
            ->fetchOne()
        ;

        if (false === $pageId) {
            $this->contaoFramework->initialize();

            $root = $this->tenantContext->isEnabled() ? $this->rootPage->current() : PageModel::findOneBy('type', 'root');
            if (null !== $root) {
                $event->setRoute(\sprintf('tl_page.%s.root', $root->id));

                return;
            }

            throw new \RuntimeException(\sprintf('Could not find a page of type "%s"', $to));
        }

        $autoItem = $event->getParameters()['auto_item'] ?? null;
        if (null !== $autoItem) {
            $event->setParameter('parameters', '/'.$autoItem);
            $event->removeParameter('auto_item');
        }

        $event->setRoute('tl_page.'.$pageId);
    }

    /**
     * Every page below the root page identified by $column = $value.
     *
     * @return list<int>
     */
    private function pagesBelowRoot(string $column, string $value): array
    {
        // Interpolated into the statement, so it stays a closed set.
        $column = match ($column) {
            'dns', 'tenant' => $column,
            default => throw new \InvalidArgumentException(\sprintf('Cannot identify a root page by "%s".', $column)),
        };

        $ids = $this->connection->executeQuery(
            \sprintf(
                <<<'SQL'
                    with recursive cte (id, name, pid) as (
                        select     id,
                                   title,
                                   pid
                        from       tl_page
                        where      type = 'root' and %s=?
                        union all
                        select     p.id,
                                   p.title,
                                   p.pid
                        from       tl_page p
                                       inner join cte
                                                  on p.pid = cte.id
                    ) select * from cte;

                    SQL,
                $column,
            ),
            [$value],
        )->fetchFirstColumn();

        return array_map(static fn ($id): int => (int) $id, $ids);
    }
}
