<?php

declare(strict_types=1);

namespace App\Tests\Module\Commercial\Unit;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientRib;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;

/**
 * Tests unitaires du ClientProcessor : gating par permission (accounting.manage
 * / archive / RG-1.28 strict) et regles metier non testables en HTTP admin
 * (RG-1.12 Virement, RG-1.13 LCR), grace a un Security et un RequestStack stubbes.
 *
 * @internal
 */
final class ClientProcessorTest extends TestCase
{
    public function testAccountingFieldWithoutPermissionIsForbidden(): void
    {
        // RG-1.28 : la modification effective d'un champ comptable sans
        // accounting.manage -> 403. En creation (POST), positionner siren est un
        // changement vs l'etat persiste vide.
        $client = $this->minimalClient();
        $client->setSiren('123456789');

        $processor = $this->makeProcessor(granted: [], payload: ['siren' => '123456789']);

        $this->expectException(AccessDeniedHttpException::class);
        $processor->process($client, $this->operation());
    }

    public function testStrictMixWithAccountingFieldIsForbidden(): void
    {
        // RG-1.28 : payload mixant main + accounting sans la permission -> 403
        // sur l'ensemble (pas de filtrage silencieux).
        $client = $this->minimalClient();
        $client->setCompanyName('X');
        $client->setSiren('123456789');

        $processor = $this->makeProcessor(
            granted: [],
            payload: ['companyName' => 'X', 'siren' => '123456789'],
        );

        $this->expectException(AccessDeniedHttpException::class);
        $processor->process($client, $this->operation());
    }

    public function testArchiveWithoutPermissionIsForbidden(): void
    {
        // RG-1.22 : basculer isArchived sans la permission archive -> 403.
        $client = $this->minimalClient();
        $client->setIsArchived(true);

        $processor = $this->makeProcessor(
            granted: [],
            payload: ['isArchived' => true],
            managed: true,
            originalData: ['isArchived' => false],
        );

        $this->expectException(AccessDeniedHttpException::class);
        $processor->process($client, $this->operation());
    }

    public function testArchiveWithOtherFieldIsUnprocessable(): void
    {
        // RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
        $client = $this->minimalClient();
        $client->setIsArchived(true);
        $client->setCompanyName('X');

        $processor = $this->makeProcessor(
            granted: ['commercial.clients.archive'],
            payload: ['isArchived' => true, 'companyName' => 'X'],
            managed: true,
            originalData: ['isArchived' => false],
        );

        $this->expectException(UnprocessableEntityHttpException::class);
        $processor->process($client, $this->operation());
    }

    public function testPostWithIsArchivedFalseIsNotGated(): void
    {
        // Bug review ERP-55 : un POST renvoyant isArchived:false (valeur par
        // defaut) ne doit declencher ni 403 (archive) ni 422, meme sans
        // permission. L'entite n'est pas encore geree par l'ORM.
        $client = $this->minimalClient(); // isArchived = false par defaut

        $processor = $this->makeProcessor(
            granted: [],
            payload: ['companyName' => 'Test Co', 'isArchived' => false],
            managed: false,
        );

        self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
    }

    public function testFullRepresentationPatchWithUnchangedArchiveIsNotGated(): void
    {
        // Bug review ERP-55 : un PATCH « representation complete » renvoyant
        // isArchived inchange + des cles JSON-LD (@id, @context) ne doit pas etre
        // gate (ni 403 archive ni 422), meme sans permission.
        $client = $this->minimalClient(); // isArchived = false (inchange)

        $processor = $this->makeProcessor(
            granted: [],
            payload: [
                '@id'         => '/api/clients/1',
                '@context'    => '/api/contexts/Client',
                'companyName' => 'Test Co',
                'isArchived'  => false,
            ],
            managed: true,
            // Etat persiste (valeurs normalisees) : sans companyName, guardManage
            // (ERP-74) le croirait modifie (compare a null) et leverait un 403
            // parasite.
            originalData: [
                'companyName'   => 'TEST CO',
                'triageService' => false,
                'isArchived'    => false,
            ],
        );

        self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
    }

