<?php

declare(strict_types=1);

namespace App\Tests\Module\Core\Api;

use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Security\SystemRoles;

/**
 * Tests fonctionnels de l'exposition API Platform de l'entite Role (CRUD nominal).
 *
 * Strategie :
 *  - Les roles systeme `admin` et `user` sont deja charges par les fixtures
 *    (cf. AppFixtures::ensureSystemRole). On ne les touche JAMAIS.
 *  - Les roles et permissions crees pour les tests ont le prefixe `test.` et
 *    sont purges en setUp + tearDown par DQL prefixe.
 *  - Les cas 403 sur role systeme et 400 sur modification de `code` sont
 *    reportes a la Task 3 (RoleProcessor) et ne sont PAS testes ici.
 *
 * @internal
 */
final class RoleApiTest extends AbstractApiTestCase
{
    // Prefixe pour les roles de test : `test_` (underscore) parce que les
    // codes de role doivent matcher `/^[a-z][a-z0-9_]*$/` (pas de point
    // autorise, contrairement aux permissions).
    private const TEST_ROLE_PREFIX = 'test_';

    // Prefixe pour les permissions de test : `test.` (point) parce que les
    // codes de permission doivent contenir au moins un `.` (convention
    // module.resource.action validee dans le constructeur Permission).
    private const TEST_PERMISSION_PREFIX = 'test.';

    protected function setUp(): void
    {
        parent::setUp();

        self::bootKernel();
        $em = $this->getEm();

        // Nettoyage defensif au cas ou un run precedent aurait laisse des restes.
        $this->cleanupTestData();

        // Permissions de test reutilisables (notamment pour le PATCH).
        $p1 = new Permission('test.core.roles.view', 'View roles (test)', 'core');
        $p2 = new Permission('test.core.roles.manage', 'Manage roles (test)', 'core');
        $em->persist($p1);
        $em->persist($p2);

        // Role custom existant : utilise pour les GET / PATCH / DELETE.
        $editor = new Role('test_editor', 'Editeur (test)', false, 'Role de test editeur');
        $em->persist($editor);

        // Deuxieme role custom : pour enrichir les collections.
        $viewer = new Role('test_viewer', 'Visualisateur (test)', false);
        $em->persist($viewer);

        $em->flush();
        $em->clear();
    }

    protected function tearDown(): void
    {
        $this->cleanupTestData();
        parent::tearDown();
    }

