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

use Contao\CoreBundle\Filesystem\VirtualFilesystemInterface;
use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CoreBundle\Application\ExtraFileManager;
use Ferienpass\CoreBundle\Application\ExtraFileRejected;
use Ferienpass\CoreBundle\Application\ExtraQuestion;
use Ferienpass\CoreBundle\Comment\InboundAttachmentPolicy;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\AttendanceExtraFile;
use Ferienpass\CoreBundle\Entity\Offer\Base;
use Ferienpass\CoreBundle\Entity\Participant\BaseParticipant;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Validator\Validation;

class ExtraFileManagerTest extends TestCase
{
    private const QUESTION = '0192d1f0-0000-7000-8000-000000000001';

    private VirtualFilesystemInterface&MockObject $storage;
    private EntityManagerInterface&MockObject $em;

    protected function setUp(): void
    {
        $this->storage = $this->createMock(VirtualFilesystemInterface::class);
        $this->em = $this->createMock(EntityManagerInterface::class);
    }

    public function testStoresAValidUploadAndMirrorsTheFilenameIntoTheScalarAnswer(): void
    {
        $attendance = new Attendance(new Base(), new BaseParticipant());

        $this->storage->expects(self::once())->method('write')
            ->with(self::matchesRegularExpression('#^application/[0-9a-f]{16}\.png$#'), self::isString());

        $file = $this->manager()->store($this->upload('Ferien Foto.png'), self::QUESTION, ['image'], $attendance);

        self::assertSame('Ferien-Foto.png', $file->getName());
        self::assertSame('image/png', $file->getMimeType());
        self::assertSame(self::QUESTION, $file->getQuestion());
        self::assertSame($attendance, $file->getAttendance());
        // Exports and lists read the answer, not the relation.
        self::assertSame('Ferien-Foto.png', $attendance->getExtra(self::QUESTION));
        self::assertSame($file, $attendance->getExtraFile(self::QUESTION));
    }

    public function testStoresStagedWhenNoAttendanceExistsYet(): void
    {
        $file = $this->manager()->store($this->upload('nachweis.png'), self::QUESTION, ['image']);

        self::assertNull($file->getAttendance());
    }

    public function testRejectsAKindTheQuestionDoesNotAllowWithoutStoringAnything(): void
    {
        $this->storage->expects(self::never())->method('write');

        $this->expectException(ExtraFileRejected::class);

        // A PDF-only question must not take the photo, even though the shared
        // allow-list has PNG.
        $this->manager()->store($this->upload('foto.png'), self::QUESTION, ['pdf']);
    }

    public function testRejectsAnOversizedUploadBeforeReadingIt(): void
    {
        $this->storage->expects(self::never())->method('write');

        $path = tempnam(sys_get_temp_dir(), 'extra_file_test_');
        file_put_contents($path, str_repeat('x', 32));

        // A "declared" size beyond the cap — as reported for a real large upload.
        $upload = new class($path, 'gross.png') extends UploadedFile {
            public function __construct(string $path, string $name)
            {
                parent::__construct($path, $name, 'image/png', null, true);
            }

            public function getSize(): int
            {
                return ExtraQuestion::MAX_BYTES + 1;
            }
        };

        try {
            $this->expectException(ExtraFileRejected::class);
            $this->manager()->store($upload, self::QUESTION, ['image']);
        } finally {
            @unlink($path);
        }
    }

    public function testReplaceRemovesThePreviousFileAndItsAnswer(): void
    {
        $attendance = new Attendance(new Base(), new BaseParticipant());
        $previous = new AttendanceExtraFile(self::QUESTION, 'application/old.png', 'alt.png', 'image/png', 10, $attendance);
        $attendance->setExtraValue(self::QUESTION, 'alt.png');

        $removed = [];
        $this->em->method('remove')->willReturnCallback(static function (object $entity) use (&$removed): void {
            $removed[] = $entity;
        });

        $new = $this->manager()->replace($attendance, self::QUESTION, $this->upload('neu.png'), ['image']);

        self::assertSame([$previous], $removed);
        self::assertSame('neu.png', $attendance->getExtra(self::QUESTION));
        self::assertSame($new, $attendance->getExtraFile(self::QUESTION));
    }

    public function testDeleteClearsTheScalarAnswerAndRemovesTheRow(): void
    {
        $attendance = new Attendance(new Base(), new BaseParticipant());
        $file = new AttendanceExtraFile(self::QUESTION, 'application/a.png', 'a.png', 'image/png', 10, $attendance);
        $attendance->setExtraValue(self::QUESTION, 'a.png');

        $this->em->expects(self::once())->method('remove')->with($file);

        $this->manager()->delete($file);

        self::assertNull($attendance->getExtra(self::QUESTION));
        self::assertNull($attendance->getExtraFile(self::QUESTION));
    }

    public function testAttachAdoptsAStagedFileAndDropsAnyEarlierOne(): void
    {
        $attendance = new Attendance(new Base(), new BaseParticipant());
        $previous = new AttendanceExtraFile(self::QUESTION, 'application/old.png', 'alt.png', 'image/png', 10, $attendance);
        $staged = new AttendanceExtraFile(self::QUESTION, 'application/new.png', 'neu.png', 'image/png', 10);

        $this->em->expects(self::once())->method('remove')->with($previous);

        $this->manager()->attach($staged, $attendance);

        self::assertSame($attendance, $staged->getAttendance());
        self::assertSame('neu.png', $attendance->getExtra(self::QUESTION));
        self::assertSame($staged, $attendance->getExtraFile(self::QUESTION));
    }

    public function testFindsTheFileByTheBase58IdThatLinksCarry(): void
    {
        $attendance = new Attendance(new Base(), new BaseParticipant());
        $file = new AttendanceExtraFile(self::QUESTION, 'application/a.png', 'a.png', 'image/png', 10, $attendance);

        // What a URL actually carries. Looking the answer up by this value against
        // the stored RFC 4122 id finds nothing — which is how every file download
        // 404'd while the drawer around it rendered fine.
        $urlId = ExtraQuestion::urlId(self::QUESTION);

        self::assertNotSame(self::QUESTION, $urlId, 'precondition: the two id forms differ');
        self::assertSame($file, $this->manager()->findByUrlId($attendance, $urlId));
    }

    public function testFindsTheFileByTheStoredIdToo(): void
    {
        $attendance = new Attendance(new Base(), new BaseParticipant());
        $file = new AttendanceExtraFile(self::QUESTION, 'application/a.png', 'a.png', 'image/png', 10, $attendance);

        // Links handed out before the switch to base58 carry the stored form.
        self::assertSame($file, $this->manager()->findByUrlId($attendance, self::QUESTION));
    }

    public function testFindsNothingForAQuestionWithoutAFile(): void
    {
        $attendance = new Attendance(new Base(), new BaseParticipant());

        self::assertNull($this->manager()->findByUrlId($attendance, ExtraQuestion::urlId(self::QUESTION)));
    }

    private function manager(): ExtraFileManager
    {
        return new ExtraFileManager(
            new InboundAttachmentPolicy(Validation::createValidator()),
            $this->storage,
            $this->em,
            new NullLogger(),
        );
    }

    private function upload(string $name): UploadedFile
    {
        $path = tempnam(sys_get_temp_dir(), 'extra_file_test_');
        file_put_contents($path, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', true));

        // test: true — no upload actually happened, the bytes are on disk already.
        return new UploadedFile($path, $name, 'image/png', null, true);
    }
}
