<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\Tests\ConsentManager;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Ferienpass\CoreBundle\ConsentManager\ConsentManager;
use Ferienpass\CoreBundle\ConsentManager\ConsentState;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Consent;
use Ferienpass\CoreBundle\Entity\ConsentForm;
use Ferienpass\CoreBundle\Entity\ConsentText;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Repository\ConsentFormRepository;
use Ferienpass\CoreBundle\Repository\ConsentRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

/**
 * The participant list resolves consent states row by row. To stop that being
 * one signature query per row, {@see ConsentManager} pre-loads the offer's
 * signatures and narrows them in PHP.
 *
 * These tests pin the property that makes that safe: whatever a row is shown
 * must not depend on whether the pre-load happened. The repository double below
 * implements the same predicate as the real
 * {@see ConsentRepository::findSignatures()} query, so a divergence between the
 * SQL and its PHP counterpart shows up as a differing state.
 */
final class ConsentManagerSignatureScopeTest extends TestCase
{
    public function testPerParticipantFormShowsEachRowOnlyItsOwnSignature(): void
    {
        $form = $this->form(perParticipant: true, text: 'per-participant');

        [$anna, $annasAccount] = $this->participant('anna');
        [$ben, $bensAccount] = $this->participant('ben');

        $annasSignature = new Consent($annasAccount, participant: $anna, consentForm: $form);
        $bensSignature = new Consent($bensAccount, participant: $ben, consentForm: $form);

        $this->assertRowsUnaffectedByPreload([$form], [$anna, $ben], [$annasSignature, $bensSignature], [
            'anna' => $annasSignature,
            'ben' => $bensSignature,
        ]);
    }

    /**
     * The dangerous case. A per-account form resolves against the *account*, and
     * {@see ConsentManager::signatureMatches()} does not compare users for it —
     * only the query's `c.user = :user` narrowing keeps one family's signature
     * away from another's. A participant without an account has no user to
     * narrow by, so the pre-loaded set must not leak the other rows' signatures
     * into their state.
     */
    public function testPerAccountFormDoesNotLeakAcrossAccountlessParticipants(): void
    {
        $form = $this->form(perParticipant: false, text: 'per-account');

        [$anna, $annasAccount] = $this->participant('anna');
        [$orphan] = $this->participant('orphan', withAccount: false);

        $annasSignature = new Consent($annasAccount, participant: null, consentForm: $form);

        $this->assertRowsUnaffectedByPreload([$form], [$anna, $orphan], [$annasSignature], [
            'anna' => $annasSignature,
            'orphan' => null,
        ]);
    }

    public function testPerAccountFormResolvesThroughTheParticipantsAccount(): void
    {
        $form = $this->form(perParticipant: false, text: 'per-account');

        [$anna, $annasAccount] = $this->participant('anna');
        [$ben, $bensAccount] = $this->participant('ben');

        $annasSignature = new Consent($annasAccount, participant: null, consentForm: $form);
        $bensSignature = new Consent($bensAccount, participant: null, consentForm: $form);

        $this->assertRowsUnaffectedByPreload([$form], [$anna, $ben], [$annasSignature, $bensSignature], [
            'anna' => $annasSignature,
            'ben' => $bensSignature,
        ]);
    }

    /**
     * A migrated signature carries the consent-text hash but no form. Both the
     * query and its PHP counterpart have to match it by hash.
     */
    public function testFormlessSignatureStillMatchesByHash(): void
    {
        $text = new ConsentText('the text');
        $form = $this->form(perParticipant: true, text: 'the text', consentText: $text);

        [$anna, $annasAccount] = $this->participant('anna');
        [$ben] = $this->participant('ben');

        $hashed = new Consent($annasAccount, hash: $text->getHash(), participant: $anna);

        $this->assertRowsUnaffectedByPreload([$form], [$anna, $ben], [$hashed], [
            'anna' => $hashed,
            'ben' => null,
        ]);
    }

    public function testARevokedSignatureStaysRevokedThroughThePreload(): void
    {
        $form = $this->form(perParticipant: true, text: 'per-participant');

        [$anna, $annasAccount] = $this->participant('anna');

        $revoked = new Consent($annasAccount, participant: $anna, consentForm: $form);
        $revoked->revoke();

        $this->assertRowsUnaffectedByPreload([$form], [$anna], [$revoked], ['anna' => $revoked]);
    }

