<?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\AbstractFragmentController;
use Contao\CoreBundle\EventListener\SubrequestCacheSubscriber;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\HttpCache\CacheTag;
use Symfony\Component\HttpFoundation\Response;

/**
 * ESI fragment for any per-user UI block on the CMS frontend.
 *
 * The surrounding CMS page is cached publicly by FOS user-context-hash (same
 * cache entry for everyone in the same role bucket — the offer list, the
 * homepage, etc. share one cached copy across all visitors). The user-
 * specific bits live in this fragment and are embedded via ESI so personal
 * details never reach the role-shared cache.
 *
 * Variants correspond to the places a page template would otherwise render
 * `{{ app.user.firstname }}` / etc. inline:
 *
 *   - `dropdown` (default) — desktop profile dropdown button + panel.
 *   - `mobile` — mobile hamburger-menu expanded user section (avatar +
 *                name + email + user nav).
 *
 * Tenants can introduce additional theme-scoped variants (e.g.
 * `dropdown_luebeck365`) when one tenant hosts multiple visually distinct
 * themes under the same Symfony bundle override dir. The variant string is
 * validated with a permissive lower-snake_case regex to guard against path
 * traversal; unknown values fall back to `dropdown`.
 *
 * Templates are resolved under the `@Contao` namespace so tenants can
 * override them in their normal Contao template directory:
 *
 *   apps/{tenant}/contao/templates/user_navigation/{variant}_{auth|anon}.html.twig
 *
 * Each tenant nav template replaces its inline `{% if app.user %}…{% endif %}`
 * block with:
 *
 *   {{ render_esi(controller(
 *       'Ferienpass\\CmsBundle\\Controller\\Fragment\\UserNavigationController',
 *       {variant: 'mobile'}
 *   )) }}
 *
 * ### Caching strategy (and the inline-fallback safety net)
 *
 * - **Anonymous** → `public, s-maxage=1y, Vary: Cookie`. One shared cache
 *   entry across every anonymous visitor — the body of the anon variant
 *   carries zero per-user data.
 *
 * - **Authenticated** → `private, no-store`. Two reasons, both mandatory:
 *
 *   1. **Direct ESI fetch.** Each user's Varnish sub-request goes straight
 *      to the backend, gets a fresh personal dropdown, no shared caching.
 *
 *   2. **Safety net when `render_esi()` inline-falls-back.** If
 *      `Surrogate-Capability` ever fails to reach the backend (e.g. a
 *      proxy strips it, a Symfony sub-request rebuild forgets it, a Twig
 *      filter like `apply machine_translate` interferes with the renderer
 *      lookup), Symfony silently runs this controller via the InlineRenderer
 *      and bakes the response body into the parent page's HTML. Contao's
 *      {@see SubrequestCacheSubscriber}
 *      then merges the sub-response's `Cache-Control` UP into the parent
 *      page — `private` propagates upward — and the parent is no longer
 *      eligible for role-bucket caching by Varnish.
 *
 *      Without this safety net, an inline-baked dropdown for User A could
 *      end up cached publicly under the role bucket and served to User B.
 *      This was the production symptom that motivated the explicit-private
 *      contract here.
 */
final class UserNavigationController extends AbstractFragmentController
{
    /**
     * Accept lower-snake_case variant names only. Prevents path traversal
     * (`../`, slashes) and keeps template lookup predictable. The safety
     * rationale is more important than the specific charset — just don't
     * relax this to include `.` or `/`.
     */
    private const VARIANT_PATTERN = '/^[a-z][a-z0-9_]*$/';

    public function __invoke(string $variant = 'dropdown'): Response
    {
        if (1 !== preg_match(self::VARIANT_PATTERN, $variant)) {
            $variant = 'dropdown';
        }

        $user = $this->getUser();
        $isAuthenticated = $user instanceof User;

        $template = \sprintf(
            '@Contao/user_navigation/%s_%s.html.twig',
            $variant,
            $isAuthenticated ? 'auth' : 'anon',
        );

        $response = $this->render($template, [
            'user' => $isAuthenticated ? $user : null,
            'variant' => $variant,
        ]);

        if ($isAuthenticated) {
            // Strict private. Even if `render_esi()` inline-falls-back and
            // bakes this body into the parent page response, Contao's
            // SubrequestCacheSubscriber merges this `private` UP into the
            // parent — preventing the role-bucket cache poisoning that
            // public + s-maxage would otherwise allow. See class docblock
            // §"Caching strategy (and the inline-fallback safety net)".
            //
            // CRITICAL: the merge propagation requires the
            // MERGE_CACHE_HEADER sentinel on BOTH the main response (stamped
            // by EnableCacheMergeListener) and on this sub-response (stamped
            // explicitly here). Without it, the subscriber's
            // `currentStrategy->add(…)` is skipped and the `private` never
            // makes it up — the parent page would still be cached publicly.
            $response->setPrivate();
            $response->headers->addCacheControlDirective('no-store');
            $response->headers->addCacheControlDirective('must-revalidate');
            $response->setVary(['Cookie']);
            $response->headers->set(SubrequestCacheSubscriber::MERGE_CACHE_HEADER, '1');

            // Contao's AbstractController (and therefore AbstractFragmentController)
            // provides tagResponse() — goes through the same SymfonyResponseTagger
            // as every other CMS response. Tag stays useful for direct
            // /_fragment URL caches that may exist briefly in browser cache.
            $this->tagResponse(CacheTag::user($user));

            return $response;
        }

        // Anonymous: aggressively shareable. The anon templates carry no
        // per-user data, so one cache entry serves every anonymous visitor.
        $response->setPublic();
        $response->setSharedMaxAge(31556926);
        $response->setVary(['Cookie']);

        return $response;
    }
}
