<?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\Sites\Domain\Entity\Site;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

/**
 * Tests fonctionnels de l'endpoint RBAC dedie `PATCH /api/users/{id}/rbac`.
 *
 * Strategie de donnees :
 *  - On cree des users, roles et permissions prefixes `test_` / `test.`
 *    en setUp et on les purge en tearDown.
 *  - On ne touche JAMAIS aux fixtures (admin / alice / bob). Les cas qui
 *    ont besoin d'un user standard authentifie s'appuient sur alice sans
 *    modification d'etat.
 *  - Les users de test incluent un admin dedie pour le cas d'auto-suicide,
 *    pour ne pas risquer de corrompre l'admin fixture.
 *
 * @internal
 */
final class UserRbacApiTest extends AbstractApiTestCase
{
    private const TEST_USER_PREFIX       = 'test_';
    private const TEST_ROLE_PREFIX       = 'test_';
    private const TEST_PERMISSION_PREFIX = 'test.';

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

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

        $this->cleanupTestData();

        /** @var UserPasswordHasherInterface $hasher */
        $hasher = self::getContainer()->get(UserPasswordHasherInterface::class);

        // User cible standard (non admin). On lui attache tous les sites
        // fixtures pour rester visible depuis les callers non-admin munis de
        // sites (cf. UserSiteScopedExtension qui filtre `/api/users` par
        // intersection de sites). Sans cela, un user `core.users.manage`
        // sans site commun avec test_target recevrait un 404 sur le PATCH.
        $target = new User();
        $target->setUsername('test_target');
        $target->setIsAdmin(false);
        $target->setPassword($hasher->hashPassword($target, 'secret'));
        foreach ($em->getRepository(Site::class)->findAll() as $site) {
            $target->addSite($site);
        }
        $em->persist($target);

        // User admin dedie pour le cas d'auto-suicide (pas l'admin fixture).
        $selfAdmin = new User();
        $selfAdmin->setUsername('test_self_admin');
        $selfAdmin->setIsAdmin(true);
        $selfAdmin->setPassword($hasher->hashPassword($selfAdmin, 'secret'));
        $em->persist($selfAdmin);

        // Role custom pour tester le remplacement de la collection roles.
        $role = new Role('test_editor', 'Editeur (test)', false);
        $em->persist($role);

        // Permission custom pour tester directPermissions.
        $permission = new Permission('test.core.users.view', 'View users (test)', 'core');
        $em->persist($permission);

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

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

