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

use Contao\CoreBundle\Filesystem\VirtualFilesystemException;
use Ferienpass\AdminBundle\Routing\Base58;
use Ferienpass\CoreBundle\Application\ExtraFileManager;
use Ferienpass\CoreBundle\Application\ExtraQuestion;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\AttendanceExtraFile;
use Ferienpass\CoreBundle\Repository\AttendanceRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;

/**
 * The file a participant uploaded as the answer to a `file` extra question: one
 * drawer that views and replaces it (mirroring the media file drawer), plus the
 * two endpoints that hand out the bytes.
 *
 * Access follows the participant list this file is reached from — the
 * `participants.view` voter, so a Veranstalter sees the files of their own offer's
 * applications and nothing else. Replacing needs `participants.edit` (enforced in
 * the live component).
 *
 * The bytes are untrusted uploads, so `download` always forces a download as
 * `application/octet-stream`, and `preview` only ever renders the two kinds we
 * verified the content of (image, PDF), under a CSP that permits the document no
 * outbound request of its own.
 */
#[IsGranted('ROLE_USER')]
final class AttendanceExtraFileController extends AbstractController
{
    public function __construct(
        private readonly AttendanceRepository $attendances,
        private readonly ExtraFileManager $files,
    ) {
    }

    #[Route('/anmeldungen/{uuid}/datei/{question}', name: 'admin_attendance_file', requirements: ['uuid' => Base58::UUID], methods: ['GET'])]
    public function drawer(string $uuid, string $question): Response
    {
        [$attendance, $definition] = $this->resolve($uuid, $question);

        return $this->render('@FerienpassAdmin/page/attendances/file.html.twig', [
            'attendance' => $attendance,
            'question' => $definition,
        ]);
    }

    #[Route('/anmeldungen/{uuid}/datei/{question}/download', name: 'admin_attendance_file_download', requirements: ['uuid' => Base58::UUID], methods: ['GET'])]
    public function download(string $uuid, string $question): Response
    {
        [$attendance, $definition] = $this->resolve($uuid, $question);

        return $this->streamFile($this->requireFile($attendance, $definition), inline: false);
    }

    #[Route('/anmeldungen/{uuid}/datei/{question}/vorschau', name: 'admin_attendance_file_preview', requirements: ['uuid' => Base58::UUID], methods: ['GET'])]
    public function preview(string $uuid, string $question): Response
    {
        [$attendance, $definition] = $this->resolve($uuid, $question);
        $file = $this->requireFile($attendance, $definition);

        if (!$file->isImage() && !$file->isPdf()) {
            throw $this->createNotFoundException();
        }

        return $this->streamFile($file, inline: true);
    }

    /**
     * @return array{0: Attendance, 1: array{uuid: string, urlId: string, question: string, type: string, required: bool, fileTypes: list<string>}}
     */
    private function resolve(string $uuid, string $question): array
    {
        try {
            $id = Uuid::fromBase58($uuid);
        } catch (\InvalidArgumentException) {
            throw $this->createNotFoundException();
        }

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

        $this->denyAccessUnlessGranted('participants.view', $attendance->getOffer());

        // The id travels base58 (ExtraQuestion::urlId); compare against both forms so
        // the raw value of a legacy, non-uuid question id resolves too.
        $stored = ExtraQuestion::fromUrlId($question);

        // The question must be one of the offer's, and it must be a file question —
        // otherwise there is nothing here to view or replace.
        foreach ($attendance->getOffer()->getApplicationExtra() ?? [] as $definition) {
            $questionId = (string) ($definition['uuid'] ?? '');
            if ($questionId !== $stored && $questionId !== $question) {
                continue;
            }

            if (ExtraQuestion::TYPE_FILE !== ExtraQuestion::type($definition['type'] ?? null)) {
                break;
            }

            return [$attendance, [
                'uuid' => $questionId,
                // The id as it travels in a URL, so the drawer's own links (preview,
                // download) do not fall back to the stored RFC 4122 form.
                'urlId' => ExtraQuestion::urlId($questionId),
                'question' => (string) ($definition['question'] ?? ''),
                'type' => ExtraQuestion::TYPE_FILE,
                'required' => (bool) ($definition['required'] ?? false),
                'fileTypes' => ExtraQuestion::kinds($definition['fileTypes'] ?? null),
            ]];
        }

        throw $this->createNotFoundException();
    }

    /**
     * Takes the RESOLVED definition, not the raw route segment: the two id forms
     * are both plain strings, and passing the URL one here looked the file up by a
     * value the offer never stores — every download 404'd while the drawer around
     * it rendered fine.
     *
     * @param array{uuid: string} $question
     */
    private function requireFile(Attendance $attendance, array $question): AttendanceExtraFile
    {
        $file = $attendance->getExtraFile($question['uuid']);
        if (!$file instanceof AttendanceExtraFile) {
            throw $this->createNotFoundException();
        }

        return $file;
    }

    private function streamFile(AttendanceExtraFile $file, bool $inline): Response
    {
        try {
            $stream = $this->files->readStream($file);
        } catch (VirtualFilesystemException) {
            throw $this->createNotFoundException();
        }

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

        $name = $file->getName();

        // Inline rendering is limited to content we verified byte-wise (image /
        // PDF); everything else — and every download — leaves as an opaque blob.
        $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 document may not fetch anything of its own — no beacons out of a
        // crafted PDF, no external references from an image.
        $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',
        ));

        return $response;
    }
}
