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

use Ferienpass\CmsBundle\Controller\Page\OfferListPage;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
 * Regression guard against the production cache-leak bug where the
 * `ferienpass.fragment.offer_details` FragmentReference was created with
 * the Offer **entity** as its `offer` attribute. Contao's
 * {@see \Contao\CoreBundle\Fragment\FragmentHandler} silently downgrades
 * any renderer back to `'forward'` when the FragmentReference attributes
 * contain non-scalar values (see
 * `vendor/contao/core-bundle/src/Fragment/FragmentHandler.php:60-62`):
 *
 *     if ('inline' !== $renderer && $this->containsNonScalars(...)) {
 *         $renderer = 'forward';
 *     }
 *
 * That silently disabled the `renderer: 'esi'` setting on
 * `OfferDetailsController` — PageBuilder dispatched OfferDetails as an
 * in-process sub-request, the nested `render_esi(ApplicationForm)` fell
 * back to inline, the visitor's per-user form HTML got baked into the
 * sub-response body, and the body ended up in Varnish's role-bucket
 * cache slot. Every user in the same role bucket then got the first
 * user's application form.
 *
 * Fix: callers must pass the offer's **UUID string** instead of the
 * entity. The FragmentReference attribute stays scalar, the renderer
 * stays `'esi'`, and Varnish stitches the page from separate per-offer
 * and per-user cache slots.
 *
 * This test scans every PHP file under `cms-bundle/src/Controller/Page/`
 * for `new FragmentReference('ferienpass.fragment.offer_details', …)`
 * constructions and verifies they do NOT pass an entity-typed `offer`.
 * Detection heuristic: the literal source must include `->toRfc4122()`
 * (or `->getUuid()` immediately followed by stringification) inside the
 * FragmentReference's attribute array. If a future caller writes
 * `['offer' => $someEntity]`, the test fails and the leak comes back
 * before it ships.
 */
final class OfferDetailsFragmentReferenceTest extends TestCase
{
    /**
     * @return iterable<string, array{class-string}>
     */
    public static function callerControllers(): iterable
    {
        yield 'OfferListPage' => [OfferListPage::class];
        // ConsentLinkController used to dispatch ferienpass.fragment.offer_details, but
        // commit 322fcc5b gave the consent page its own ferienpass.fragment.consent_sign
        // fragment, so it no longer participates in the offer-details path.
    }

    /**
     * @param class-string $controllerClass
     */
    #[DataProvider('callerControllers')]
    public function testCallerPassesScalarUuidToOfferDetailsFragment(string $controllerClass): void
    {
        $source = file_get_contents(new \ReflectionClass($controllerClass)->getFileName());
        self::assertIsString($source, \sprintf('Must be able to read source of %s.', $controllerClass));

        // Find every `new FragmentReference('ferienpass.fragment.offer_details', [...])` call.
        // The dotall flag (`s`) lets `.` span newlines so a multi-line array literal is captured.
        $count = preg_match_all(
            '/new\s+FragmentReference\s*\(\s*[\'"]ferienpass\.fragment\.offer_details[\'"]\s*,\s*\[(.+?)\]\s*\)/s',
            $source,
            $matches,
        );

        self::assertGreaterThan(
            0,
            $count,
            \sprintf(
                '%s no longer references ferienpass.fragment.offer_details — either this test '
                .'needs updating, or someone removed the offer-details dispatch path entirely.',
                $controllerClass,
            ),
        );

        foreach ($matches[1] as $attributeBlock) {
            // Pull the value bound to the `offer` key from the attribute literal.
            // Permissive on whitespace; intentionally NOT a full PHP parser.
            self::assertMatchesRegularExpression(
                '/[\'"]offer[\'"]\s*=>\s*[^,]*->(toRfc4122|getUuid)\b/',
                $attributeBlock,
                \sprintf(
                    'In %s, the FragmentReference for `ferienpass.fragment.offer_details` must pass '
                    .'the offer UUID as a SCALAR STRING (e.g. `$offer->getUuid()->toRfc4122()`), NOT '
                    .'the Offer entity. Contao\'s FragmentHandler silently downgrades the renderer to '
                    ."'forward' on non-scalar attributes, which disables the renderer:'esi' setting on "
                    .'OfferDetailsController and reopens the cross-user cache-leak bug. See the '
                    ."class docblock for the full chain. Attribute block found:\n%s",
                    $controllerClass,
                    $attributeBlock,
                ),
            );
        }
    }
}
