<?php

declare(strict_types=1);

namespace App\Tests\Architecture;

use App\Shared\Domain\Attribute\Auditable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\Finder\Finder;

use function is_string;
use function sprintf;

use const JSON_THROW_ON_ERROR;

/**
 * Garde-fou architecture : toute entite `#[Auditable]` doit avoir son libelle
 * i18n dans le bloc `audit.entity` du `fr.json` du shell.
 *
 * Pourquoi : le filtre « Type d'entite » de l'audit-log est dynamique
 * (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents
 * en base). Des qu'un module audite une entite, un nouveau type apparait. Le
 * rendu front (`formatEntityType`, audit-log.vue) construit la cle
 * `audit.entity.<module>_<entity>` et, faute de traduction, retombe
 * SILENCIEUSEMENT sur le type technique brut (ex: `commercial.Client`). Le
 * manque passe donc inapercu jusqu'a observation dans l'UI.
 *
 * Ce test rend le manque BLOQUANT (meme esprit que ColumnsHaveSqlCommentTest) :
 * il scanne les entites `#[Auditable]` sous `src/Module/<m>/Domain/Entity/`,
 * derive la cle attendue comme le fait le front, et echoue si elle est absente
 * du `fr.json`.
 *
 * Derivation de la cle (miroir exact de AuditListener::formatEntityType + de
 * formatEntityType cote front) :
 *   FQCN `App\Module\Commercial\Domain\Entity\ClientAddress`
 *     -> entity_type `commercial.ClientAddress`   (module en minuscules, Entity intacte)
 *     -> cle i18n    `commercial_clientaddress`   (tout en minuscules, `.` -> `_`)
 *
 * @internal
 */
final class AuditableEntitiesHaveI18nLabelTest extends TestCase
{
    /**
     * Chemin du fichier de traductions FR du shell. Source unique des libelles
     * d'entite audit (decision ERP-99 : emplacement centralise, schema flat).
     */
    private const LOCALE_FILE = __DIR__.'/../../frontend/i18n/locales/fr.json';

    public function testEveryAuditableEntityHasAnI18nLabel(): void
    {
        $labels = $this->loadAuditEntityLabels();

        $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 ?');

        $checked = 0;
        foreach ($finder as $file) {
            $fqcn = $this->extractFqcn($file->getRealPath());
            if (null === $fqcn) {
                continue;
            }

            $reflection = new ReflectionClass($fqcn);
            // On ne s'interesse qu'aux entites reellement auditees.
            if ($reflection->isAbstract() || [] === $reflection->getAttributes(Auditable::class)) {
                continue;
            }

            $key = $this->deriveI18nKey($fqcn);
            self::assertNotNull(
                $key,
                sprintf('Entite %s hors structure modulaire attendue (App\Module\<M>\Domain\Entity\<E>).', $fqcn),
            );

            self::assertArrayHasKey(
                $key,
                $labels,
                sprintf(
                    'L\'entite auditable %s n\'a pas de libelle i18n. Ajouter "%s" dans le bloc '
                    .'`audit.entity` de frontend/i18n/locales/fr.json (sinon le filtre audit-log '
                    .'affiche le type technique brut). Cf. ERP-99 + .claude/rules/backend.md § Audit.',
                    $fqcn,
                    $key,
                ),
            );
            self::assertNotSame('', trim($labels[$key]), sprintf('Le libelle audit "%s" est vide.', $key));

            ++$checked;
        }

        // Garde : au moins une entite auditable doit avoir ete verifiee, sinon
        // la detection de l'attribut est cassee (faux positif vert).
        self::assertGreaterThan(0, $checked, 'Aucune entite #[Auditable] detectee : detection d\'attribut cassee ?');
    }

    /**
     * Charge le bloc `audit.entity` du fr.json sous forme de map cle -> libelle.
     *
     * @return array<string, string>
     */
    private function loadAuditEntityLabels(): array
    {
        $raw = file_get_contents(self::LOCALE_FILE);
        self::assertIsString($raw, sprintf('Fichier de locale introuvable : %s', self::LOCALE_FILE));

        /** @var array<string, mixed> $json */
        $json = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);

        $entity = $json['audit']['entity'] ?? null;
        self::assertIsArray($entity, 'Bloc `audit.entity` absent ou invalide dans fr.json.');

        $labels = [];
        foreach ($entity as $key => $value) {
            if (is_string($key) && is_string($value)) {
                $labels[$key] = $value;
            }
        }

        return $labels;
    }

    /**
     * Derive la cle i18n `<module>_<entity>` depuis le FQCN, en miroir de
     * AuditListener::formatEntityType (module en minuscules) suivi de
     * l'aplatissement front (tout en minuscules, `.` -> `_`).
     *
     * Retourne null si le FQCN ne respecte pas la structure modulaire.
     */
    private function deriveI18nKey(string $fqcn): ?string
    {
        if (1 !== preg_match('#^App\\\Module\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $fqcn, $m)) {
            return null;
        }

        return strtolower($m['module']).'_'.strtolower($m['entity']);
    }

    /**
     * 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];
    }
}