    public function testUnchangedAccountingFieldOnPatchIsNotGated(): void
    {
        // Bug review ERP-55 : renvoyer un champ comptable a sa valeur persistee
        // (PATCH representation complete) ne change rien -> pas d'exigence
        // accounting.manage.
        $client = $this->minimalClient();
        $client->setSiren('123456789'); // identique a l'etat persiste

        $processor = $this->makeProcessor(
            granted: [],
            payload: ['companyName' => 'Test Co', 'siren' => '123456789'],
            managed: true,
            // getOriginalEntityData renvoie tous les champs mappes d'une entite
            // geree : isArchived (non-null) y figure toujours, ainsi que les
            // champs metier (companyName) sinon guardManage les croirait modifies.
            originalData: [
                'siren'         => '123456789',
                'companyName'   => 'TEST CO',
                'triageService' => false,
                'isArchived'    => false,
            ],
        );

        self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
    }

    public function testBusinessFieldWithoutManagePermissionIsForbidden(): void
    {
        // ERP-74 (guardManage) : modifier un champ metier (companyName) sur un
        // client existant sans `manage` -> 403, meme avec accounting.manage
        // (cas Compta qui sort de son onglet).
        $client = $this->minimalClient();
        $client->setCompanyName('Renamed Co');

        $processor = $this->makeProcessor(
            granted: ['commercial.clients.accounting.manage'],
            payload: ['companyName' => 'Renamed Co'],
            managed: true,
            originalData: [
                'companyName'   => 'TEST CO',
                'triageService' => false,
                'isArchived'    => false,
            ],
        );

        $this->expectException(AccessDeniedHttpException::class);
        $processor->process($client, $this->operation());
    }

    public function testAccountingOnlyPatchWithAccountingManageOnlyPasses(): void
    {
        // ERP-74 : Compta (accounting.manage, PAS manage) qui ne touche QUE
        // l'onglet Comptabilite d'un client existant -> 200. guardManage ne
        // declenche pas (aucun champ metier modifie), guardAccounting passe.
        $client = $this->minimalClient();
        $client->setSiren('999999999');

        $processor = $this->makeProcessor(
            granted: ['commercial.clients.accounting.manage'],
            payload: ['siren' => '999999999'],
            managed: true,
            originalData: [
                'siren'         => '111111111',
                'companyName'   => 'TEST CO',
                'triageService' => false,
                'isArchived'    => false,
            ],
        );

        self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
    }

    public function testVirementWithoutBankIsUnprocessable(): void
    {
        // RG-1.12
        $client = $this->minimalClient();
        $client->setPaymentType($this->paymentType('VIREMENT'));

        $processor = $this->makeProcessor(
            granted: ['commercial.clients.accounting.manage'],
            payload: ['paymentType' => '/api/payment_types/1'],
        );

        $this->expectException(ValidationException::class);
        $processor->process($client, $this->operation());
    }

    public function testVirementWithBankPasses(): void
    {
        // RG-1.12 satisfait : Virement + banque.
        $client = $this->minimalClient();
        $client->setPaymentType($this->paymentType('VIREMENT'));
        $client->setBank(new Bank());

        $processor = $this->makeProcessor(
            granted: ['commercial.clients.accounting.manage'],
            payload: ['paymentType' => '/api/payment_types/1', 'bank' => '/api/banks/1'],
        );

        $result = $processor->process($client, $this->operation());
        self::assertInstanceOf(Client::class, $result);
    }

    public function testLcrWithoutRibIsUnprocessable(): void
    {
        // RG-1.13
        $client = $this->minimalClient();
        $client->setPaymentType($this->paymentType('LCR'));

        $processor = $this->makeProcessor(
            granted: ['commercial.clients.accounting.manage'],
            payload: ['paymentType' => '/api/payment_types/2'],
        );

        $this->expectException(ValidationException::class);
        $processor->process($client, $this->operation());
    }

    public function testLcrWithRibPasses(): void
    {
        // RG-1.13 satisfait : LCR + au moins un RIB.
        $client = $this->minimalClient();
        $client->setPaymentType($this->paymentType('LCR'));
        $client->addRib(new ClientRib());

        $processor = $this->makeProcessor(
            granted: ['commercial.clients.accounting.manage'],
            payload: ['paymentType' => '/api/payment_types/2'],
        );

        self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
    }

    public function testFullAccountingSubmitWithEmptyFieldsIsUnprocessable(): void
    {
        // spec-front § Onglet Comptabilite : une validation complete de l'onglet
        // (les 6 champs presents dans le payload) avec des valeurs vides -> 422.
        // C'est le bug corrige : avant, le back acceptait un onglet tout vide.
        $client = $this->minimalClient(); // aucun champ comptable renseigne

        $processor = $this->makeProcessor(
            granted: ['commercial.clients.accounting.manage'],
            payload: $this->emptyAccountingPayload(),
        );

        $this->expectException(ValidationException::class);
        $processor->process($client, $this->operation());
    }

