<?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\Controller\Fragment;

use Contao\CoreBundle\Controller\AbstractController;
use Doctrine\ORM\QueryBuilder;
use Ferienpass\CmsBundle\Filter\OfferListFilter;
use Ferienpass\CmsBundle\Filter\WhenPresetResolver;
use Ferienpass\CoreBundle\ApplicationSystem\ApplicationSystems;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Enum\OfferStatus;
use Ferienpass\CoreBundle\HttpCache\CacheTag;
use Ferienpass\CoreBundle\Pagination\Paginator;
use Ferienpass\CoreBundle\Repository\EditionRepository;
use Ferienpass\CoreBundle\Repository\OfferCategoryRepository;
use Ferienpass\CoreBundle\Repository\OfferRepositoryInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;

final class OfferListController extends AbstractController
{
    public function __construct(private readonly EditionRepository $editions, private readonly OfferRepositoryInterface $offers, private readonly OfferListFilter $filter, private readonly FormFactoryInterface $formFactory, private readonly ApplicationSystems $applicationSystems, private readonly OfferCategoryRepository $categories)
    {
    }

    public function __invoke(?array $ids, Request $request, Session $session): Response
    {
        /** @var QueryBuilder $qb */
        $qb = $this->offers->createQueryBuilder('o');

        self::prepareQueryBuilder($qb, null !== $ids || $request->query->has('varianten'));

        if (null !== $ids) {
            $qb->andWhere('o.id IN (:ids)')->setParameter('ids', $ids);
        }

        $hasEditions = null === $ids && $this->editions->count([]) > 0;
        $pageModel = $request->attributes->get('pageModel');
        if ($pageModel?->edition) {
            $edition = $this->editions->find($pageModel->edition);
        } else {
            $edition = $this->editions->findOneToShow($pageModel);
        }

        if ($hasEditions && null !== $edition) {
            $qb->andWhere('o.edition = :edition')->setParameter('edition', $edition);
        }

        if ($hasEditions && (null === $edition || !$edition->isOnline())) {
            // Offline: render the empty list, but make it self-correcting. The
            // edition whose upcoming `show_offers` window will populate it is
            // the one to tag (so an admin edit to that window purges this page)
            // and to time-bound against (so the cache expires when it opens).
            $boundaryEdition = $edition ?? $this->editions->findOneClosestByTask('show_offers');

            $tags = [CacheTag::offerCollection($edition)];
            if (null !== $boundaryEdition) {
                $tags[] = CacheTag::edition($boundaryEdition);
            }
            $this->tagResponse($tags);

            $response = $this->render('@Contao/fragment/offer_list.html.twig');
            $this->capTtlToShowOffersBoundary($response, $boundaryEdition?->findUpcomingTask('show_offers')?->getPeriodBegin());

            return $response;
        }

        if ($edition) {
            $applicationSystem = $this->applicationSystems->findApplicationSystem($edition);
        }

        $filterForm = $this->instantiateForm($request);

        $this->filter->apply($qb, $filterForm);

        $paginator = new Paginator($qb)->paginate($request->query->getInt('page', 1));

        // Per-offer tags purge the page when a rendered offer changes; the
        // edition-scoped collection tag additionally purges it when an offer
        // enters or leaves this edition's published set (draft → published,
        // cancel, complete, create, delete) — events the page's offers can't
        // carry a tag for. Tagged even when the result set is empty so the
        // first published offer makes the (cached-empty) list appear.
        $tags = array_map(static fn (OfferInterface $offer) => CacheTag::offer($offer), (array) $paginator->getResults());
        $tags[] = CacheTag::offerCollection($edition ?? null);
        if (null !== $edition) {
            // Carry the edition tag so an admin edit to the `show_offers` window
            // — a real mutation, which CacheInvalidationListener purges via
            // edition.<uuid> — also drops this list.
            $tags[] = CacheTag::edition($edition);
        }
        $this->tagResponse($tags);

        $response = $this->render('@Contao/fragment/offer_list.html.twig', [
            'filterForm' => $filterForm,
            'edition' => $edition ?? null,
            'pagination' => $paginator,
            'applicationSystem' => $applicationSystem ?? null,
            'categories' => $this->categories->findBy([], ['name' => 'ASC']),
            'hasCategories' => $this->categories->count([]) > 0,
        ]);

        if ($hasEditions && null !== $edition) {
            // Online now: expire when the current `show_offers` window closes so
            // the list flips back to empty on time, without waiting on a mutation.
            $this->capTtlToShowOffersBoundary($response, $edition->findCurrentTask('show_offers')?->getPeriodEnd());
        }

        return $response;
    }

