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

use Contao\CoreBundle\ContaoCoreBundle;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;

/**
 * Marks requests to Symfony's signed fragment endpoint (`/_fragment`) as
 * Contao frontend-scoped so the `contao_frontend` firewall actually fires
 * and authenticates the requesting user.
 *
 * ### Why this is needed
 *
 * `contao_frontend` in `config/packages/security.yaml` is gated by
 * {@see \Contao\CoreBundle\Routing\Matcher\FrontendMatcher} which only
 * matches when the request attribute `_scope === 'frontend'`. For real
 * Contao pages, Contao's router sets that attribute during routing.
 *
 * When a CMS Twig template calls
 * `render_esi(controller('…CmsBundle\\Controller\\Fragment\\…'))`, Symfony
 * generates a signed `/_fragment?_path=…` URL. Varnish (or Symfony's inline
 * fallback) makes an HTTP sub-request to that URL. That sub-request never
 * matches a Contao route, so `_scope` is never populated — the frontend
 * firewall silently refuses to match, the user stays anonymous, and
 * `$this->getUser()` in the fragment controller returns `null`. The
 * fragment then renders its `*_anon.html.twig` variant — an empty stub for
 * most tenants — and the outer dropdown appears blank for logged-in users.
 *
 * Setting `_scope = frontend` here, BEFORE the firewall listener fires,
 * restores the intended behaviour.
 *
 * ### Priority / ordering
 *
 * Must fire AFTER Symfony's `FragmentListener` (priority 48) has populated
 * `_controller` from the signed URI — otherwise we'd see an empty
 * controller attribute and skip. Must fire BEFORE the Symfony firewall
 * (priority 8) so the scope is set when the firewall matchers run.
 * Priority 40 is comfortably inside that window.
 *
 * ### Scope safety
 *
 * Only sets `_scope` when:
 *   1. No `_scope` already present — respects Contao routes, FragmentReference
 *      attributes, and anything else that declares its own scope.
 *   2. Path is exactly `/_fragment` — other endpoints are out of scope.
 *   3. The dispatched controller lives under
 *      `Ferienpass\CmsBundle\Controller\Fragment\` — prevents accidentally
 *      authenticating admin or third-party fragments as frontend users.
 */
final class FragmentFrontendScopeListener
{
    private const FRAGMENT_PATH = '/_fragment';
    private const CMS_FRAGMENT_CONTROLLER_PREFIX = 'Ferienpass\\CmsBundle\\Controller\\Fragment\\';

    #[AsEventListener(priority: 40)]
    public function __invoke(RequestEvent $event): void
    {
        $request = $event->getRequest();

        if ($request->attributes->has('_scope')) {
            return;
        }

        if (self::FRAGMENT_PATH !== $request->getPathInfo()) {
            return;
        }

        $controller = (string) $request->attributes->get('_controller');
        if (!str_starts_with($controller, self::CMS_FRAGMENT_CONTROLLER_PREFIX)) {
            return;
        }

        $request->attributes->set('_scope', ContaoCoreBundle::SCOPE_FRONTEND);
    }
}
