<?php

declare(strict_types=1);

namespace App\Tests\Architecture;

use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Country;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Transport\Domain\Entity\QualimatCarrier;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use Doctrine\ORM\Mapping\Entity;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\Finder\Finder;

use function in_array;

/**
 * Garde-fou architecture (niveau L3 de la spec § 2.8.bis).
 *
 * Scanne toutes les entites Doctrine sous `src/Module/<module>/Domain/Entity/`
 * et verifie qu'elles implementent TimestampableInterface ET BlamableInterface
 * (via TimestampableBlamableTrait). Empeche tout oubli du pattern sur une
 * nouvelle entite metier : la CI passe au rouge.
 *
 * @internal
 */
final class EntitiesAreTimestampableBlamableTest extends TestCase
{
    /**
     * Entites explicitement exemptees du pattern.
     *
     * Au M0, on whiteliste les 4 entites preexistantes du noyau (creees avant
     * l'introduction du pattern) : leur retrofit est une decision archi a part
     * entiere, hors scope ERP-52.
     *
     * - User : referentiel d'authentification, createdAt gere manuellement dans
     *   le constructeur. Retrofit hors scope M0 (cf. HP-9) : impose de trancher
     *   la recursion Blamable (un User cree par un User) + casse des tests
     *   existants.
     * - Role : referentiel RBAC synchronise via `app:sync-permissions`, pas de
     *   tracabilite user-driven necessaire.
     * - Permission : idem Role (synchronise, pas pilote utilisateur).
     * - Site : referentiel admin-managed, a integrer dans un futur module Sites
     *   v2 (cf. HP-10).
     * - CategoryType : referentiel statique (codes de typage des categories),
     *   pas de besoin de tracabilite user-driven (cree par migration/seed,
     *   pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17.
     * - TvaMode / PaymentDelay / PaymentType / Bank (M1 Commercial) : referentiels
     *   comptables statiques (id/code/label/position), seedes par migration +
     *   CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
     *   tracabilite user-driven, meme justification que CategoryType. Cf.
     *   spec-back M1 § 2.6 + § 3.5.
     * - Country (ERP-116) : referentiel statique des pays (id/code/name/position),
     *   seede par migration, lecture seule. Meme justification que Bank.
     * - QualimatCarrier (M4, ERP-39/155) : mapping ORM LECTURE SEULE sur la table
     *   referentielle qualimat_carrier, alimentee/soft-deletee exclusivement par
     *   la commande `app:qualimat:sync` (pas de tracabilite user-driven, pas
     *   d'ecriture API). Meme justification que les referentiels ci-dessus.
     *
     * Les futurs referentiels statiques s'ajoutent ici avec une justification.
     */
    private const EXCLUDED = [
        User::class,
        Role::class,
        Permission::class,
        Site::class,
        CategoryType::class,
        TvaMode::class,
        PaymentDelay::class,
        PaymentType::class,
        Bank::class,
        Country::class,
        QualimatCarrier::class,
    ];

    public function testAllBusinessEntitiesImplementBothInterfaces(): void
    {
        // Garde : chaque entree de la whitelist doit pointer sur une classe
        // reelle. Empeche un FQCN errone de masquer silencieusement un oubli.
        foreach (self::EXCLUDED as $excluded) {
            self::assertTrue(class_exists($excluded), sprintf('Classe whitelistee inexistante : %s', $excluded));
        }

        $finder = new Finder()
            ->files()
            ->in(__DIR__.'/../../src/Module')
            ->path('Domain/Entity')
            ->name('*.php')
        ;

        // Garde : si le scan ne trouve rien, le chemin est casse — le test
        // deviendrait un faux positif vert. On verifie qu'il a du grain a moudre.
        self::assertNotEmpty(iterator_to_array($finder), 'Aucune entite scannee : chemin src/Module invalide ?');

        foreach ($finder as $file) {
            $fqcn = $this->extractFqcn($file->getRealPath());
            if (null === $fqcn || in_array($fqcn, self::EXCLUDED, true)) {
                continue;
            }

            $reflection = new ReflectionClass($fqcn);
            // On ignore les classes abstraites et tout ce qui n'est pas une
            // entite Doctrine (value objects, embeddables non mappes, etc.).
            if ($reflection->isAbstract() || [] === $reflection->getAttributes(Entity::class)) {
                continue;
            }

            self::assertTrue(
                $reflection->implementsInterface(TimestampableInterface::class)
                && $reflection->implementsInterface(BlamableInterface::class),
                sprintf(
                    'L\'entite %s doit implementer TimestampableInterface ET BlamableInterface '
                    .'(utiliser TimestampableBlamableTrait). Si c\'est un referentiel statique '
                    .'justifie, l\'ajouter dans EntitiesAreTimestampableBlamableTest::EXCLUDED.',
                    $fqcn,
                ),
            );
        }
    }

    /**
     * Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du
     * source, sans charger le fichier.
     */
    private function extractFqcn(string $path): ?string
    {
        $source = file_get_contents($path);
        if (false === $source) {
            return null;
        }

        if (
            1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
            || 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
        ) {
            return null;
        }

        return trim($nsMatch[1]).'\\'.$classMatch[1];
    }
}
