<?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\ConsentManager;

use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Consent;
use Ferienpass\CoreBundle\Entity\ConsentForm;
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 Symfony\Contracts\Service\ResetInterface;

class ConsentManager implements ResetInterface
{
    /**
     * Consent forms per offer id, for the lifetime of one request.
     *
     * A participant list asks for the states of every row, and each of those
     * resolves the same offer's forms — without this that is one query per row.
     * Implementing ResetInterface keeps it correct under a worker runtime,
     * where the container survives between requests.
     *
     * @var array<int, ConsentForm[]>
     */
    private array $formsByOffer = [];

    /**
     * Signatures pre-loaded for a whole offer, keyed by offer id.
     *
     * @var array<int, array{forms: ConsentForm[], participants: ParticipantInterface[], users: User[], signatures: Consent[]}>
     */
    private array $signatureScopes = [];

    /**
     * How often {@see forOfferAndParticipants()} was asked about each offer.
     *
     * The pre-load costs two queries (the offer's participants, then their
     * signatures), so it only pays off from the second row on. Detail pages,
     * which ask exactly once per offer, keep their single query.
     *
     * @var array<int, int>
     */
    private array $offerLookups = [];

    public function __construct(
        private ConsentFormRepository $consentFormRepository,
        private ConsentRepository $consentRepository,
        private EntityManagerInterface $entityManager
    ) {
    }

    public function reset(): void
    {
        $this->formsByOffer = [];
        $this->signatureScopes = [];
        $this->offerLookups = [];
    }

    public function findState(ConsentForm $consentForm, User $user, ?ParticipantInterface $participant = null): ConsentState
    {
        $states = $this->forFormsAndParticipants([$consentForm], array_filter([$participant]), $user);

        return $states[0];
    }

    public function sign(ConsentForm $consentForm, ?User $user, ?ParticipantInterface $participant = null): ConsentState
    {
        return $this->updateDecision($consentForm, $user, $participant, 1);
    }

    public function decline(ConsentForm $consentForm, ?User $user, ?ParticipantInterface $participant = null): ConsentState
    {
        return $this->updateDecision($consentForm, $user, $participant, 0);
    }

    public function getConsentsForOffer(OfferInterface $offer): array
    {
        $id = $offer->getId();
        if (null === $id) {
            return $this->consentFormRepository->findForOffer($offer);
        }

        return $this->formsByOffer[$id] ??= $this->consentFormRepository->findForOffer($offer);
    }

    /**
     * @param ParticipantInterface[] $participants
     *
     * @return ConsentState[]
     */
    public function forOfferAndParticipants(OfferInterface $offer, array $participants, ?User $user = null): array
    {
        $forms = $this->getConsentsForOffer($offer);

        $this->primeSignaturesForOffer($offer, $forms);

        return $this->forFormsAndParticipants($forms, $participants, $user);
    }

    /**
     * @param ConsentForm[]          $consentForms
     * @param ParticipantInterface[] $participants
     *
     * @return ConsentState[]
     */
    public function forFormsAndParticipants(array $consentForms, array $participants, ?User $user = null): array
    {
        $user ??= (!empty($participants) ? $participants[0]->getUser() : null);

        $signatures = $this->findSignatures($consentForms, $user, $participants);

        $states = [];
        foreach ($consentForms as $form) {
            // CARDINALITY is driven by the subject, NOT by gate-vs-inline
            // placement: account → one state; participant → one state per
            // participant (even for edition-scoped forms shown in the gate).
            $perAccount = !$form->isPerParticipant();
            $subjects = $perAccount ? [null] : ($participants ?: [null]);

            foreach ($subjects as $subject) {
                $targetUser = $perAccount ? $user : ($subject?->getUser() ?? $user);

                $candidates = array_filter($signatures, fn (Consent $sig) => $this->signatureMatches($sig, $form, $subject, $perAccount));

                $best = $this->findBestSignature($candidates, $form->getText(), $targetUser);

                $states[] = new ConsentState($form, $best, $perAccount ? null : $subject, $targetUser);
            }
        }

        return $states;
    }

    /**
     * Pre-load every signature the given offer's rows can possibly need, so a
     * participant list does not run one lookup per row.
     *
     * Deliberately keyed on the offer rather than on the page: the roster is
     * paginated inside the list component, which never reaches this service,
     * and a superset stays correct — {@see signaturesFromScope()} narrows the
     * cached set with the very predicate the per-row query would have used.
     *
     * @param ConsentForm[] $forms
     */
    private function primeSignaturesForOffer(OfferInterface $offer, array $forms): void
    {
        $id = $offer->getId();
        if (null === $id || [] === $forms || isset($this->signatureScopes[$id])) {
            return;
        }

        // First ask about this offer: answer it with the single narrow query,
        // as before. Only a second row makes the two-query pre-load worthwhile.
        $this->offerLookups[$id] = ($this->offerLookups[$id] ?? 0) + 1;
        if ($this->offerLookups[$id] < 2) {
            return;
        }

        // Rooted on the attendance, not the participant: DQL cannot select an
        // entity reached only through a join, and the participant entity is an
        // interface resolved per installation.
        $attendances = $this->entityManager->createQueryBuilder()
            ->select('a')
            ->addSelect('p')
            ->from(Attendance::class, 'a')
            ->innerJoin('a.participant', 'p')
            ->where('a.offer = :offer')
            ->setParameter('offer', $offer)
            ->getQuery()
            ->getResult()
        ;

        $participants = [];
        foreach ($attendances as $attendance) {
            $participant = $attendance->getParticipant();
            if (null !== $participant) {
                $participants[spl_object_id($participant)] = $participant;
            }
        }
        $participants = array_values($participants);

        if ([] === $participants) {
            return;
        }

        $users = array_values(array_filter(array_map(static fn (ParticipantInterface $p) => $p->getUser(), $participants)));

        $this->signatureScopes[$id] = [
            'forms' => $forms,
            'participants' => $participants,
            'users' => $users,
            'signatures' => $this->consentRepository->findSignatures($forms, null, $participants),
        ];
    }