    /**
     * A page that asks about one offer once — a detail view — must not pay for
     * the pre-load, which costs a participant query on top of the signatures.
     */
    public function testASingleLookupDoesNotTriggerThePreload(): void
    {
        $form = $this->form(perParticipant: true, text: 'per-participant');
        [$anna] = $this->participant('anna');

        $repository = $this->consentRepository([]);
        $repository->expects($this->once())->method('findSignatures');

        $entityManager = $this->createMock(EntityManagerInterface::class);
        $entityManager->expects($this->never())->method('createQueryBuilder');

        $manager = new ConsentManager($this->formRepository([$form]), $repository, $entityManager);
        $manager->forOfferAndParticipants($this->offer(), [$anna]);
    }

    public function testTheSecondLookupPreloadsAndTheRestAreServedFromIt(): void
    {
        $form = $this->form(perParticipant: true, text: 'per-participant');
        $participants = [];
        for ($i = 0; $i < 10; ++$i) {
            [$participants[]] = $this->participant("p$i");
        }

        $repository = $this->consentRepository([]);
        // Once for the first row, once for the pre-load. Not ten times.
        $repository->expects($this->exactly(2))->method('findSignatures');

        $manager = new ConsentManager($this->formRepository([$form]), $repository, $this->entityManager($participants));

        $offer = $this->offer();
        foreach ($participants as $participant) {
            $manager->forOfferAndParticipants($offer, [$participant]);
        }
    }

    public function testResetDropsThePreloadedScope(): void
    {
        $form = $this->form(perParticipant: true, text: 'per-participant');
        [$anna] = $this->participant('anna');
        [$ben] = $this->participant('ben');

        $repository = $this->consentRepository([]);
        // Two requests' worth of lookups: 1 + pre-load, then 1 + pre-load again.
        $repository->expects($this->exactly(4))->method('findSignatures');

        $manager = new ConsentManager($this->formRepository([$form]), $repository, $this->entityManager([$anna, $ben]));

        $offer = $this->offer();
        $manager->forOfferAndParticipants($offer, [$anna]);
        $manager->forOfferAndParticipants($offer, [$ben]);

        $manager->reset();

        $manager->forOfferAndParticipants($offer, [$anna]);
        $manager->forOfferAndParticipants($offer, [$ben]);
    }

    /**
     * Renders every participant as a list row twice — once with the pre-load
     * suppressed (each row hits the repository, the behaviour before batching)
     * and once with it active — and asserts both agree, and that they agree
     * with the signature the row is expected to resolve to.
     *
     * @param ConsentForm[]               $forms
     * @param ParticipantInterface[]      $participants
     * @param Consent[]                   $signatures
     * @param array<string, Consent|null> $expected     participant name => its signature
     */
    private function assertRowsUnaffectedByPreload(array $forms, array $participants, array $signatures, array $expected): void
    {
        $offer = $this->offer();

        // Without the pre-load: a fresh manager per row never reaches a second
        // lookup, so every row goes to the repository, as it did before.
        $unbatched = [];
        foreach ($participants as $participant) {
            $manager = new ConsentManager(
                $this->formRepository($forms),
                $this->consentRepository($signatures),
                $this->createStub(EntityManagerInterface::class),
            );
            $unbatched[$participant->getName()] = $manager->forOfferAndParticipants($offer, [$participant]);
        }

        // With the pre-load: one manager across the whole page.
        $manager = new ConsentManager(
            $this->formRepository($forms),
            $this->consentRepository($signatures),
            $this->entityManager($participants),
        );
        $batched = [];
        foreach ($participants as $participant) {
            $batched[$participant->getName()] = $manager->forOfferAndParticipants($offer, [$participant]);
        }

        foreach ($participants as $participant) {
            $name = $participant->getName();

            $this->assertSame(
                $this->describe($unbatched[$name]),
                $this->describe($batched[$name]),
                \sprintf('Row "%s" resolved differently with the pre-load active.', $name),
            );

            $this->assertCount(1, $batched[$name], \sprintf('Row "%s" should hold one state per form.', $name));
            $this->assertSame(
                $expected[$name],
                $batched[$name][0]->getSignature(),
                \sprintf('Row "%s" resolved to the wrong signature.', $name),
            );
        }
    }

