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

use PHPUnit\Framework\TestCase;
use Symfony\Component\Process\Process;

/**
 * Every class in the bundles must be *declarable*. PHP rejects some declarations
 * outright — most easily hit by a controller or live component that names a method
 * like one of {@see \Symfony\Bundle\FrameworkBundle\Controller\AbstractController}'s
 * helpers (`file()`, `stream()`, `render()`, `json()`, …) with an incompatible
 * signature. That is a compile-time fatal, so the container cannot be built and the
 * whole application is down.
 *
 * The rest of the suite never loads most of these classes, so such a clash used to
 * surface only on `cache:clear` in the application — i.e. after a release. This test
 * declares all of them, in subprocesses because the error cannot be caught
 * in-process: each run dies on the first offender (which it names in a progress
 * file), and that one is then skipped so the run after it reaches the classes
 * behind it.
 */
class ClassDeclarationTest extends TestCase
{
    /**
     * Classes this repository alone cannot declare, and why. NOT a list of things
     * to fix here — each entry is a signature that only resolves against a
     * dependency version the application installs, not one this repository does.
     *
     * Anything else showing up in the failure message is a real, application-down
     * bug in the declaration it names.
     *
     * @var list<string>
     */
    private const ALLOWED = [
        // Implements Contao's PublicUriProviderInterface against `Options`, which
        // the Contao version resolved here does not ship.
        'Ferienpass\CoreBundle\Filesystem\PublicUri\StoragePublicUriProvider',
    ];

    public function testEveryClassCanBeDeclared(): void
    {
        $undeclarable = [];
        $progress = tempnam(sys_get_temp_dir(), 'class_declaration_');
        self::assertNotFalse($progress);

        // One extra round so a run that only removes the last offender still gets
        // to complete cleanly.
        for ($i = 0; $i <= \count(self::ALLOWED) + 5; ++$i) {
            $process = new Process([\PHP_BINARY, __DIR__.'/Fixtures/load-all-classes.php', implode(',', $undeclarable), $progress]);
            $process->setTimeout(180);
            $process->run();

            if ($process->isSuccessful() && str_contains($process->getOutput(), 'DECLARED ')) {
                // Guard the guard: a scan that silently found nothing would pass.
                preg_match('/DECLARED (\d+)/', $process->getOutput(), $m);
                self::assertGreaterThan(500, (int) ($m[1] ?? 0), 'the class scan found suspiciously few classes');

                @unlink($progress);

                self::assertSame(self::ALLOWED, $undeclarable, implode("\n", [
                    'PHP refused these class declarations — the application cannot boot with them:',
                    ...array_diff($undeclarable, self::ALLOWED),
                    '',
                    'Rename the offending method (it most likely shadows an AbstractController helper).',
                ]));

                return;
            }

            // The loader records each class before attempting it, so the file holds
            // the one PHP refused.
            $offender = (string) file_get_contents($progress);

            self::assertNotSame('', $offender, "the class loader did not start:\n".$process->getErrorOutput());
            self::assertNotContains($offender, $undeclarable, 'the class loader did not make progress');

            $undeclarable[] = $offender;
        }

        @unlink($progress);

        self::fail('too many undeclarable classes: '.implode(', ', $undeclarable));
    }
}
