<?php

declare(strict_types=1);

namespace Grav\Plugin\Api\Controllers;

use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Serializers\MediaSerializer;
use Grav\Plugin\Api\Services\ThumbnailService;
use Grav\Plugin\Api\Services\UploadFieldSettings;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;

/**
 * Shared file-upload pipeline for media endpoints: validating uploads,
 * moving them into a target folder, and serializing the resulting media.
 *
 * The logic is storage-agnostic — it only needs a resolved filesystem
 * directory to write into. That makes it reusable for any object that can
 * yield a media folder: a page (its content folder) or a folder-stored Flex
 * object (its storage folder, e.g. user-data://flex-objects/contacts/{id}).
 *
 * Used by MediaController (pages + site media) and by the flex-objects
 * plugin's FlexApiController via the shared AbstractApiController base.
 */
trait HandlesMediaUploads
{
    use StoresMediaUploads;
    /** Maximum upload size: 64 MB */
    private const int MAX_UPLOAD_SIZE = 67_108_864;

    private ?MediaSerializer $mediaSerializer = null;

    protected function getThumbnailService(): ThumbnailService
    {
        return ThumbnailService::forGrav($this->grav);
    }

    protected function getSerializer(): MediaSerializer
    {
        if (!$this->mediaSerializer) {
            $thumbnailService = $this->getThumbnailService();
            $baseUrl = $this->getApiBaseUrl();
            $this->mediaSerializer = new MediaSerializer($thumbnailService, $baseUrl);
        }
        return $this->mediaSerializer;
    }

    /**
     * Extract and validate a safe filename from the route parameters.
     */
    protected function getSafeFilename(ServerRequestInterface $request): string
    {
        $filename = $this->getRouteParam($request, 'filename');

        if ($filename === null || $filename === '') {
            throw new ValidationException('Filename is required.');
        }

        $filename = basename($filename);

        if (
            str_contains($filename, '..')
            || str_contains($filename, "\0")
            || str_starts_with($filename, '.')
        ) {
            throw new ValidationException('Invalid filename.');
        }

        return $filename;
    }

    /**
     * Parse blueprint file-field upload settings (random_name, avoid_overwriting,
     * accept, filesize) from a request's form fields. Absent settings yield an
     * inert object, so callers without field context keep current behavior.
     */
    protected function parseUploadFieldSettings(ServerRequestInterface $request): UploadFieldSettings
    {
        $body = $request->getParsedBody();

        return is_array($body) ? UploadFieldSettings::fromParams($body) : UploadFieldSettings::none();
    }

    /**
     * Process a single uploaded file: validate it and move to the target directory.
     *
     * Optional per-field $settings (from a blueprint `type: file` field) layer
     * filename randomization, overwrite avoidance, an accept allowlist, and a
     * per-field size limit *on top of* the immovable security floor enforced
     * here (size cap, traversal guard, dangerous-extension denylist).
     *
     * Returns the safe filename that was written.
     */
    protected function processUploadedFile(
        UploadedFileInterface $file,
        string $targetDir,
        ?UploadFieldSettings $settings = null,
    ): string {
        // Check for upload errors
        if ($file->getError() !== UPLOAD_ERR_OK) {
            $message = match ($file->getError()) {
                UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File exceeds maximum upload size.',
                UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.',
                UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
                UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder.',
                UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
                default => 'Unknown upload error.',
            };
            throw new ValidationException($message);
        }

        // Validate file size against the hard cap, then the per-field limit.
        $size = $file->getSize();
        if ($size !== null && $size > self::MAX_UPLOAD_SIZE) {
            throw new ValidationException(
                sprintf('File exceeds maximum allowed size of %d MB.', self::MAX_UPLOAD_SIZE / 1_048_576)
            );
        }
        $settings?->assertFilesize($size);

        // Sanitize the filename
        $originalName = $file->getClientFilename() ?? 'upload';
        $filename = basename($originalName);

        if (
            str_contains($filename, '..')
            || str_contains($filename, "\0")
            || str_starts_with($filename, '.')
        ) {
            throw new ValidationException("Invalid filename: '{$filename}'.");
        }

        // Validate extension against dangerous extensions list, then the
        // field's accept allowlist (matched on the original name's extension).
        $this->validateFileExtension($filename);
        $settings?->assertAccepted($filename);

        // Apply random_name / avoid_overwriting last — both preserve the
        // already-validated extension, so the floor checks above still hold.
        if ($settings !== null) {
            $filename = $settings->resolveFilename($filename, $targetDir);
        }

        $this->storeUploadedFile($file, $targetDir . '/' . $filename, $filename);

        return $filename;
    }

    /**
     * Validate that none of a filename's extensions are on the dangerous list.
     *
     * Also refuses names with control or invisible format characters, or that
     * end in whitespace or a dot: some filesystems and servers strip those, so
     * the stored name would not be the one checked here.
     */
    protected function validateFileExtension(string $filename): void
    {
        if (preg_match('/[\p{Cc}\p{Cf}]/u', $filename) !== 0 || preg_match('/[\s\p{Z}.]$/u', $filename) !== 0) {
            throw new ValidationException('Invalid filename.');
        }

        $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));

        if ($extension === '') {
            throw new ValidationException('Uploaded file must have a file extension.');
        }

        $dangerousExtensions = array_map(
            'strtolower',
            $this->config->get('security.uploads_dangerous_extensions', [])
        );

        // Check EVERY dot-separated component, not just the last one. A name like
        // "shell.php.jpg" passes a last-extension check (".jpg") yet the web
        // server may still execute the earlier ".php" — the classic double-
        // extension RCE (GHSA-66v2-vxxf-xc3v). The basename (first segment) is
        // skipped; only the trailing extension chain is inspected. Each part is
        // also checked URL-decoded and trimmed, so `php%00` or `php%20` can't
        // slip past as a name the denylist doesn't recognize.
        $parts = array_slice(explode('.', strtolower($filename)), 1);
        foreach ($parts as $part) {
            $decoded = strtolower(trim(rawurldecode($part)));
            if (in_array($part, $dangerousExtensions, true) || in_array($decoded, $dangerousExtensions, true)) {
                throw new ValidationException(
                    "File extension '.{$decoded}' is not allowed for security reasons."
                );
            }
        }
    }

    /**
     * Flatten a potentially nested array of uploaded files into a flat list.
     *
     * PSR-7 allows uploaded files to be nested (e.g. files[avatar], files[gallery][]).
     *
     * @return UploadedFileInterface[]
     */
    protected function flattenUploadedFiles(array $files): array
    {
        $result = [];

        foreach ($files as $file) {
            if ($file instanceof UploadedFileInterface) {
                $result[] = $file;
            } elseif (is_array($file)) {
                $result = [...$result, ...$this->flattenUploadedFiles($file)];
            }
        }

        return $result;
    }
}
