<?php

declare(strict_types=1);

namespace App\Tests\Module\Core\Infrastructure\ApiPlatform\State\Processor;

use ApiPlatform\Metadata\Patch;
use ApiPlatform\State\ProcessorInterface;
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\Exception\LastAdminProtectionException;
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
use LogicException;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use stdClass;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

/**
 * Tests unitaires du UserRbacProcessor : couvre la garde "auto-suicide", la
 * garde "dernier admin global" et la delegation au PersistProcessor Doctrine
 * decore pour les trois champs RBAC (isAdmin, roles, directPermissions).
 *
 * @internal
 */
#[AllowMockObjectsWithoutExpectations]
final class UserRbacProcessorTest extends TestCase
{
    private MockObject&ProcessorInterface $persistProcessor;
    private EntityManagerInterface&MockObject $entityManager;
    private MockObject&UnitOfWork $unitOfWork;
    private MockObject&Security $security;
    private AdminHeadcountGuardInterface&MockObject $adminHeadcountGuard;
    private RequestStack $requestStack;
    private UserRbacProcessor $processor;

    protected function setUp(): void
    {
        $this->persistProcessor    = $this->createMock(ProcessorInterface::class);
        $this->entityManager       = $this->createMock(EntityManagerInterface::class);
        $this->unitOfWork          = $this->createMock(UnitOfWork::class);
        $this->security            = $this->createMock(Security::class);
        $this->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class);

        // Request vide par defaut pour les tests existants : la garde
        // anti-ecrasement (restoreAbsentCollections) no-op quand le body est ''
        // donc elle n'interfere pas avec les assertions deja en place.
        $this->requestStack = new RequestStack();
        $this->requestStack->push(new Request());

