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

use Contao\CoreBundle\Filesystem\VirtualFilesystemException;
use Contao\CoreBundle\Filesystem\VirtualFilesystemInterface;
use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CoreBundle\Comment\InboundAttachmentPolicy;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\AttendanceExtraFile;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;

/**
 * The one way a file answer to an extra application question gets stored, moved
 * or deleted — used by the public application form, the applicant's confirmation
 * page and the admin file drawer alike, so all three share one security gate and
 * one storage layout.
 *
 * Security: the bytes come from whoever fills in the form, i.e. exactly as
 * untrusted as an inbound email attachment, so they go through the very same
 * {@see InboundAttachmentPolicy} — libmagic MIME detection against the real
 * bytes, extension↔content match, script-capable types absent from the
 * allow-list — narrowed to the kinds the admin allowed for that question and to
 * a smaller size cap ({@see ExtraQuestion::MAX_BYTES}). They are then stored in
 * the tenant-isolated S3 `inbound_attachments` filesystem next to those
 * attachments, and only ever served back through a controller that forces a
 * download (or renders a verified image/PDF under a locked-down CSP).
 */
class ExtraFileManager
{
    /**
     * Everything lands under this prefix inside the shared bucket, next to the
     * inbound email attachments' `conversation/`. Both domains owning a named
     * prefix is what makes per-domain lifecycle rules and bucket policies
     * expressible at all — S3 matches those by prefix and has no exclude
     * operator — and it matters here because the two hold quite different data:
     * documents families upload about their children on one side, host
     * correspondence on the other.
     *
     * Objects are never moved when this changes: every row stores its own path.
     */
    private const PREFIX = 'application';

    public function __construct(
        private readonly InboundAttachmentPolicy $policy,
        #[Autowire(service: 'contao.filesystem.virtual.inbound_attachments')]
        private readonly VirtualFilesystemInterface $storage,
        private readonly EntityManagerInterface $em,
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * Validate + store an upload. The row is created immediately (staged, i.e.
     * without an attendance, when the application does not exist yet) so the
     * caller can render and download it before the form is submitted.
     *
     * @param list<string> $kinds the question's allowed file kinds
     *
     * @throws ExtraFileRejected when the file does not pass the policy
     */
    public function store(UploadedFile $file, string $question, array $kinds, ?Attendance $attendance = null): AttendanceExtraFile
    {
        if (!$file->isValid()) {
            throw new ExtraFileRejected('applicationForm.extra.file.error.upload', reason: $file->getErrorMessage());
        }

        // Check the declared size before pulling the bytes into memory; the policy
        // re-checks the real length.
        if ($file->getSize() > ExtraQuestion::MAX_BYTES) {
            throw new ExtraFileRejected('applicationForm.extra.file.error.tooLarge', ['max' => $this->maxMegabytes()]);
        }

        $bytes = $file->getContent();

        $decision = $this->policy->validate(
            $file->getClientOriginalName(),
            $bytes,
            ExtraQuestion::extensions($kinds),
            ExtraQuestion::MAX_BYTES,
        );

        if (!($decision['ok'] ?? false)) {
            $this->logger->notice('Application file upload blocked.', [
                'name' => $file->getClientOriginalName(),
                'reason' => $decision['reason'] ?? '',
            ]);

            throw new ExtraFileRejected('applicationForm.extra.file.error.rejected', ['max' => $this->maxMegabytes()], (string) ($decision['reason'] ?? ''));
        }

        $path = self::PREFIX.'/'.bin2hex(random_bytes(8)).'.'.$decision['ext'];

        try {
            $this->storage->write($path, $bytes);
        } catch (\Throwable $e) {
            $this->logger->error('Storing an application file failed: '.$e->getMessage());

            throw new ExtraFileRejected('applicationForm.extra.file.error.storage');
        }

        $extraFile = new AttendanceExtraFile($question, $path, $decision['name'], $decision['mime'], $decision['size'], $attendance);

        $this->em->persist($extraFile);

        if (null !== $attendance) {
            $attendance->setExtraValue($question, $decision['name']);
        }

        $this->em->flush();

        return $extraFile;
    }

    /**
     * Adopt a staged upload: bind it to the now-existing attendance and mirror its
     * filename into the scalar answer, so exports and lists read it like every
     * other answer. Any file the attendance already had for that question is
     * dropped — one answer, one file.
     */
    public function attach(AttendanceExtraFile $file, Attendance $attendance): void
    {
        $previous = $attendance->getExtraFile($file->getQuestion());
        if (null !== $previous && $previous !== $file) {
            $this->delete($previous, flush: false);
        }

        $file->setAttendance($attendance);
        $attendance->setExtraValue($file->getQuestion(), $file->getName());

        $this->em->flush();
    }

    /**
     * Replace (or set) the file answer of an existing attendance.
     *
     * @param list<string> $kinds
     *
     * @throws ExtraFileRejected
     */
    public function replace(Attendance $attendance, string $question, UploadedFile $file, array $kinds): AttendanceExtraFile
    {
        $previous = $attendance->getExtraFile($question);

        // Store first: a rejected upload must leave the existing answer untouched.
        $stored = $this->store($file, $question, $kinds, $attendance);

        if (null !== $previous) {
            $this->delete($previous);
        }

        return $stored;
    }

    /**
     * Drop a file answer — the row, the stored bytes and the scalar answer.
     */
    public function delete(AttendanceExtraFile $file, bool $flush = true): void
    {
        $attendance = $file->getAttendance();
        if (null !== $attendance) {
            // Clear the scalar answer only when this file IS the current one:
            // replacing stores the new file first, so the answer already names it
            // and must survive the old row going away.
            $isCurrent = $file === $attendance->getExtraFile($file->getQuestion());

            $attendance->removeExtraFile($file);

            if ($isCurrent) {
                $attendance->setExtraValue($file->getQuestion(), null);
            }
        }

        // The bytes are removed by AttendanceExtraFileListener on postRemove, so
        // the DB row and the object stay the single source of truth here.
        $this->em->remove($file);

        if ($flush) {
            $this->em->flush();
        }
    }

    /**
     * The file answering the question a URL names.
     *
     * Links carry the question id base58 ({@see ExtraQuestion::urlId()}) while the
     * offer stores it RFC 4122, so a lookup straight off the URL segment silently
     * finds nothing. Both forms resolve here — including the arbitrary string a
     * question predating the uuid normalisation still carries.
     */
    public function findByUrlId(Attendance $attendance, string $questionUrlId): ?AttendanceExtraFile
    {
        return $attendance->getExtraFile(ExtraQuestion::fromUrlId($questionUrlId))
            ?? $attendance->getExtraFile($questionUrlId);
    }

    /**
     * @return resource
     */
    public function readStream(AttendanceExtraFile $file)
    {
        return $this->storage->readStream($file->getPath());
    }

    /**
     * Remove the stored bytes. Called from the Doctrine listener after the row is
     * gone; a missing object is not an error (it may have been pruned already).
     */
    public function deleteBytes(AttendanceExtraFile $file): void
    {
        try {
            $this->storage->delete($file->getPath());
        } catch (VirtualFilesystemException $e) {
            $this->logger->warning('Deleting an application file failed: '.$e->getMessage(), ['path' => $file->getPath()]);
        }
    }

    private function maxMegabytes(): int
    {
        return intdiv(ExtraQuestion::MAX_BYTES, 1024 * 1024);
    }
}