    public function testFullAccountingSubmitWithAllFieldsPasses(): void
    {
        // Les 6 champs obligatoires renseignes + type de reglement neutre
        // (ni VIREMENT ni LCR -> ni banque ni RIB requis) -> 200.
        $client = $this->minimalClient();
        $client->setSiren('123456789');
        $client->setAccountNumber('00012345678');
        $client->setTvaMode(new TvaMode());
        $client->setNTva('FR12345678901');
        $client->setPaymentDelay(new PaymentDelay());
        $client->setPaymentType($this->paymentType('CHEQUE'));

        $processor = $this->makeProcessor(
            granted: ['commercial.clients.accounting.manage'],
            payload: $this->emptyAccountingPayload(),
        );

        self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
    }

    public function testPartialAccountingPatchSkipsCompleteness(): void
    {
        // Un PATCH ciblant un seul champ comptable n'est pas une validation
        // d'onglet : la completude n'est pas exigee (les autres champs restent
        // vides) -> 200. Preserve l'edition ponctuelle (ex. Compta corrige le SIREN).
        $client = $this->minimalClient();
        $client->setSiren('999999999');

        $processor = $this->makeProcessor(
            granted: ['commercial.clients.accounting.manage'],
            payload: ['siren' => '999999999'],
            managed: true,
            originalData: [
                'siren'         => '111111111',
                'companyName'   => 'TEST CO',
                'triageService' => false,
                'isArchived'    => false,
            ],
        );

        self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
    }

    /**
     * @param list<string>         $granted      Permissions accordees a l'utilisateur courant
     * @param array<string, mixed> $payload      Corps JSON simule de la requete
     * @param bool                 $managed      true = entite geree par l'ORM (PATCH), false = creation (POST)
     * @param array<string, mixed> $originalData Etat persiste simule (getOriginalEntityData) pour la detection de changement
     */
    private function makeProcessor(
        array $granted,
        array $payload,
        bool $managed = false,
        array $originalData = [],
    ): ClientProcessor {
        $persist = new class implements ProcessorInterface {
            public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
            {
                return $data;
            }
        };

        $security = $this->createStub(Security::class);
        $security->method('isGranted')->willReturnCallback(
            static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true),
        );

        $requestStack = new RequestStack();
        $requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR)));

        // EntityManager stub : contains() distingue creation (POST) et mise a
        // jour (PATCH) ; getOriginalEntityData() fournit l'etat persiste compare
        // par le gating (RG-1.22 / RG-1.28).
        $uow = $this->createMock(UnitOfWork::class);
        $uow->method('getOriginalEntityData')->willReturn($originalData);

        $em = $this->createMock(EntityManagerInterface::class);
        $em->method('contains')->willReturn($managed);
        $em->method('getUnitOfWork')->willReturn($uow);

        return new ClientProcessor(
            $persist,
            new ClientFieldNormalizer(),
            new ClientAccountingCompletenessValidator(),
            $security,
            $requestStack,
            $em,
        );
    }

    /**
     * Client minimal — companyName seul depuis la suppression du contact inline.
     * Suffisant pour atteindre les validations testees (le contact vit desormais
     * dans ClientContact, hors scope du ClientProcessor).
     */
    private function minimalClient(): Client
    {
        $client = new Client();
        $client->setCompanyName('Test Co');

        return $client;
    }

    /**
     * Payload simulant une validation complete de l'onglet Comptabilite : les 6
     * champs obligatoires presents (le front les envoie toujours ensemble). Les
     * valeurs importent peu — la completude est evaluee sur l'etat de l'entite.
     *
     * @return array<string, mixed>
     */
    private function emptyAccountingPayload(): array
    {
        return [
            'siren'         => null,
            'accountNumber' => null,
            'tvaMode'       => null,
            'nTva'          => null,
            'paymentDelay'  => null,
            'paymentType'   => null,
        ];
    }

    private function paymentType(string $code): PaymentType
    {
        $type = new PaymentType();
        $type->setCode($code);
        $type->setLabel($code);

        return $type;
    }

    private function operation(): Operation
    {
        return $this->createStub(Operation::class);
    }
}
