<?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\Page;

use Contao\CoreBundle\Exception\PageNotFoundException;
use Contao\PageModel;
use Ferienpass\CmsBundle\Controller\AbstractController;
use Ferienpass\CmsBundle\Filter\OfferListFilter;
use Ferienpass\CmsBundle\Fragment\FragmentReference;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Export\Offer\ICal\ICalExport;
use Ferienpass\CoreBundle\HttpCache\CacheTag;
use Ferienpass\CoreBundle\Repository\EditionRepository;
use Ferienpass\CoreBundle\Repository\OfferRepositoryInterface;
use Ferienpass\CoreBundle\Service\OfferPrivateLinkService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;

/**
 * Public, edition-scoped landing pages — a Symfony route that
 * borrows the Contao root page's layout via {@see PageBuilder}, no
 * dedicated `tl_page` required).
 *
 *  - `/o/{alias}`                  → the edition's offer list
 *  - `/o/{alias}/{base58}`         → a single offer's detail page (no alias)
 *  - `/o/{alias}/{base58}-{slug}`  → a single offer's detail page
 *
 * The detail URL is a base58-encoded offer UUID plus a decorative
 * `-{slug}` tail (rename-stable lookup key + human-readable tail, like
 * StackOverflow's `/questions/{id}/{slug}`). The base58 part is
 * authoritative; the slug tail self-canonicalises (stale slug → 301).
 * These `/o/...` URLs are the canonical home of every offer — the legacy
 * Contao `offer_list` reader emits `<link rel="canonical">` here.
 *
 * The prefix is `/o` (offers); `/p` is reserved by PrivateLinkController.
 */
#[Route('/o')]
final class EditionLandingController extends AbstractController
{
    public function __construct(
        private readonly EditionRepository $editions,
        private readonly OfferRepositoryInterface $offers,
        private readonly OfferPrivateLinkService $privateLinks,
        private readonly ICalExport $iCalExport,
        private readonly OfferListFilter $filter,
    ) {
    }

    #[Route('/{alias}', name: 'cms_edition_landing')]
    public function list(string $alias, Request $request): Response
    {
        $this->initializeContaoFramework();

        $edition = $this->resolveOnlineEdition($alias);

        $pageModel = $this->rootPageModel();
        $pageModel->title = (string) $edition->getName();
        // Scope the shared offer_list fragment to this edition (the
        // fragment reads $pageModel->edition; see OfferListController).
        $pageModel->edition = $edition->getId();

        $this->tagResponse(CacheTag::edition($edition));

        $pageBuilder = $this->createPageBuilder($pageModel);

        // Optional intro copy, rendered above the list.
        if ('' !== trim((string) $edition->getWelcomeText())) {
            $pageBuilder->addFragment('main', new FragmentReference('ferienpass.fragment.edition_welcome', ['edition' => $edition->getId()]));
        }

        $pageBuilder->addFragment('main', new FragmentReference('ferienpass.fragment.offer_list'));

        // Same crawl trap as the legacy offer_list page: this landing page
        // mounts the very same filterable fragment. See OfferListPage.
        if ($this->filter->isFiltered($request)) {
            $pageBuilder->setMetaRobots('noindex,follow');
        }

        return $pageBuilder->getResponse();
    }

