<?php

declare(strict_types=1);

namespace App\Tests\Module\Catalog\Api;

use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Core\Domain\Entity\User;
use DateTimeImmutable;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Clock\Test\ClockSensitiveTrait;

/**
 * Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir
 * automatiquement les 4 colonnes au prePersist (RG-1.15) et au preUpdate
 * (RG-1.16), sans qu'aucun champ ne soit modifiable par l'API client.
 *
 *  - POST authentifie : createdAt = updatedAt = now, createdBy = updatedBy = user
 *  - Persist hors HTTP (console context) : dates remplies, blame null
 *  - PATCH par un user different : updatedAt + updatedBy changent, createdAt /
 *    createdBy restent figes
 *  - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
 *    Doctrine declenche le subscriber)
 *
 * ERP-98 : ces tests pilotent une horloge mockee (ClockSensitiveTrait) plutot
 * que de dependre d'un `sleep(1)` reel. Le subscriber lit le service `clock`,
 * que `self::mockTime()` remplace par un MockClock fige au niveau du process —
 * ce qui survit aux reboots de kernel entre requetes (POST admin / PATCH bob)
 * et reste insensible a la derive d'horloge WSL2 a l'origine des flakes.
 *
 * @internal
 */
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
{
    use ClockSensitiveTrait;

    /**
     * Fige l'horloge globale sur l'instant courant DANS LE FUSEAU PHP par
     * defaut, et la retourne pour la piloter (`sleep()`).
     *
     * Subtilite : `self::mockTime()` cree par defaut un MockClock en UTC, or
     * les colonnes `TIMESTAMP WITHOUT TIME ZONE` round-trippent via le fuseau
     * PHP (Europe/Paris). Un MockClock UTC decalerait createdAt de l'offset
     * (2h) au rechargement. On seede donc avec `new DateTimeImmutable()`
     * (fuseau par defaut), exactement comme le NativeClock en prod.
     */
    private function freezeClock(): ClockInterface
    {
        return self::mockTime(new DateTimeImmutable());
    }

    public function testCreatedByAdminOnPost(): void
    {
        // Horloge figee : le subscriber posera createdAt/updatedAt sur cet
        // instant exact, insensible a tout decalage d'horloge reel.
        $clock = $this->freezeClock();

        $type = $this->createCategoryType();

        /** @var User $admin */
        $admin = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'admin']);
        self::assertNotNull($admin);
        $adminId = $admin->getId();

        $before = $clock->now();

        $client   = $this->createAdminClient();
        $response = $client->request('POST', '/api/categories', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json'    => [
                'name'         => self::TEST_CATEGORY_PREFIX.'tsb_admin',
                'categoryTypes' => ['/api/category_types/'.$type->getId()],
            ],
        ]);
        self::assertSame(201, $response->getStatusCode());
        $createdId = $response->toArray()['id'];

        $em = $this->getEm();
        $em->clear();

        /** @var Category $reloaded */
        $reloaded = $em->getRepository(Category::class)->find($createdId);

        // RG-1.15 — dates remplies, egales au prePersist
        self::assertNotNull($reloaded->getCreatedAt());
        self::assertNotNull($reloaded->getUpdatedAt());
        self::assertGreaterThanOrEqual(
            $before->getTimestamp(),
            $reloaded->getCreatedAt()->getTimestamp(),
            'createdAt doit etre post-test-start.',
        );
        self::assertSame(
            $reloaded->getCreatedAt()->getTimestamp(),
            $reloaded->getUpdatedAt()->getTimestamp(),
            'Au POST, createdAt et updatedAt doivent etre identiques.',
        );

        // RG-1.15 — blame remplis avec le user authentifie (admin)
        self::assertNotNull($reloaded->getCreatedBy());
        self::assertNotNull($reloaded->getUpdatedBy());
        self::assertSame($adminId, $reloaded->getCreatedBy()->getId());
        self::assertSame($adminId, $reloaded->getUpdatedBy()->getId());
    }

    public function testCreatedByNullInConsoleContext(): void
    {
        // RG-1.15 : persist sans contexte HTTP -> Security::getUser() retourne
        // null -> blame reste null, mais les dates restent remplies.
        // On utilise la factory createCategory() qui fait un persist Doctrine
        // direct (pas via le client HTTP).
        $category = $this->createCategory(self::TEST_CATEGORY_PREFIX.'console');

        $em = $this->getEm();
        $em->clear();

        /** @var Category $reloaded */
        $reloaded = $em->getRepository(Category::class)->find($category->getId());

        // Dates remplies par le subscriber.
        self::assertNotNull($reloaded->getCreatedAt());
        self::assertNotNull($reloaded->getUpdatedAt());

        // Blame null (pas de Security::getUser() dispo hors HTTP).
        self::assertNull(
            $reloaded->getCreatedBy(),
            'createdBy doit etre null hors contexte HTTP (RG-1.15 fallback).',
        );
        self::assertNull($reloaded->getUpdatedBy());
    }

    public function testPatchUpdatesUpdatedFieldsOnly(): void
    {
        $clock = $this->freezeClock();

        // Etape 1 : creation par admin pour figer createdBy=admin.
        $type        = $this->createCategoryType();
        $adminClient = $this->createAdminClient();

        $response = $adminClient->request('POST', '/api/categories', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json'    => [
                'name'         => self::TEST_CATEGORY_PREFIX.'tsb_patch',
                'categoryTypes' => ['/api/category_types/'.$type->getId()],
            ],
        ]);
        self::assertSame(201, $response->getStatusCode());
        $createdId = $response->toArray()['id'];

        // Snapshot des valeurs initiales pour comparaison apres PATCH.
        $em = $this->getEm();
        $em->clear();

        /** @var Category $initial */
        $initial            = $em->getRepository(Category::class)->find($createdId);
        $initialCreatedAt   = $initial->getCreatedAt();
        $initialUpdatedAt   = $initial->getUpdatedAt();
        $initialCreatedById = $initial->getCreatedBy()->getId();

        // Avance deterministe de l'horloge mockee : garantit un updatedAt
        // strictement superieur cote PG (precision seconde) sans sleep reel.
        $clock->sleep(1);

        // Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
        $manage    = $this->createManageClient();
        $bobClient = $manage['client'];

        /** @var User $bob */
        $bob   = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $manage['credentials']['username']]);
        $bobId = $bob->getId();
        self::assertNotSame($initialCreatedById, $bobId, 'Le test exige deux users distincts.');

        $bobClient->request('PATCH', '/api/categories/'.$createdId, [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['name' => self::TEST_CATEGORY_PREFIX.'tsb_patched_by_bob'],
        ]);
        self::assertResponseIsSuccessful();

        // Etape 3 : verifications RG-1.16
        $em = $this->getEm();
        $em->clear();

        /** @var Category $patched */
        $patched = $em->getRepository(Category::class)->find($createdId);

        // createdAt / createdBy figes
        self::assertSame(
            $initialCreatedAt->getTimestamp(),
            $patched->getCreatedAt()->getTimestamp(),
            'createdAt doit etre fige au PATCH (RG-1.16).',
        );
        self::assertSame(
            $initialCreatedById,
            $patched->getCreatedBy()->getId(),
            'createdBy doit etre fige au PATCH (RG-1.16).',
        );

        // updatedAt / updatedBy mis a jour
        self::assertGreaterThan(
            $initialUpdatedAt->getTimestamp(),
            $patched->getUpdatedAt()->getTimestamp(),
            'updatedAt doit avancer apres PATCH (RG-1.16).',
        );
        self::assertSame(
            $bobId,
            $patched->getUpdatedBy()->getId(),
            'updatedBy doit refleter le user PATCH (RG-1.16).',
        );
    }

    public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
    {
        $clock = $this->freezeClock();

        // RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
        // doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
        $type        = $this->createCategoryType();
        $adminClient = $this->createAdminClient();

        $response = $adminClient->request('POST', '/api/categories', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json'    => [
                'name'         => self::TEST_CATEGORY_PREFIX.'tsb_delete',
                'categoryTypes' => ['/api/category_types/'.$type->getId()],
            ],
        ]);
        self::assertSame(201, $response->getStatusCode());
        $createdId = $response->toArray()['id'];

        $em = $this->getEm();
        $em->clear();

        /** @var Category $initial */
        $initial          = $em->getRepository(Category::class)->find($createdId);
        $initialUpdatedAt = $initial->getUpdatedAt();

        // Avance deterministe de l'horloge mockee (cf. testPatch).
        $clock->sleep(1);

        // Soft delete par un manager non-admin.
        $manage    = $this->createManageClient();
        $bobClient = $manage['client'];

        /** @var User $bob */
        $bob   = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $manage['credentials']['username']]);
        $bobId = $bob->getId();

        $bobClient->request('DELETE', '/api/categories/'.$createdId);
        self::assertResponseStatusCodeSame(204);

        $em = $this->getEm();
        $em->clear();

        /** @var Category $deleted */
        $deleted = $em->getRepository(Category::class)->find($createdId);

        // deletedAt rempli
        self::assertNotNull($deleted->getDeletedAt(), 'deletedAt doit etre rempli apres DELETE.');

        // updatedAt avance, updatedBy = bob
        self::assertGreaterThan(
            $initialUpdatedAt->getTimestamp(),
            $deleted->getUpdatedAt()->getTimestamp(),
            'updatedAt doit avancer au soft delete (RG-1.16).',
        );
        self::assertSame(
            $bobId,
            $deleted->getUpdatedBy()->getId(),
            'updatedBy doit refleter l\'auteur du soft delete (RG-1.16).',
        );
    }
}