        $this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);

        // wrapInTransaction doit executer reellement la closure pour que le
        // resultat de persistProcessor->process() soit capture dans $result.
        // Sans ce stub, la closure n'est jamais invoquee et $result reste null.
        $this->entityManager
            ->method('wrapInTransaction')
            ->willReturnCallback(static fn (callable $fn) => $fn())
        ;

        $this->processor = new UserRbacProcessor(
            $this->persistProcessor,
            $this->entityManager,
            $this->security,
            $this->adminHeadcountGuard,
            $this->requestStack,
        );
    }

    public function testPatchPromotesUserToAdminDelegatesToPersistProcessor(): void
    {
        $target = $this->buildUser(42, 'alice', true);

        $currentAdmin = $this->buildUser(1, 'admin', true);
        $this->security->method('getUser')->willReturn($currentAdmin);

        // La cible gagne isAdmin (false -> true) : willLoseAdmin = false, donc
        // getOriginalEntityData est appele mais aucune garde ne bloque.
        $this->unitOfWork
            ->method('getOriginalEntityData')
            ->with($target)
            ->willReturn([
                'id'       => 42,
                'username' => 'alice',
                'isAdmin'  => false,
            ])
        ;

        $this->persistProcessor
            ->expects(self::once())
            ->method('process')
            ->with($target)
            ->willReturn($target)
        ;

        $result = $this->processor->process($target, new Patch());

        self::assertSame($target, $result);
    }

    public function testPatchUpdatesRolesCollectionDelegatesToPersistProcessor(): void
    {
        $target = $this->buildUser(42, 'alice', false);
        $target->addRbacRole(new Role('editor', 'Editor', false));

        $currentAdmin = $this->buildUser(1, 'admin', true);
        $this->security->method('getUser')->willReturn($currentAdmin);

        $this->persistProcessor
            ->expects(self::once())
            ->method('process')
            ->with($target)
            ->willReturn($target)
        ;

        $result = $this->processor->process($target, new Patch());

        self::assertSame($target, $result);
        self::assertCount(1, $result->getRbacRoles());
    }

    public function testPatchUpdatesDirectPermissionsCollectionDelegatesToPersistProcessor(): void
    {
        $target = $this->buildUser(42, 'alice', false);
        $target->addDirectPermission(new Permission('core.users.view', 'View', 'core'));

        $currentAdmin = $this->buildUser(1, 'admin', true);
        $this->security->method('getUser')->willReturn($currentAdmin);

        $this->persistProcessor
            ->expects(self::once())
            ->method('process')
            ->with($target)
            ->willReturn($target)
        ;

        $result = $this->processor->process($target, new Patch());

        self::assertSame($target, $result);
        self::assertCount(1, $result->getDirectPermissions());
    }

    public function testPatchSelfRemovingAdminThrowsBadRequestHttpException(): void
    {
        // Meme identifiant : l'user courant PATCH sa propre ressource.
        $self = $this->buildUser(1, 'admin', false);

        $this->security->method('getUser')->willReturn($self);

        $this->unitOfWork
            ->expects(self::once())
            ->method('getOriginalEntityData')
            ->with($self)
            ->willReturn([
                'id'       => 1,
                'username' => 'admin',
                'isAdmin'  => true,
            ])
        ;

        $this->persistProcessor->expects(self::never())->method('process');

        $this->expectException(BadRequestHttpException::class);
        $this->expectExceptionMessage('Vous ne pouvez pas retirer vos propres droits administrateur.');

        $this->processor->process($self, new Patch());
    }

    public function testPatchAdminDemotingAnotherUserIsAllowed(): void
    {
        // Un admin qui retire isAdmin a quelqu'un d'autre : autorise si d'autres
        // admins existent (guard ne leve pas d'exception).
        $target  = $this->buildUser(42, 'alice', false);
        $current = $this->buildUser(1, 'admin', true);

        $this->security->method('getUser')->willReturn($current);

        // La cible perd isAdmin (true -> false) : getOriginalEntityData est appele.
        $this->unitOfWork
            ->method('getOriginalEntityData')
            ->with($target)
            ->willReturn([
                'id'       => 42,
                'username' => 'alice',
                'isAdmin'  => true,
            ])
        ;

        // Le garde ne leve pas d'exception : d'autres admins existent.
        $this->adminHeadcountGuard
            ->expects(self::once())
            ->method('ensureAtLeastOneAdminRemainsAfterDemotion')
            ->with($target)
        ;

        $this->persistProcessor
            ->expects(self::once())
            ->method('process')
            ->with($target)
            ->willReturn($target)
        ;

        $result = $this->processor->process($target, new Patch());

        self::assertSame($target, $result);
    }

    public function testPatchSelfKeepingAdminIsAllowed(): void
    {
        // L'user courant se PATCH lui-meme mais garde isAdmin = true :
        // aucun auto-suicide, on delegue au PersistProcessor.
        $self = $this->buildUser(1, 'admin', true);

        $this->security->method('getUser')->willReturn($self);

        $this->unitOfWork
            ->expects(self::once())
            ->method('getOriginalEntityData')
            ->with($self)
            ->willReturn([
                'id'       => 1,
                'username' => 'admin',
                'isAdmin'  => true,
            ])
        ;

        $this->persistProcessor
            ->expects(self::once())
            ->method('process')
            ->with($self)
            ->willReturn($self)
        ;

        $result = $this->processor->process($self, new Patch());

        self::assertSame($self, $result);
    }

    public function testProcessNonUserDataThrowsLogicException(): void
    {
        // Garde-fou contre une misconfiguration : ce processor est wire
        // exclusivement sur l'operation user_rbac_patch (cible User).
        $this->persistProcessor->expects(self::never())->method('process');

        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('UserRbacProcessor attend une instance de');

        $this->processor->process(new stdClass(), new Patch());
    }

    // -------------------------------------------------------------------------
    // Tests de la garde "dernier admin global"
    // -------------------------------------------------------------------------

    public function testBlocksDemotionWhenLastAdminGlobally(): void
    {
        // L'admin courant A tente de retirer isAdmin a l'admin B (le dernier).
        $adminA = $this->buildUser(1, 'adminA', true);
        $adminB = $this->buildUser(2, 'adminB', false); // isAdmin -> false dans le PATCH

        $this->security->method('getUser')->willReturn($adminA);

        $this->unitOfWork
            ->method('getOriginalEntityData')
            ->with($adminB)
            ->willReturn([
                'id'       => 2,
                'username' => 'adminB',
                'isAdmin'  => true,
            ])
        ;

        // Le garde signale qu'il n'y aurait plus aucun admin.
        $this->adminHeadcountGuard
            ->expects(self::once())
            ->method('ensureAtLeastOneAdminRemainsAfterDemotion')
            ->with($adminB)
            ->willThrowException(new LastAdminProtectionException())
        ;

        $this->persistProcessor->expects(self::never())->method('process');

        $this->expectException(BadRequestHttpException::class);
        $this->expectExceptionMessage('Impossible : au moins un administrateur doit rester sur l\'instance.');

        $this->processor->process($adminB, new Patch());
    }

    public function testDelegatesDemotionWhenAdminsRemain(): void
    {
        // L'admin courant A retire isAdmin a l'admin B, mais d'autres admins existent.
        $adminA = $this->buildUser(1, 'adminA', true);
        $adminB = $this->buildUser(2, 'adminB', false); // isAdmin -> false dans le PATCH

        $this->security->method('getUser')->willReturn($adminA);

        $this->unitOfWork
            ->method('getOriginalEntityData')
            ->with($adminB)
            ->willReturn([
                'id'       => 2,
                'username' => 'adminB',
                'isAdmin'  => true,
            ])
        ;

        // Le garde ne leve pas d'exception : il reste au moins un admin.
        $this->adminHeadcountGuard
            ->expects(self::once())
            ->method('ensureAtLeastOneAdminRemainsAfterDemotion')
            ->with($adminB)
        ;

        $this->persistProcessor
            ->expects(self::once())
            ->method('process')
            ->with($adminB)
            ->willReturn($adminB)
        ;

        $result = $this->processor->process($adminB, new Patch());

        self::assertSame($adminB, $result);
    }

    public function testDoesNotCallGuardWhenIsAdminUntouched(): void
    {
        // PATCH qui ne touche pas isAdmin (reste false) : la garde ne doit pas etre appelee.
        $target  = $this->buildUser(42, 'alice', false);
        $current = $this->buildUser(1, 'admin', true);

        $this->security->method('getUser')->willReturn($current);

        $this->unitOfWork
            ->method('getOriginalEntityData')
            ->with($target)
            ->willReturn([
                'id'       => 42,
                'username' => 'alice',
                'isAdmin'  => false,
            ])
        ;

        // isAdmin reste false : willLoseAdmin = false, garde jamais appelee.
        $this->adminHeadcountGuard
            ->expects(self::never())
            ->method('ensureAtLeastOneAdminRemainsAfterDemotion')
        ;

        $this->persistProcessor
            ->expects(self::once())
            ->method('process')
            ->with($target)
            ->willReturn($target)
        ;

        $result = $this->processor->process($target, new Patch());

        self::assertSame($target, $result);
    }

    public function testAutoSuicideTakesPrecedenceOverLastAdminGlobal(): void
    {
        // L'unique admin tente de se retirer lui-meme son propre flag.
        // La garde auto-suicide doit court-circuiter avant la garde dernier-admin.
        $self = $this->buildUser(1, 'admin', false); // isAdmin -> false dans le PATCH

        $this->security->method('getUser')->willReturn($self);

        $this->unitOfWork
            ->method('getOriginalEntityData')
            ->with($self)
            ->willReturn([
                'id'       => 1,
                'username' => 'admin',
                'isAdmin'  => true,
            ])
        ;

        // La garde dernier-admin ne doit jamais etre appelee : l'auto-suicide
        // court-circuite avant.
        $this->adminHeadcountGuard
            ->expects(self::never())
            ->method('ensureAtLeastOneAdminRemainsAfterDemotion')
        ;

        $this->persistProcessor->expects(self::never())->method('process');

        $this->expectException(BadRequestHttpException::class);
        $this->expectExceptionMessage('Vous ne pouvez pas retirer vos propres droits administrateur.');

        $this->processor->process($self, new Patch());
    }

    /**
     * Construit un User avec un id force via reflection (les mocks
     * d'UnitOfWork n'alimentent pas l'id tout seul).
     */
    private function buildUser(int $id, string $username, bool $isAdmin): User
    {
        $user = new User();
        $user->setUsername($username);
        $user->setIsAdmin($isAdmin);

        $refl = new ReflectionClass($user);
        $prop = $refl->getProperty('id');
        $prop->setAccessible(true);
        $prop->setValue($user, $id);

        return $user;
    }
}