    #[Route('/{alias}/{offerUuid}', name: 'cms_edition_offer', requirements: ['offerUuid' => '[^-/]+'])]
    #[Route('/{alias}/{offerUuid}-{offerSlug}', name: 'cms_edition_offer_slug', requirements: ['offerUuid' => '[^-/]+', 'offerSlug' => '[^/]+'])]
    public function detail(string $alias, string $offerUuid, Request $request, ?string $offerSlug = null): Response
    {
        $this->initializeContaoFramework();

        try {
            $uuid = Uuid::fromBase58($offerUuid);
        } catch (\InvalidArgumentException) {
            throw new PageNotFoundException('Malformed offer identifier');
        }

        $offerEntity = $this->offers->findByUuid($uuid);
        if (!$offerEntity instanceof OfferInterface) {
            throw new PageNotFoundException('Offer not found');
        }

        $edition = $offerEntity->getEdition();

        // The edition-slug is part of the canonical URL: a wrong/stale slug
        // is treated as a different (non-existent) URL, not a redirect.
        if (!$edition instanceof Edition || $edition->getAlias() !== $alias) {
            throw new PageNotFoundException('Offer does not belong to this edition');
        }

        // Public visibility requires all three: the offer is workflow-published,
        // marked publish=public (private offers are reachable only via their
        // private link), and its edition's show_offers window is open
        // (Edition::isOnline() is true when the edition has no show_offers task
        // at all).
        $publiclyVisible = $edition->isOnline() && $offerEntity->isPublished() && 'public' === $offerEntity->getPublish();

        // A valid `k` (the private-link signature) grants access to an
        // otherwise-hidden offer — the /p private link redirects here with it.
        // Admin preview also bypasses the public gate.
        $preview = $request->attributes->getBoolean('preview');
        $key = (string) $request->query->get('k', '');
        $hasValidKey = '' !== $key && $this->privateLinks->isValidSignature($offerEntity, $key);

        if (!$publiclyVisible && !$preview && !$hasValidKey) {
            throw new PageNotFoundException('Offer not available');
        }

        // Self-canonicalise the URL. The access key is preserved only while it
        // is actually needed (non-public access via a valid key); a public
        // offer drops a stray `k` so its canonical URL stays key-free and
        // shares a single cache entry.
        $keepKey = !$publiclyVisible && $hasValidKey;
        $slugMismatch = (string) $offerSlug !== (string) $offerEntity->getAlias();
        if ($slugMismatch || ($publiclyVisible && '' !== $key)) {
            [$route, $params] = self::offerRouteAndParams($offerEntity);
            if ($keepKey) {
                $params['k'] = $key;
            }

            return new RedirectResponse($this->generateUrl($route, $params), Response::HTTP_MOVED_PERMANENTLY);
        }

        $pageModel = $this->rootPageModel();
        $pageModel->title = (string) $offerEntity->getName();
        $pageModel->edition = $edition->getId();

        $this->tagResponse(CacheTag::offer($offerEntity));

        $response = $this->createPageBuilder($pageModel)
            // Pass the UUID string (not the entity) so Contao's
            // FragmentHandler doesn't downgrade the renderer to 'forward'
            // — see OfferDetailsController docblock.
            ->addFragment('main', new FragmentReference('ferienpass.fragment.offer_details', ['offer' => $offerEntity->getUuid()->toRfc4122()]))
            ->getResponse()
        ;

        // Cacheable iff the cache key itself gates access:
        //  - public offer: keyless URL, public content — cache normally;
        //  - valid ?k:     the credential is in the URL (and thus in Varnish's
        //                  cache key), so only link-holders can hit the cached
        //                  copy, exactly like a signed CDN URL. Tagged with the
        //                  offer, so it's invalidated on change like any other.
        // Admin preview is the one case with neither: it's granted by a request
        // attribute on the *keyless* canonical URL, so its cache key is identical
        // to the public one — caching a draft/private preview there would serve
        // it to the public. Hence no-store for preview only.
        //
        // NB: relies on Varnish keeping `k` in the cache key (it must, or the
        // backend wouldn't see it either) — same assumption the whole ?k grant
        // already depends on.
        $gatedByCacheKey = $publiclyVisible || $hasValidKey;
        if (!$gatedByCacheKey) {
            $this->markPageAsPrivate($response);
        }

        return $response;
    }

