<?php
declare(strict_types=1);

namespace App\Media;

use App\Support\TranslatableException;

/**
 * Stores an administrator-supplied branding image inside the web root.
 *
 * The trust boundary matters here. Quaeriva refuses browser upload of anything
 * executable; an image is data, not code, so it is allowed -- but only after
 * the bytes are confirmed to be an image, the extension is rewritten from what
 * the decoder actually found, and the destination directory is configured to
 * never execute a script. A file named `logo.php.png` therefore lands as a
 * `.png` in a directory that cannot run PHP even if it could be renamed.
 */
final class ImageUploadService
{
    public const MAX_BYTES = 2097152; // 2 MB is generous for a logo or favicon.

    /** Extension is decided by the detected type, never by the uploaded name. */
    private const ALLOWED = [
        IMAGETYPE_PNG => 'png',
        IMAGETYPE_JPEG => 'jpg',
        IMAGETYPE_GIF => 'gif',
        IMAGETYPE_WEBP => 'webp',
        IMAGETYPE_ICO => 'ico',
    ];

    public function __construct(private readonly string $subdirectory = 'branding')
    {
    }

    /**
     * @param array<string,mixed> $file One entry from $_FILES.
     * @return string A root-relative URL, or '' when no file was submitted.
     */
    public function store(array $file): string
    {
        $error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
        if ($error === UPLOAD_ERR_NO_FILE) {
            return '';
        }
        if ($error !== UPLOAD_ERR_OK) {
            throw new TranslatableException('admin.upload_failed');
        }

        $size = (int) ($file['size'] ?? 0);
        if ($size <= 0 || $size > self::MAX_BYTES) {
            throw new TranslatableException('admin.upload_too_large');
        }

        $tmp = (string) ($file['tmp_name'] ?? '');
        if ($tmp === '' || !is_uploaded_file($tmp)) {
            throw new TranslatableException('admin.upload_failed');
        }

        $extension = self::resolveExtension($tmp);

        $directory = app_public_path('uploads', $this->subdirectory);
        if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) {
            throw new TranslatableException('admin.upload_failed');
        }
        $this->protectDirectory(app_public_path('uploads'));

        $name = bin2hex(random_bytes(16)) . '.' . $extension;
        $target = $directory . DIRECTORY_SEPARATOR . $name;
        if (!@move_uploaded_file($tmp, $target)) {
            throw new TranslatableException('admin.upload_failed');
        }
        @chmod($target, 0644);

        // Root-relative through the URL helper so a subdirectory install works.
        return quaeriva_url('uploads/' . $this->subdirectory . '/' . $name);
    }

    /**
     * The decoder is the authority: a file that does not parse as one of the
     * allowed image types is rejected whatever its name or declared MIME says.
     *
     * Kept separate from store() so the rule can be exercised directly; store()
     * still refuses anything that did not arrive through a real upload.
     */
    public static function resolveExtension(string $path): string
    {
        $info = @getimagesize($path);
        $type = is_array($info) ? (int) ($info[2] ?? 0) : 0;
        if (!isset(self::ALLOWED[$type])) {
            throw new TranslatableException('admin.upload_invalid_image');
        }

        return self::ALLOWED[$type];
    }

    /**
     * Uploaded files must never be executed, even if something later renames one.
     * The rule ships with the directory instead of relying on the operator.
     */
    private function protectDirectory(string $directory): void
    {
        $htaccess = $directory . DIRECTORY_SEPARATOR . '.htaccess';
        if (is_file($htaccess)) {
            return;
        }
        @file_put_contents($htaccess, implode("\n", [
            '# Uploaded media is data, never code.',
            'php_flag engine off',
            '<FilesMatch "\.(php|phar|phtml|pl|py|cgi|asp|aspx|jsp|sh)$">',
            '  Require all denied',
            '</FilesMatch>',
            'Options -ExecCGI -Indexes',
            'AddType text/plain .php .phtml .phar',
            '',
        ]));
    }
}