    public function testPatchRbacPromotesUserToAdmin(): void
    {
        $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
        self::assertNotNull($target);

        $client = $this->authenticatedClient('admin', 'admin');
        $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['isAdmin' => true],
        ]);

        self::assertResponseIsSuccessful();

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

        /** @var User $reloaded */
        $reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
        self::assertTrue($reloaded->isAdmin());
    }

    public function testPatchRbacReplacesRolesCollection(): void
    {
        $em     = $this->getEm();
        $target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
        $role   = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
        self::assertNotNull($target);
        self::assertNotNull($role);

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

        self::assertResponseIsSuccessful();

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

        /** @var User $reloaded */
        $reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
        self::assertCount(1, $reloaded->getRbacRoles());
        self::assertSame('test_editor', $reloaded->getRbacRoles()->first()->getCode());
    }

    public function testPatchRbacReplacesDirectPermissionsCollection(): void
    {
        $em         = $this->getEm();
        $target     = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
        $permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'test.core.users.view']);
        self::assertNotNull($target);
        self::assertNotNull($permission);

        $client = $this->authenticatedClient('admin', 'admin');
        $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['directPermissions' => ['/api/permissions/'.$permission->getId()]],
        ]);

        self::assertResponseIsSuccessful();

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

        /** @var User $reloaded */
        $reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
        self::assertCount(1, $reloaded->getDirectPermissions());
        self::assertSame('test.core.users.view', $reloaded->getDirectPermissions()->first()->getCode());
    }

    public function testPatchRbacAsStandardUserReturns403(): void
    {
        $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
        self::assertNotNull($target);

        $client = $this->authenticatedClient('alice', 'alice');
        $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['isAdmin' => true],
        ]);

        self::assertResponseStatusCodeSame(403);
    }

    public function testPatchRbacUnauthenticatedReturns401(): void
    {
        $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
        self::assertNotNull($target);

        $client = self::createClient();
        $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['isAdmin' => true],
        ]);

        self::assertResponseStatusCodeSame(401);
    }

    public function testPatchRbacIgnoresUsernameField(): void
    {
        $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
        self::assertNotNull($target);
        $targetId = $target->getId();

        $client = $this->authenticatedClient('admin', 'admin');
        $client->request('PATCH', '/api/users/'.$targetId.'/rbac', [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => [
                'username' => 'test_target_renamed',
                'isAdmin'  => true,
            ],
        ]);

        self::assertResponseIsSuccessful();

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

        /** @var User $reloaded */
        $reloaded = $em->getRepository(User::class)->find($targetId);
        // `username` n'est pas dans `user:rbac:write` : ignore en denormalization.
        self::assertSame('test_target', $reloaded->getUsername());
        // `isAdmin` est bien applique.
        self::assertTrue($reloaded->isAdmin());
    }

    public function testPatchProfileEndpointDoesNotModifyIsAdmin(): void
    {
        // Confirme la decision 0fc4e16 : `isAdmin` n'est plus dans `user:write`,
        // donc `PATCH /api/users/{id}` sans `/rbac` ne peut plus promouvoir.
        $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
        self::assertNotNull($target);
        $targetId = $target->getId();
        self::assertFalse($target->isAdmin());

        $client = $this->authenticatedClient('admin', 'admin');
        $client->request('PATCH', '/api/users/'.$targetId, [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['isAdmin' => true],
        ]);

        // Peu importe le code : le champ ne doit tout simplement pas bouger.
        $em = $this->getEm();
        $em->clear();

        /** @var User $reloaded */
        $reloaded = $em->getRepository(User::class)->find($targetId);
        self::assertFalse($reloaded->isAdmin());
    }

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

    public function testPatchRbacAsUserWithManagePermissionReturns200(): void
    {
        // Un non-admin portant core.users.manage doit pouvoir appeler PATCH /rbac.
        $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
        self::assertNotNull($target);

        $credentials = $this->createUserWithPermission('core.users.manage');
        $client      = $this->authenticatedClient($credentials['username'], $credentials['password']);
        $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['isAdmin' => false],
        ]);

        self::assertResponseIsSuccessful();
    }

    public function testPatchRbacAsUserWithOnlyViewPermissionReturns403(): void
    {
        // Un user avec core.users.view uniquement ne peut pas ecrire via /rbac.
        $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
        self::assertNotNull($target);

        $credentials = $this->createUserWithPermission('core.users.view');
        $client      = $this->authenticatedClient($credentials['username'], $credentials['password']);
        $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['isAdmin' => true],
        ]);

        self::assertResponseStatusCodeSame(403);
    }

    public function testPatchRbacSelfRemovingAdminReturns400(): void
    {
        // On utilise le user admin dedie (test_self_admin) pour ne pas
        // corrompre l'admin fixture en cas de bug.
        $em        = $this->getEm();
        $selfAdmin = $em->getRepository(User::class)->findOneBy(['username' => 'test_self_admin']);
        self::assertNotNull($selfAdmin);
        $selfAdminId = $selfAdmin->getId();

        $client = $this->authenticatedClient('test_self_admin', 'secret');
        $client->request('PATCH', '/api/users/'.$selfAdminId.'/rbac', [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['isAdmin' => false],
        ]);

        self::assertResponseStatusCodeSame(400);

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

        /** @var User $reloaded */
        $reloaded = $em->getRepository(User::class)->find($selfAdminId);
        self::assertTrue($reloaded->isAdmin());
    }

    private function cleanupTestData(): void
    {
        $em = $this->getEm();

        // Ordre important : delier les collections avant de supprimer les
        // entites referencees pour que les FK cascade s'appliquent via le
        // schema PostgreSQL.
        $em->createQuery(
            'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
        )->setParameter('prefix', self::TEST_USER_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();
    }
}
