<?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 Contao\CoreBundle\Filesystem\VirtualFilesystemException;
use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CmsBundle\Controller\AbstractController;
use Ferienpass\CmsBundle\Fragment\FragmentReference;
use Ferienpass\CoreBundle\Application\ExtraFileManager;
use Ferienpass\CoreBundle\Application\ExtraFileRejected;
use Ferienpass\CoreBundle\Application\ExtraQuestion;
use Ferienpass\CoreBundle\ConsentManager\ConsentManager;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\AttendanceExtraFile;
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\File\UploadedFile;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
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}).
 *
 * All three may also correct the answers to the offer's extra questions — including
 * re-taking or replacing an uploaded photo/PDF — for as long as the application is
 * live ({@see self::saveAnswers()}). Families notice a wrong or unreadable upload
 * on this page, and sending them back through the whole application form to fix one
 * photo is how a missing certificate ends up never arriving.
 *
 * 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,
        private readonly EntityManagerInterface $entityManager,
        private readonly ExtraFileManager $extraFiles,
        private readonly RateLimiterFactoryInterface $applicationFileUploadLimiter,
    ) {
    }

    // 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;
        $answersError = 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);
                }
            }

            // Correct the answers to the extra questions, uploads included. PRG back
            // to the unchanged URL (see above) with a toast on success.
            if ('save-answers' === $action) {
                $answersError = $this->saveAnswers($attendance, $request);
                if (null === $answersError) {
                    $this->addFlash(...Flash::confirmation()
                        ->headline($this->translator->trans('attendanceShow.answers.saved.toastTitle', [], 'cms'))
                        ->text($this->translator->trans('attendanceShow.answers.saved.toast', [], 'cms'))
                        ->create());

                    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,
                'answersError' => $answersError,
            ]))
            ->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);
    }

    /**
     * Hand out one uploaded file answer to whoever may see this application: inline
     * for the two kinds whose bytes we verified (image, PDF) so the page can show
     * it, forced download otherwise and whenever `?download` is asked for.
     *
     * Access is resolved exactly as for the page — owner, signed link or consent
     * token. Note that an `anon` visitor reaches this path with its OWN signature
     * ({@see self::fileUrl()}): a UriSigner signature covers the whole URI, so the
     * page's signature does not carry over to a different path.
     */
    #[Route('/anmeldung/{attendance}/datei/{question}', name: 'cms_attendance_file', requirements: ['attendance' => '[1-9A-HJ-NP-Za-km-z]+'], methods: ['GET'])]
    public function extraFile(string $attendance, string $question, Request $request): Response
    {
        $this->initializeContaoFramework();

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

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

        // The id travels base58 (ExtraQuestion::urlId); the raw value resolves too, so
        // a legacy non-uuid question id and any link handed out earlier still work.
        $file = $this->extraFiles->findByUrlId($attendance, $question);

        if (!$file instanceof AttendanceExtraFile) {
            throw new PageNotFoundException();
        }

        $inline = !$request->query->has('download') && ($file->isImage() || $file->isPdf());

        try {
            $stream = $this->extraFiles->readStream($file);
        } catch (VirtualFilesystemException) {
            throw new PageNotFoundException();
        }

        $response = new StreamedResponse(static function () use ($stream): void {
            stream_copy_to_stream($stream, fopen('php://output', 'w'));
        });

        $name = $file->getName();

        $response->headers->set('Content-Type', $inline ? $file->getMimeType() : 'application/octet-stream');
        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('X-Frame-Options', $inline ? 'SAMEORIGIN' : 'DENY');
        // The rendered document may not fetch anything of its own.
        $response->headers->set('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; object-src 'self'; base-uri 'none'; form-action 'none'");
        $response->headers->set('Content-Length', (string) $file->getSize());
        $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(
            $inline ? HeaderUtils::DISPOSITION_INLINE : HeaderUtils::DISPOSITION_ATTACHMENT,
            $name,
            preg_replace('/[^\x20-\x7e]|[%\/\\\\]/', '_', $name) ?: 'download',
        ));
        // A bearer credential in the URL must never end up in a shared cache.
        $response->headers->set('Referrer-Policy', 'no-referrer');

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

    /**
     * Persist corrected answers to the extra questions. Returns a translation key on
     * failure, null on success — nothing is written unless the whole set validates,
     * so a rejected upload cannot leave half-saved answers behind.
     *
     * A file question keeps its current file when no new one is submitted; a new
     * upload replaces it (the previous bytes are deleted with the row).
     */
    private function saveAnswers(Attendance $attendance, Request $request): ?string
    {
        // Nothing to correct on an application that can no longer lead anywhere —
        // mirrors what the fragment renders as editable.
        if ($attendance->isWithdrawn() || $attendance->isRejected() || $attendance->isUnfulfilled()) {
            return 'attendanceShow.answers.error.closed';
        }

        $submitted = $request->request->all('answers');
        $uploads = $request->files->get('answerFiles');
        $uploads = \is_array($uploads) ? $uploads : [];

        $extra = [];
        foreach ($attendance->getOffer()->getApplicationExtra() ?? [] as $definition) {
            $uuid = (string) ($definition['uuid'] ?? '');
            if ('' === $uuid) {
                continue;
            }

            $required = (bool) ($definition['required'] ?? false);

            if (ExtraQuestion::TYPE_FILE === ExtraQuestion::type($definition['type'] ?? null)) {
                $upload = $uploads[$uuid] ?? null;
                if ($upload instanceof UploadedFile) {
                    // Same throttle as the application form: this endpoint writes to
                    // object storage and an `anon` visitor only needs the link.
                    if (!$this->applicationFileUploadLimiter->create($request->getClientIp() ?? '')->consume()->isAccepted()) {
                        return 'applicationForm.extra.file.error.rateLimited';
                    }

                    try {
                        $this->extraFiles->replace($attendance, $uuid, $upload, $definition['fileTypes'] ?? []);
                    } catch (ExtraFileRejected $e) {
                        return $e->getTranslationKey();
                    }
                }

                $file = $attendance->getExtraFile($uuid);
                if ($file instanceof AttendanceExtraFile) {
                    $extra[$uuid] = $file->getName();
                } elseif ($required) {
                    return 'applicationForm.extra.required';
                }

                continue;
            }

            $value = trim((string) ($submitted[$uuid] ?? ''));
            if ('' === $value) {
                if ($required) {
                    return 'applicationForm.extra.required';
                }

                continue;
            }

            $extra[$uuid] = $value;
        }

        $attendance->setExtra($extra);
        $this->entityManager->flush();

        return null;
    }

    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;
    }
}
