<?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\CmsBundle\Controller\Page;

use Contao\CoreBundle\Exception\PageNotFoundException;
use Ferienpass\CmsBundle\Controller\AbstractController;
use Ferienpass\CmsBundle\Fragment\FragmentReference;
use Ferienpass\CoreBundle\ConsentManager\ConsentManager;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Enum\AttendanceTransition;
use Ferienpass\CoreBundle\Facade\AttendanceFacade;
use Ferienpass\CoreBundle\Message\AttendanceWithdrawRequested;
use Ferienpass\CoreBundle\Repository\AttendanceRepository;
use Ferienpass\CoreBundle\Security\Voter\AttendanceVoter;
use Ferienpass\CoreBundle\Ux\Flash;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Workflow\WorkflowInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
 * The canonical confirmation / status page for a single attendance ("Anmelde-
 * bestätigung"). It is reachable three ways:
 *   - the authenticated owner (or an admin) via a plain link;
 *   - an unauthenticated visitor via a signed URL (UriSigner + a `valid` expiry),
 *     as handed out for team-created applications and after e-mail confirmation;
 *   - the legal guardian via the attendance's consent token, to sign the
 *     outstanding consents (this page supersedes the former /einwilligung/{token}).
 *
 * Withdrawal differs by access: the authenticated owner withdraws directly via the
 * existing "Meine Anmeldungen" flow; an unauthenticated visitor only triggers a
 * confirmation e-mail — the withdrawal itself happens when that link is clicked
 * ({@see AttendanceWithdrawConfirmController}), so a shared/forwarded page link
 * cannot perform the irreversible withdrawal on its own.
 */
class AttendanceShowController extends AbstractController
{
    public function __construct(
        private readonly UriSigner $uriSigner,
        private readonly WorkflowInterface $attendanceStateMachine,
        private readonly MessageBusInterface $eventBus,
        private readonly ConsentManager $consentManager,
        private readonly AttendanceFacade $attendanceFacade,
        private readonly TranslatorInterface $translator,
        private readonly AttendanceRepository $attendances,
    ) {
    }

    // The public URL carries the base58 UUID (short, like the offer reader), not the
    // dashed RFC 4122 form. Requirement limits it to the base58 alphabet.
    #[Route('/anmeldung/{attendance}', name: 'cms_attendance_show', requirements: ['attendance' => '[1-9A-HJ-NP-Za-km-z]+'], methods: ['GET', 'POST'])]
    public function __invoke(string $attendance, Request $request): Response
    {
        $this->initializeContaoFramework();

        $attendance = $this->findByBase58($attendance);

        $access = $this->resolveAccess($attendance, $request);
        if (null === $access) {
            throw new PageNotFoundException();
        }

        $consentError = null;
        if ($request->isMethod('POST')) {
            $action = (string) $request->request->get('action');

            // Unauthenticated withdrawal is never immediate: dispatch the e-mail
            // confirmation link and confirm with a toast. PRG back to the *unchanged*
            // signed URL — appending a query parameter would invalidate the
            // signature; the flash rides the stateless `flashes` cookie the app
            // already uses, so it works without a server session.
            if ('request-withdraw' === $action && 'anon' === $access) {
                if ($this->attendanceStateMachine->can($attendance, AttendanceTransition::WITHDRAW)) {
                    $this->eventBus->dispatch(new AttendanceWithdrawRequested((string) $attendance->getUuid()));
                    // The FOSHttpCacheBundle flash listener json_encodes the flash
                    // bag verbatim onto the stateless `flashes` cookie, so the toast
                    // text must be a plain string (a TranslatableMessage would encode
                    // to `{}`); translate it here.
                    $this->addFlash(...Flash::confirmation()
                        ->headline($this->translator->trans('attendanceShow.withdraw.sent.toastTitle', [], 'cms'))
                        ->text($this->translator->trans('attendanceShow.withdraw.sent.toast', ['email' => (string) $attendance->getParticipant()?->getEmail()], 'cms'))
                        ->create());
                }

                return $this->redirect($request->getRequestUri(), Response::HTTP_SEE_OTHER);
            }

            // Sign the outstanding consents (supersedes /einwilligung/{token}). On
            // success the attendance leaves CONSENTS_OUTSTANDING, so a plain reload
            // of the (unchanged) URL already renders the resulting status.
            if ('sign-consents' === $action) {
                $consentError = $this->signConsents($attendance, $request);
                if (null === $consentError) {
                    return $this->redirect($request->getRequestUri(), Response::HTTP_SEE_OTHER);
                }
            }
        }

        $response = $this->createPageBuilder()
            ->addFragment('main', new FragmentReference('ferienpass.fragment.attendance_show', [
                'attendance' => $attendance->getUuid()->toRfc4122(),
                'access' => $access,
                'formAction' => $request->getRequestUri(),
                'consentError' => $consentError,
            ]))
            ->getResponse()
        ;

        // The anon / token paths carry a bearer credential (signature or consent
        // token) in the URL: keep it out of the Referer header on outbound links.
        if ('anon' === $access) {
            $response->headers->set('Referrer-Policy', 'no-referrer');
        }

        return $this->markPageAsPrivate($response);
    }

    private function findByBase58(string $base58): Attendance
    {
        try {
            $uuid = Uuid::fromBase58($base58);
        } catch (\InvalidArgumentException) {
            throw new PageNotFoundException();
        }

        $attendance = $this->attendances->findOneBy(['uuid' => $uuid]);
        if (!$attendance instanceof Attendance) {
            throw new PageNotFoundException();
        }

        return $attendance;
    }

    /**
     * @return 'auth'|'anon'|null the granted access mode, or null when the request
     *                            may not view the attendance
     */
    private function resolveAccess(Attendance $attendance, Request $request): ?string
    {
        // Authenticated owner or admin.
        if ($this->isGranted(AttendanceVoter::VIEW, $attendance)) {
            return 'auth';
        }

        // Signed URL with an explicit, self-described expiry (mirrors
        // AttendanceConfirmController::isValidLink()).
        if ($this->isValidSignedLink($request)) {
            return 'anon';
        }

        // Consent token — the bearer credential handed to the legal guardian.
        $token = (string) $request->query->get('token');
        if ('' !== $token && null !== $attendance->getConsentToken()
            && hash_equals($attendance->getConsentToken(), $token)) {
            return 'anon';
        }

        return null;
    }

    private function isValidSignedLink(Request $request): bool
    {
        if (!$this->uriSigner->checkRequest($request)) {
            return false;
        }

        $valid = $request->query->get('valid');
        if (!\is_string($valid) || '' === $valid) {
            return false;
        }

        try {
            return new \DateTimeImmutable($valid) >= new \DateTimeImmutable();
        } catch (\DateMalformedStringException) {
            return false;
        }
    }

    /**
     * Validate and persist the consent decisions submitted from the page. Returns a
     * translation key on validation failure, null on success. Mirrors the logic of
     * the former ConsentLinkController::sign(): nothing is written unless the whole
     * set is complete, then a held application is released into allocation.
     */
    private function signConsents(Attendance $attendance, Request $request): ?string
    {
        $participant = $attendance->getParticipant();
        if (null === $participant) {
            return 'consentSign.error.incomplete';
        }

        $signedBy = trim((string) $request->request->get('signedBy'));
        if ('' === $signedBy) {
            return 'consentSign.error.nameRequired';
        }

        $submitted = $request->request->all('decisions');
        $states = array_values($this->consentManager->forOfferAndParticipants($attendance->getOffer(), [$participant]));

        // Validate first — nothing is persisted unless the set is complete.
        $decisions = [];
        foreach ($states as $state) {
            // Consents the team already recorded on behalf of the guardian are no
            // longer outstanding and carry no input on the page — skip them so a
            // partially team-signed set (admin "grant" + "leave open" mix) can be
            // completed instead of failing as "incomplete".
            if (!$state->isOutstanding()) {
                continue;
            }

            $form = $state->getConsentForm();
            $decision = $submitted[(string) $form->getId()] ?? null;
            $decision = \in_array($decision, ['1', '0'], true) ? $decision : null;

            // Unchecked checkboxes are not submitted: opt-in/opt-out default to "no".
            $decision ??= match ($form->getCompulsory()) {
                'opt-in', 'opt-out' => '0',
                default => null,
            };

            if (null === $decision || ('compulsory' === $form->getCompulsory() && '1' !== $decision)) {
                return 'consentSign.error.incomplete';
            }

            $decisions[] = [$form, $decision];
        }

        foreach ($decisions as [$form, $decision]) {
            $state = '1' === $decision
                ? $this->consentManager->sign($form, null, $participant)
                : $this->consentManager->decline($form, null, $participant);

            $state->getSignature()?->setSignedBy($signedBy);
        }

        // Releases a held application into the normal allocation (and flushes the
        // signee names set above).
        $this->attendanceFacade->confirmConsents($attendance);

        return null;
    }
}