    public function testPostCreatesCustomRoleAsAdmin(): void
    {
        $client   = $this->authenticatedClient('admin', 'admin');
        $response = $client->request('POST', '/api/roles', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json'    => [
                'code'        => 'test_new_editor',
                'label'       => 'Nouvel editeur',
                'description' => 'Role de test',
            ],
        ]);

        self::assertResponseStatusCodeSame(201);
        $data = $response->toArray();
        self::assertSame('test_new_editor', $data['code']);
        self::assertSame('Nouvel editeur', $data['label']);
        self::assertFalse($data['isSystem']);

        // Verification cote base : le role existe et isSystem = false.
        $persisted = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_new_editor']);
        self::assertNotNull($persisted);
        self::assertFalse($persisted->isSystem());
    }

    public function testPostWithDuplicateCodeReturns422(): void
    {
        $client = $this->authenticatedClient('admin', 'admin');
        $client->request('POST', '/api/roles', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json'    => [
                // `admin` est un role systeme charge par les fixtures.
                'code'  => SystemRoles::ADMIN_CODE,
                'label' => 'Tentative de doublon',
            ],
        ]);

        self::assertResponseStatusCodeSame(422);
    }

    public function testPostWithInvalidCodeReturns422(): void
    {
        $client = $this->authenticatedClient('admin', 'admin');
        $client->request('POST', '/api/roles', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json'    => [
                // Majuscules interdites par la regex snake_case.
                'code'  => 'BadCode',
                'label' => 'Code invalide',
            ],
        ]);

        self::assertResponseStatusCodeSame(422);
    }

    public function testPostWithIsSystemTrueIgnoresItAndPersistsFalse(): void
    {
        $client   = $this->authenticatedClient('admin', 'admin');
        $response = $client->request('POST', '/api/roles', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json'    => [
                'code'     => 'test_sneaky',
                'label'    => 'Tentative systeme',
                'isSystem' => true,
            ],
        ]);

        self::assertResponseStatusCodeSame(201);
        $data = $response->toArray();
        self::assertFalse($data['isSystem']);

        $persisted = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_sneaky']);
        self::assertNotNull($persisted);
        self::assertFalse($persisted->isSystem());
    }

    public function testGetCollectionAsAdminReturnsRoles(): void
    {
        $client   = $this->authenticatedClient('admin', 'admin');
        $response = $client->request('GET', '/api/roles?pagination=false');

        self::assertResponseIsSuccessful();
        $data = $response->toArray();
        self::assertArrayHasKey('member', $data);
        // Au moins admin systeme + user systeme + test_editor + test_viewer.
        self::assertGreaterThanOrEqual(4, $data['totalItems']);
        $codes = array_column($data['member'], 'code');
        self::assertContains('test_editor', $codes);
    }

    /**
     * Verrouille le chemin paginE PAR DEFAUT (ERP-72) : le test ci-dessus passe
     * `?pagination=false` (usage <select>) et n'exerce donc plus le defaut
     * paginE. On seed 11 roles de test pour depasser une page (10) et verifier
     * que, sans parametre, la page est bornee a 10 et expose `view`.
     */
    public function testDefaultCollectionIsPaginatedToGlobalDefault(): void
    {
        $em = $this->getEm();
        for ($i = 1; $i <= 11; ++$i) {
            $em->persist(new Role(sprintf('test_pg_%d', $i), sprintf('Role pagination %d (test)', $i), false));
        }
        $em->flush();
        $em->clear();

        $client   = $this->authenticatedClient('admin', 'admin');
        $response = $client->request('GET', '/api/roles');

        self::assertResponseIsSuccessful();
        $data = $response->toArray();

        // La page par defaut ne doit jamais depasser le maximum global (10).
        self::assertLessThanOrEqual(10, count($data['member']), 'La page par defaut doit etre bornee a 10 items.');
        // 11 roles de test + 2 systeme + editor + viewer => total > 10.
        self::assertGreaterThan(10, $data['totalItems']);
        // `view` n'est present que lorsque la collection est reellement paginee.
        self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.');
    }

    public function testGetCollectionFilterByIsSystemTrue(): void
    {
        $client   = $this->authenticatedClient('admin', 'admin');
        $response = $client->request('GET', '/api/roles', [
            'query' => ['isSystem' => 'true'],
        ]);

        self::assertResponseIsSuccessful();
        $data = $response->toArray();
        foreach ($data['member'] as $item) {
            self::assertTrue($item['isSystem']);
        }
        $codes = array_column($data['member'], 'code');
        self::assertNotContains('test_editor', $codes);
        self::assertNotContains('test_viewer', $codes);
    }

    public function testGetItemReturnsAllReadFields(): void
    {
        $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
        self::assertNotNull($role);

        $client   = $this->authenticatedClient('admin', 'admin');
        $response = $client->request('GET', '/api/roles/'.$role->getId());

        self::assertResponseIsSuccessful();
        $data = $response->toArray();
        self::assertSame('test_editor', $data['code']);
        self::assertSame('Editeur (test)', $data['label']);
        self::assertSame('Role de test editeur', $data['description']);
        self::assertFalse($data['isSystem']);
        self::assertArrayHasKey('permissions', $data);
        self::assertIsArray($data['permissions']);
    }

    public function testPatchCustomRoleUpdatesLabelAndAddsPermission(): void
    {
        $em   = $this->getEm();
        $role = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
        self::assertNotNull($role);
        $permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'test.core.roles.view']);
        self::assertNotNull($permission);

        $client   = $this->authenticatedClient('admin', 'admin');
        $response = $client->request('PATCH', '/api/roles/'.$role->getId(), [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => [
                'label'       => 'Editeur modifie',
                'permissions' => ['/api/permissions/'.$permission->getId()],
            ],
        ]);

        self::assertResponseIsSuccessful();
        $data = $response->toArray();
        self::assertSame('Editeur modifie', $data['label']);
        self::assertCount(1, $data['permissions']);

        // Verification cote base.
        $em->clear();

        /** @var Role $reloaded */
        $reloaded = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
        self::assertSame('Editeur modifie', $reloaded->getLabel());
        self::assertCount(1, $reloaded->getPermissions());
    }

    public function testDeleteCustomRoleReturns204(): void
    {
        $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_viewer']);
        self::assertNotNull($role);
        $id = $role->getId();

        $client = $this->authenticatedClient('admin', 'admin');
        $client->request('DELETE', '/api/roles/'.$id);

        self::assertResponseStatusCodeSame(204);

        $em = $this->getEm();
        $em->clear();
        self::assertNull($em->getRepository(Role::class)->find($id));
    }

    public function testDeleteCustomRoleAttachedToUserDoesNotDeleteUser(): void
    {
        // Scenario spec #344 sections 7 & 11 : supprimer un role custom rattache
        // a un user doit laisser le user en base (la FK user_role est nettoyee
        // par ON DELETE CASCADE, mais jamais le user lui-meme).
        $em = $this->getEm();

        // Creer un user de test dedie et lui rattacher le role custom `test_editor`.
        $testUser = new User();
        $testUser->setUsername('test_cascade_user');
        // Le hashage du password est hors scope du test mais la colonne est NOT NULL.
        $testUser->setPassword('not-hashed-ok-for-test');

        /** @var Role $editor */
        $editor = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
        self::assertNotNull($editor);
        $testUser->addRbacRole($editor);

        $em->persist($testUser);
        $em->flush();
        $userId   = $testUser->getId();
        $editorId = $editor->getId();
        $em->clear();

        // DELETE du role editor via l'API.
        $client = $this->authenticatedClient('admin', 'admin');
        $client->request('DELETE', '/api/roles/'.$editorId);
        self::assertResponseStatusCodeSame(204);

        // Verification : l'user existe toujours et sa collection de roles est vide.
        $em = $this->getEm();

        /** @var null|User $refreshed */
        $refreshed = $em->getRepository(User::class)->find($userId);
        self::assertNotNull($refreshed, 'L\'user ne doit PAS etre supprime par le cascade.');
        self::assertCount(0, $refreshed->getRbacRoles(), 'La relation user_role doit etre nettoyee par le cascade.');

        // Cleanup explicite : cleanupTestData() ne purge pas les users.
        $em->remove($refreshed);
        $em->flush();
    }

    public function testDeleteSystemRoleReturns403(): void
    {
        $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]);
        self::assertNotNull($role);

        $client = $this->authenticatedClient('admin', 'admin');
        $client->request('DELETE', '/api/roles/'.$role->getId());

        self::assertResponseStatusCodeSame(403);

        // Le role systeme doit toujours exister.
        $em = $this->getEm();
        $em->clear();
        self::assertNotNull($em->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]));
    }

    public function testPatchSystemRoleLabelReturns200(): void
    {
        $em   = $this->getEm();
        $role = $em->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]);
        self::assertNotNull($role);
        $originalLabel = $role->getLabel();
        $roleId        = $role->getId();

        $client = $this->authenticatedClient('admin', 'admin');

        try {
            $response = $client->request('PATCH', '/api/roles/'.$roleId, [
                'headers' => ['Content-Type' => 'application/merge-patch+json'],
                'json'    => ['label' => 'Administrateur (modifie test)'],
            ]);

            self::assertResponseIsSuccessful();
            $data = $response->toArray();
            self::assertSame('Administrateur (modifie test)', $data['label']);
            self::assertSame(SystemRoles::ADMIN_CODE, $data['code']);
            self::assertTrue($data['isSystem']);
        } finally {
            // Restauration defensive du label original pour ne pas polluer
            // les tests suivants (les fixtures systeme sont partagees).
            $em = $this->getEm();

            /** @var null|Role $reloaded */
            $reloaded = $em->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]);
            if (null !== $reloaded && $reloaded->getLabel() !== $originalLabel) {
                $reloaded->setLabel($originalLabel);
                $em->flush();
            }
        }
    }

    public function testPatchRoleCodeChangeReturns400(): void
    {
        $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
        self::assertNotNull($role);

        $client = $this->authenticatedClient('admin', 'admin');
        $client->request('PATCH', '/api/roles/'.$role->getId(), [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['code' => 'test_editor_renamed'],
        ]);

        self::assertResponseStatusCodeSame(400);

        // Verification cote base : le code d'origine n'a pas bouge.
        $em = $this->getEm();
        $em->clear();
        self::assertNotNull($em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']));
        self::assertNull($em->getRepository(Role::class)->findOneBy(['code' => 'test_editor_renamed']));
    }

    public function testUnauthenticatedGetCollectionReturns401(): void
    {
        $client = self::createClient();
        $client->request('GET', '/api/roles');

        self::assertResponseStatusCodeSame(401);
    }

    public function testNonAdminGetCollectionReturns403(): void
    {
        $client = $this->authenticatedClient('alice', 'alice');
        $client->request('GET', '/api/roles');

        self::assertResponseStatusCodeSame(403);
    }

    // --- Tests voter RBAC : non-admin avec / sans permission ---

    public function testListRolesAsUserWithViewPermissionReturns200(): void
    {
        // Un non-admin portant core.roles.view doit pouvoir lister les roles.
        $credentials = $this->createUserWithPermission('core.roles.view');
        $client      = $this->authenticatedClient($credentials['username'], $credentials['password']);
        $client->request('GET', '/api/roles');

        self::assertResponseIsSuccessful();
    }

    public function testListRolesAsUserWithOnlyManagePermissionReturns403(): void
    {
        // Un user avec uniquement core.roles.manage ne peut PAS lister (list/get
        // exige core.roles.view, cf. spec section 3 ticket-345).
        $credentials = $this->createUserWithPermission('core.roles.manage');
        $client      = $this->authenticatedClient($credentials['username'], $credentials['password']);
        $client->request('GET', '/api/roles');

        self::assertResponseStatusCodeSame(403);
    }

    public function testListRolesAsStandardUserReturns403(): void
    {
        $client = $this->authenticatedClient('alice', 'alice');
        $client->request('GET', '/api/roles');

        self::assertResponseStatusCodeSame(403);
    }

    public function testCreateRoleAsUserWithManagePermissionReturns201(): void
    {
        // Un non-admin portant core.roles.manage doit pouvoir creer un role.
        $credentials = $this->createUserWithPermission('core.roles.manage');
        $client      = $this->authenticatedClient($credentials['username'], $credentials['password']);
        $response    = $client->request('POST', '/api/roles', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json'    => [
                'code'  => 'test_created_by_manager',
                'label' => 'Role cree par manager (test)',
            ],
        ]);

        self::assertResponseStatusCodeSame(201);
        $data = $response->toArray();
        self::assertSame('test_created_by_manager', $data['code']);
    }

    public function testCreateRoleAsUserWithOnlyViewPermissionReturns403(): void
    {
        // Un user avec core.roles.view uniquement ne peut pas creer (POST exige .manage).
        $credentials = $this->createUserWithPermission('core.roles.view');
        $client      = $this->authenticatedClient($credentials['username'], $credentials['password']);
        $client->request('POST', '/api/roles', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json'    => [
                'code'  => 'test_shouldnotcreate',
                'label' => 'Ne doit pas etre cree',
            ],
        ]);

        self::assertResponseStatusCodeSame(403);
    }

    public function testCreateRoleAsStandardUserReturns403(): void
    {
        $client = $this->authenticatedClient('alice', 'alice');
        $client->request('POST', '/api/roles', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json'    => [
                'code'  => 'test_shouldnotcreate_alice',
                'label' => 'Ne doit pas etre cree',
            ],
        ]);

        self::assertResponseStatusCodeSame(403);
    }

    /**
     * Purge les donnees de test (roles et permissions prefixees `test.`).
     * Ne touche JAMAIS aux roles systeme `admin` et `user` charges par les
     * fixtures.
     */
    private function cleanupTestData(): void
    {
        $em = $this->getEm();

        // Le cascade FK de la migration #343 (ON DELETE CASCADE sur
        // role_permission.role_id et permission_id) nettoie automatiquement
        // role_permission lors du DELETE SQL emis par Doctrine, meme via DQL
        // bulk delete : le cascade est applique au niveau FK par PostgreSQL,
        // pas par l'Unit of Work Doctrine. Verifie par comptage avant/apres
        // runs successifs de la suite (stable a la ligne de base systeme).
        // Purge defensive des users de test crees par certains scenarios
        // (ex: testDeleteCustomRoleAttachedToUserDoesNotDeleteUser). Doit etre
        // fait AVANT la suppression des roles pour que le cascade FK ne soit
        // pas sollicite en ordre inverse.
        $em->createQuery(
            'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
        )->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute();

        $em->createQuery(
            'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
        )->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute();

        $em->createQuery(
            'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix'
        )->setParameter('prefix', self::TEST_PERMISSION_PREFIX.'%')->execute();
    }
}