    // Higher priority than the HTML detail routes above: their `offerSlug`
    // pattern (`[^/]+`) otherwise greedily swallows the `.ics` suffix, so a
    // `.ics` request would match the HTML route first (registration order) and
    // 301-redirect to the keyless HTML page instead of streaming the calendar.
    #[Route('/{alias}/{offerUuid}.ics', name: 'cms_offer_ics', requirements: ['offerUuid' => '[^-/]+'], methods: ['GET'], priority: 10)]
    #[Route('/{alias}/{offerUuid}-{offerSlug}.ics', name: 'cms_offer_ics_slug', requirements: ['offerUuid' => '[^-/]+', 'offerSlug' => '[^/]+'], methods: ['GET'], priority: 10)]
    public function ics(string $alias, string $offerUuid, Request $request, ?string $offerSlug = null): Response
    {
        try {
            $uuid = Uuid::fromBase58($offerUuid);
        } catch (\InvalidArgumentException) {
            throw new PageNotFoundException('Malformed offer identifier');
        }

        $offerEntity = $this->offers->findByUuid($uuid);
        if (!$offerEntity instanceof OfferInterface) {
            throw new PageNotFoundException('Offer not found');
        }

        $edition = $offerEntity->getEdition();
        if (!$edition instanceof Edition || $edition->getAlias() !== $alias) {
            throw new PageNotFoundException('Offer does not belong to this edition');
        }

        // Stale slug = different URL (no redirect — see method docblock).
        if (null !== $offerSlug && $offerSlug !== (string) $offerEntity->getAlias()) {
            throw new PageNotFoundException('Stale offer slug');
        }

        // Cancelled offers don't get a calendar entry — the date isn't real
        // any more and we don't want orphan VEVENTs sitting in users'
        // calendars after the host cancelled.
        if ($offerEntity->isCancelled()) {
            throw new PageNotFoundException('Offer cancelled');
        }

        // Same gating as detail(): public offers are open, private offers
        // need a valid `?k=` signature, admin preview bypasses both.
        $publiclyVisible = $edition->isOnline() && $offerEntity->isPublished() && 'public' === $offerEntity->getPublish();
        $preview = $request->attributes->getBoolean('preview');
        $key = (string) $request->query->get('k', '');
        $hasValidKey = '' !== $key && $this->privateLinks->isValidSignature($offerEntity, $key);

        if (!$publiclyVisible && !$preview && !$hasValidKey) {
            throw new PageNotFoundException('Offer not available');
        }

        $this->tagResponse(CacheTag::offer($offerEntity));

        $offerAlias = $offerEntity->getAlias();
        $filename = \is_string($offerAlias) && '' !== $offerAlias
            ? 'termin-'.preg_replace('/[^a-z0-9-]+/i', '-', $offerAlias).'.ics'
            : 'termin-'.$offerEntity->getUuid()->toBase58().'.ics';

        $iCal = $this->iCalExport;
        $response = new StreamedResponse(static function () use ($iCal, $offerEntity): void {
            $stream = $iCal->generate([$offerEntity], stream: true);
            if (!\is_resource($stream)) {
                return;
            }
            try {
                fpassthru($stream);
            } finally {
                fclose($stream);
            }
        });

        $response->headers->set('Content-Type', 'text/calendar; charset=utf-8');
        $response->headers->set(
            'Content-Disposition',
            \sprintf('attachment; filename="%s"', $filename),
        );

        // Same cacheability rule as detail(): public + keyless OR signed
        // URL (the `k` is in Varnish's cache key) ⇒ cacheable. Admin preview
        // on the canonical keyless URL ⇒ no-store so a draft can't be
        // served to anonymous visitors that hit the same key.
        $gatedByCacheKey = $publiclyVisible || $hasValidKey;
        if ($gatedByCacheKey) {
            $response->setPublic();
        } else {
            $this->markPageAsPrivate($response);
        }

        return $response;
    }

    /**
     * Route name + params for an offer's canonical detail URL. Picks the
     * {uuid}-{slug} route when the offer has an alias, the bare-{uuid}
     * route otherwise. The base58 UUID is the authoritative lookup key;
     * the slug is the decorative tail. Every link to the detail page must
     * go through this helper so the route choice stays consistent.
     *
     * @return array{0: string, 1: array<string, string>}
     */
    public static function offerRouteAndParams(OfferInterface $offer): array
    {
        $params = [
            'alias' => (string) $offer->getEdition()?->getAlias(),
            'offerUuid' => $offer->getUuid()->toBase58(),
        ];

        $slug = (string) $offer->getAlias();
        if ('' !== $slug) {
            $params['offerSlug'] = $slug;

            return ['cms_edition_offer_slug', $params];
        }

        return ['cms_edition_offer', $params];
    }

    public static function offerIcsRouteAndParams(OfferInterface $offer): array
    {
        [$route, $params] = self::offerRouteAndParams($offer);

        return ['cms_edition_offer_slug' === $route ? 'cms_offer_ics_slug' : 'cms_offer_ics', $params];
    }

    private function resolveOnlineEdition(string $alias): Edition
    {
        $edition = $this->editions->findOneBy(['alias' => $alias]);
        if (!$edition instanceof Edition) {
            throw new PageNotFoundException('Edition not found');
        }

        if (!$edition->isOnline()) {
            // show_offers boundary not active → page does not exist yet /
            // any more (preview handling integrated later).
            throw new PageNotFoundException('Edition not online');
        }

        return $edition;
    }

    private function rootPageModel(): PageModel
    {
        $rootPages = PageModel::findPublishedRootPages();
        if (null === $rootPages || 0 === \count($rootPages)) {
            throw new PageNotFoundException('No published root page');
        }

        return $rootPages[0];
    }
}