    /**
     * The signatures {@see ConsentRepository::findSignatures()} would return —
     * served from a pre-loaded scope when one provably covers this lookup.
     *
     * @param ConsentForm[]          $consentForms
     * @param ParticipantInterface[] $participants
     *
     * @return Consent[]
     */
    private function findSignatures(array $consentForms, ?User $user, array $participants): array
    {
        foreach ($this->signatureScopes as $scope) {
            if ($this->scopeCovers($scope, $consentForms, $user, $participants)) {
                return array_values(array_filter(
                    $scope['signatures'],
                    fn (Consent $sig) => $this->matchesLookup($sig, $consentForms, $user, $participants),
                ));
            }
        }

        return $this->consentRepository->findSignatures($consentForms, $user, $participants);
    }

    /**
     * Whether a pre-loaded scope is a superset of this lookup.
     *
     * The scope was loaded with `formOrHash(scope.forms) AND subject(null,
     * scope.participants)`. With `forms ⊆ scope.forms` the form/hash half of a
     * narrower lookup implies the scope's (its hash set is a subset too), and
     * with `participants ⊆ scope.participants` plus `user` among the scope's
     * users so does the subject half. The narrower result set is therefore
     * contained in the scope, and filtering it yields exactly that result.
     *
     * @param array{forms: ConsentForm[], participants: ParticipantInterface[], users: User[], signatures: Consent[]} $scope
     * @param ConsentForm[]                                                                                           $consentForms
     * @param ParticipantInterface[]                                                                                  $participants
     */
    private function scopeCovers(array $scope, array $consentForms, ?User $user, array $participants): bool
    {
        foreach ($consentForms as $form) {
            if (!\in_array($form, $scope['forms'], true)) {
                return false;
            }
        }

        foreach ($participants as $participant) {
            if (!\in_array($participant, $scope['participants'], true)) {
                return false;
            }
        }

        return !$user instanceof User || \in_array($user, $scope['users'], true);
    }

    /**
     * Mirrors {@see ConsentRepository::findSignatures()}'s WHERE clause. Keep
     * the two in step: a signature this lets through that the query would not
     * have returned is a consent shown against the wrong account.
     *
     * @param ConsentForm[]          $consentForms
     * @param ParticipantInterface[] $participants
     */
    private function matchesLookup(Consent $sig, array $consentForms, ?User $user, array $participants): bool
    {
        // c.consentForm IN (:forms) OR c.hash IN (:hashes) OR t.hash IN (:hashes)
        $hashes = array_filter(array_map(static fn (ConsentForm $f) => $f->getConsentText()?->getHash(), $consentForms));
        $formOrHash = \in_array($sig->getConsentForm(), $consentForms, true)
            || ([] !== $hashes && (\in_array($sig->getHash(), $hashes, true) || \in_array($sig->getConsentText()?->getHash(), $hashes, true)));

        if (!$formOrHash) {
            return false;
        }

        // c.user = :user OR c.participant IN (:participants) OR c.user IN (:users)
        if ($user instanceof User && $sig->getUser() === $user) {
            return true;
        }

        if ([] === $participants) {
            return false;
        }

        if (\in_array($sig->getParticipant(), $participants, true)) {
            return true;
        }

        $users = array_filter(array_map(static fn (ParticipantInterface $p) => $p->getUser(), $participants));

        return [] !== $users && \in_array($sig->getUser(), $users, true);
    }

    private function signatureMatches(Consent $sig, ConsentForm $form, ?ParticipantInterface $subject, bool $perAccount): bool
    {
        $matchByForm = $sig->getConsentForm() === $form;
        $matchByHash = $sig->getHash() && $sig->getHash() === $form->getConsentText()?->getHash();

        if (!$matchByForm && !$matchByHash) {
            return false;
        }

        return $perAccount || $sig->getParticipant() === $subject;
    }

    private function updateDecision(ConsentForm $consentForm, ?User $user, ?ParticipantInterface $participant, int $decision): ConsentState
    {
        /** @var Consent|null $signature */
        $signature = $this->consentRepository->findOneBy([
            'consentForm' => $consentForm,
            'participant' => $participant,
            'user' => $user,
        ], ['createdAt' => 'DESC']);

        if ($signature && !$signature->isValid($consentForm->getText())) {
            $signature = null;
        }

        if ($signature) {
            $signature->setDecision($decision);
        } else {
            $signature = new Consent($user, participant: $participant, consentForm: $consentForm, decision: $decision);
            $this->entityManager->persist($signature);
        }

        $this->entityManager->flush();

        return new ConsentState($consentForm, $signature, $participant, $user);
    }

    /**
     * @param Consent[] $signatures
     */
    private function findBestSignature(array $signatures, ?string $consentText, ?User $user): ?Consent
    {
        if (null !== $user) {
            $signatures = array_filter($signatures, static fn (Consent $s) => $s->getUser() === $user);
        }

        if ([] === $signatures) {
            return null;
        }

        usort($signatures, static function (Consent $a, Consent $b) use ($consentText) {
            if (($aValid = $a->isValid($consentText)) !== ($bValid = $b->isValid($consentText))) {
                return $aValid ? -1 : 1;
            }

            return $b->getCreatedAt() <=> $a->getCreatedAt();
        });

        return $signatures[0];
    }
}
