<?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\Security\Voter;

use Ferienpass\CoreBundle\ConsentManager\ConsentState;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\ConsentForm;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

/**
 * Who may look at a single consent's state (signed / rejected / outstanding).
 *
 * Deliberately more permissive than {@see ConsentVoter}, which guards the
 * Consent entity itself (revoking, deleting, downloading the signature sheet)
 * and stays admin-only. Viewing a state leaks strictly less than the consent
 * overview a Veranstalter can already export for their own offer
 * (`einwilligungen.pdf?detailed=1` even renders every signature sheet in full),
 * so gating it on ROLE_ADMIN only produced participant lists whose consent
 * badges opened an empty 403 drawer.
 *
 * The rule mirrors {@see \Ferienpass\CoreBundle\Repository\ConsentFormRepository::findByOfferOrEdition}
 * — the query that decides which forms a participant list shows in the first
 * place — so a non-admin sees exactly the states their own roster displays,
 * and nothing beyond it.
 */
class ConsentStateVoter extends Voter
{
    public function __construct(private readonly Security $security)
    {
    }

    protected function supports($attribute, $subject): bool
    {
        return 'view' === $attribute && $subject instanceof ConsentState;
    }

    protected function voteOnAttribute($attribute, $subject, TokenInterface $token, ?\Symfony\Component\Security\Core\Authorization\Voter\Vote $vote = null): bool
    {
        if (!$token->getUser() instanceof User) {
            return false;
        }

        if ($this->security->isGranted('ROLE_ADMIN')) {
            return true;
        }

        /** @var ConsentState $subject */
        $form = $subject->getConsentForm();
        if (!$form instanceof ConsentForm) {
            return false;
        }

        // The Veranstalter declaration is the host's own consent, never part of
        // the applicant flow — findByOfferOrEdition() excludes it, so it must
        // not become reachable through a participant's attendance either.
        if ('host' === $form->getSubject()) {
            return false;
        }

        foreach ($this->participants($subject) as $participant) {
            foreach ($participant->getAttendances() as $attendance) {
                if (!$attendance instanceof Attendance) {
                    continue;
                }

                $offer = $attendance->getOffer();
                if (!$offer instanceof OfferInterface || !$this->formApplies($form, $offer)) {
                    continue;
                }

                if ($this->security->isGranted('participants.view', $offer)) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * The subjects to look for attendances under. A per-participant form names
     * its participant; an account-level one (Datenschutz & co.) names only the
     * account, and any of its children being on the roster is what puts the
     * consent in front of the host.
     *
     * @return iterable<ParticipantInterface>
     */
    private function participants(ConsentState $state): iterable
    {
        if (($participant = $state->getParticipant()) instanceof ParticipantInterface) {
            return [$participant];
        }

        return $state->getUser()?->getParticipants() ?? [];
    }

    private function formApplies(ConsentForm $form, OfferInterface $offer): bool
    {
        if (null !== $form->getOffer()) {
            return $form->getOffer() === $offer;
        }

        $edition = $form->getEdition();

        return null !== $edition && $edition === $offer->getEdition();
    }
}