    public static function prepareQueryBuilder(QueryBuilder $queryBuilder, bool $includePrivate = false): QueryBuilder
    {
        $published = OfferStatus::PUBLISHED;

        $queryBuilder
            ->leftJoin('o.dates', 'd')
            ->addSelect('d')
            ->where("JSON_CONTAINS_PATH(o.status, 'one', '$.$published') = 1")
            ->groupBy('o.id')
            ->addGroupBy('d.id')
        ;

        if (!$includePrivate) {
            $queryBuilder->andWhere("o.publish = 'public'");
        }

        return $queryBuilder;
    }

    protected function instantiateForm(Request $request): FormInterface
    {
        $filterDataFromUrl = array_filter($request->query->all(), fn (string $key) => \in_array($key, $this->filter->getFilterNames(), true), \ARRAY_FILTER_USE_KEY);

        foreach (['kategorie', 'alter'] as $multi) {
            $raw = $filterDataFromUrl[$multi] ?? null;
            if (null === $raw || '' === $raw || [] === $raw) {
                unset($filterDataFromUrl[$multi]);
                continue;
            }
            $candidates = \is_array($raw) ? $raw : explode(',', (string) $raw);
            $parts = array_values(array_filter(
                array_map(static fn ($v) => trim((string) $v), $candidates),
                static fn (string $v): bool => '' !== $v,
            ));
            if ([] === $parts) {
                unset($filterDataFromUrl[$multi]);
            } else {
                $filterDataFromUrl[$multi] = $parts;
            }
        }

        // Resolve "Wann?" presets to von/bis so existing date filters apply.
        // DateType (widget=single_text) expects "Y-m-d" strings on submit, not
        // DateTime instances — passing the object silently drops the filter.
        $when = $request->query->get('wann');
        if ($when && 'eigen' !== $when) {
            [$von, $bis] = WhenPresetResolver::resolve($when);
            if (null !== $von) {
                $filterDataFromUrl['von'] = $von->format('Y-m-d');
                $filterDataFromUrl['bis'] = $bis->format('Y-m-d');
            }
        }

        // Resolve "Preis" presets to existing checkbox / fee filters.
        $preis = $request->query->get('preis');
        if ('0' === $preis) {
            $filterDataFromUrl['kostenlos'] = '1';
            unset($filterDataFromUrl['gebuehr']);
        } elseif (\in_array($preis, ['10', '25', '50', '100'], true)) {
            $filterDataFromUrl['gebuehr'] = $preis;
        }

        $filterForm = $this->formFactory->create($this->filter::class);
        $filterForm->submit($filterDataFromUrl);

        return $this->formFactory->create($this->filter::class, $filterForm->getData());
    }

    /**
     * `show_offers` visibility is purely wall-clock driven (see {@see Edition::isOnline()}):
     * no entity mutation fires when the window opens or closes, so tag-based
     * invalidation alone never flips the cached list at the boundary. Cap the
     * shared TTL so Varnish re-renders exactly when the rendered set changes.
     */
    private function capTtlToShowOffersBoundary(Response $response, ?\DateTimeImmutable $boundary): void
    {
        if (null === $boundary) {
            return;
        }

        $seconds = $boundary->getTimestamp() - time();
        if ($seconds <= 0) {
            return;
        }

        $existing = $response->headers->getCacheControlDirective('s-maxage');
        $existing = is_numeric($existing) ? (int) $existing : null;

        if (null === $existing || $seconds < $existing) {
            $response->setSharedMaxAge($seconds);
        }
    }
}
