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

use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\Validator\Constraints\File;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * The security gate for files arriving from outside — inbound email (anyone who
 * can send a mail to a reply address) and the file answers uploaded through the
 * application form ({@see \Ferienpass\CoreBundle\Application\ExtraFileManager}).
 * Both are untrusted input and pass exactly the same checks; the upload path only
 * narrows the allow-list to the kinds the question permits.
 *
 * The size + type check is delegated to Symfony's {@see File} constraint (the
 * same one the offer/media upload forms use): its MIME check reads the real
 * bytes with libmagic, ignoring the sender's claimed `Content-Type`, so an
 * executable renamed `invoice.pdf` is rejected. We scope `mimeTypes` per
 * extension, so the claimed extension must also match the content — a `.pdf`
 * that is really a zip does not pass. Script-capable types (html, svg, js, …)
 * are simply absent from the allow-list and never stored. Serving then forces a
 * download with `nosniff` (see the attachment controller).
 *
 * The only things kept in-house are what the constraint does not return: the
 * extension allow-list, the canonical MIME to store (so a docx is not served as
 * the `application/zip` libmagic reports), and a sanitized filename.
 *
 * Residual risk: Office files are legitimate but can carry macros — this cannot
 * strip those, so a virus scan (e.g. ClamAV) is the sensible next layer.
 */
final class InboundAttachmentPolicy
{
    public const MAX_BYTES = 15 * 1024 * 1024;
    public const MAX_COUNT = 10;

    /**
     * extension => [canonical MIME to store, [detected MIME types accepted for it]].
     * The accepted list is what libmagic may report for that extension (ZIP for
     * OOXML/ODF containers, OLE for legacy Office) and is handed to the File
     * constraint; the extension gate keeps a bare `.zip` out.
     *
     * @var array<string, array{0: string, 1: list<string>}>
     */
    private const ALLOWED = [
        'pdf' => ['application/pdf', ['application/pdf']],
        'jpg' => ['image/jpeg', ['image/jpeg']],
        'jpeg' => ['image/jpeg', ['image/jpeg']],
        'png' => ['image/png', ['image/png']],
        'gif' => ['image/gif', ['image/gif']],
        'webp' => ['image/webp', ['image/webp']],
        'heic' => ['image/heic', ['image/heic', 'image/heif']],
        'txt' => ['text/plain', ['text/plain']],
        'csv' => ['text/csv', ['text/plain', 'text/csv']],
        'doc' => ['application/msword', ['application/msword', 'application/x-ole-storage', 'application/vnd.ms-office']],
        'xls' => ['application/vnd.ms-excel', ['application/vnd.ms-excel', 'application/x-ole-storage', 'application/vnd.ms-office']],
        'ppt' => ['application/vnd.ms-powerpoint', ['application/vnd.ms-powerpoint', 'application/x-ole-storage', 'application/vnd.ms-office']],
        'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip']],
        'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip']],
        'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation', ['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/zip']],
        'odt' => ['application/vnd.oasis.opendocument.text', ['application/vnd.oasis.opendocument.text', 'application/zip']],
        'ods' => ['application/vnd.oasis.opendocument.spreadsheet', ['application/vnd.oasis.opendocument.spreadsheet', 'application/zip']],
    ];

    public function __construct(private readonly ValidatorInterface $validator)
    {
    }

    /**
     * @param list<string>|null $allowedExtensions narrows the allow-list to these extensions (null = the full one)
     * @param int|null          $maxBytes          overrides {@see self::MAX_BYTES}
     *
     * @return array{ok: bool, reason?: string, name?: string, mime?: string, ext?: string, size?: int}
     */
    public function validate(string $filename, string $bytes, ?array $allowedExtensions = null, ?int $maxBytes = null): array
    {
        if ('' === $bytes) {
            return ['ok' => false, 'reason' => 'empty file'];
        }

        $ext = strtolower(pathinfo($filename, \PATHINFO_EXTENSION));
        if (!isset(self::ALLOWED[$ext])
            || (null !== $allowedExtensions && !\in_array($ext, $allowedExtensions, true))
        ) {
            return ['ok' => false, 'reason' => \sprintf('extension not allowed: "%s"', $ext)];
        }
        [$canonicalMime, $acceptedMimes] = self::ALLOWED[$ext];

        $tmp = tempnam(sys_get_temp_dir(), 'inbound_');
        if (false === $tmp) {
            return ['ok' => false, 'reason' => 'cannot buffer file'];
        }

        try {
            file_put_contents($tmp, $bytes);

            // The File constraint enforces the size and detects the MIME from the
            // bytes (libmagic) — not the sender's claim.
            $violations = $this->validator->validate($tmp, new File(
                maxSize: $maxBytes ?? self::MAX_BYTES,
                mimeTypes: $acceptedMimes,
            ));

            if ($violations->count() > 0) {
                return ['ok' => false, 'reason' => (string) $violations->get(0)->getMessage()];
            }

            return [
                'ok' => true,
                'name' => $this->safeName($filename, $ext),
                'mime' => $canonicalMime,
                'ext' => $ext,
                'size' => \strlen($bytes),
            ];
        } finally {
            @unlink($tmp);
        }
    }

    /**
     * A human-friendly but harmless display/download name: slugged base + the
     * validated extension, so no path separators, control chars or misleading
     * double extensions survive.
     */
    private function safeName(string $filename, string $ext): string
    {
        $base = pathinfo($filename, \PATHINFO_FILENAME);
        $slug = trim(new AsciiSlugger()->slug($base)->toString(), '-');

        return ('' !== $slug ? $slug : 'anhang').'.'.$ext;
    }
}