    /**
     * @param ConsentState[] $states
     *
     * @return array<int, array<string, mixed>>
     */
    private function describe(array $states): array
    {
        return array_map(static fn (ConsentState $s) => [
            'form' => spl_object_id($s->getConsentForm()),
            'signature' => $s->getSignature() ? spl_object_id($s->getSignature()) : null,
            'participant' => $s->getParticipant()?->getName(),
            'user' => $s->getUser()?->getEmail(),
            'signed' => $s->isSigned(),
            'revoked' => $s->isRevoked(),
            'acknowledged' => $s->isAcknowledged(),
        ], $states);
    }

    /**
     * A stand-in for the real query: the same predicate, over an in-memory set.
     *
     * @param Consent[] $signatures
     *
     * @return ConsentRepository&MockObject
     */
    private function consentRepository(array $signatures): ConsentRepository
    {
        $repository = $this->createMock(ConsentRepository::class);
        $repository->method('findSignatures')->willReturnCallback(
            static function (array $forms, ?User $user, array $participants) use ($signatures): array {
                if ([] === $forms) {
                    return [];
                }

                $hashes = array_filter(array_map(static fn (ConsentForm $f) => $f->getConsentText()?->getHash(), $forms));
                $users = array_filter(array_map(static fn (ParticipantInterface $p) => $p->getUser(), $participants));

                if (!$user instanceof User && [] === $participants) {
                    return [];
                }

                return array_values(array_filter($signatures, static function (Consent $sig) use ($forms, $hashes, $user, $participants, $users): bool {
                    $formOrHash = \in_array($sig->getConsentForm(), $forms, true)
                        || ([] !== $hashes && (\in_array($sig->getHash(), $hashes, true) || \in_array($sig->getConsentText()?->getHash(), $hashes, true)));

                    if (!$formOrHash) {
                        return false;
                    }

                    return ($user instanceof User && $sig->getUser() === $user)
                        || ([] !== $participants && \in_array($sig->getParticipant(), $participants, true))
                        || ([] !== $users && \in_array($sig->getUser(), $users, true));
                }));
            }
        );

        return $repository;
    }

    /**
     * @param ConsentForm[] $forms
     *
     * @return ConsentFormRepository&MockObject
     */
    private function formRepository(array $forms): ConsentFormRepository
    {
        $repository = $this->createMock(ConsentFormRepository::class);
        $repository->method('findForOffer')->willReturn($forms);

        return $repository;
    }

    /**
     * Answers the pre-load's "which attendances does this offer have" query.
     * The pre-load reads the participants off them, so hand back attendances,
     * not participants — a mismatch here makes the pre-load quietly not happen
     * and every assertion below compare the un-batched path against itself.
     *
     * @param ParticipantInterface[] $participants
     */
    private function entityManager(array $participants): EntityManagerInterface
    {
        $attendances = array_map(function (ParticipantInterface $participant): Attendance {
            $attendance = $this->createStub(Attendance::class);
            $attendance->method('getParticipant')->willReturn($participant);

            return $attendance;
        }, $participants);

        $query = $this->createStub(Query::class);
        $query->method('getResult')->willReturn($attendances);

        $queryBuilder = $this->createStub(QueryBuilder::class);
        $queryBuilder->method('select')->willReturnSelf();
        $queryBuilder->method('addSelect')->willReturnSelf();
        $queryBuilder->method('from')->willReturnSelf();
        $queryBuilder->method('innerJoin')->willReturnSelf();
        $queryBuilder->method('where')->willReturnSelf();
        $queryBuilder->method('setParameter')->willReturnSelf();
        $queryBuilder->method('getQuery')->willReturn($query);

        $entityManager = $this->createStub(EntityManagerInterface::class);
        $entityManager->method('createQueryBuilder')->willReturn($queryBuilder);

        return $entityManager;
    }

    private function offer(): OfferInterface
    {
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getId')->willReturn(42);

        return $offer;
    }

    private function form(bool $perParticipant, string $text, ?ConsentText $consentText = null): ConsentForm
    {
        $form = $this->createStub(ConsentForm::class);
        $form->method('isPerParticipant')->willReturn($perParticipant);
        $form->method('getText')->willReturn($text);
        $form->method('getConsentText')->willReturn($consentText);

        return $form;
    }

    /**
     * @return array{ParticipantInterface, User|null}
     */
    private function participant(string $name, bool $withAccount = true): array
    {
        $user = null;
        if ($withAccount) {
            $user = $this->createStub(User::class);
            $user->method('getEmail')->willReturn($name.'@example.org');
        }

        $participant = $this->createStub(ParticipantInterface::class);
        $participant->method('getName')->willReturn($name);
        $participant->method('getUser')->willReturn($user);

        return [$participant, $user];
    }
}
