<?php

declare(strict_types=1);

namespace Grav\Plugin\Api\Controllers;

use Grav\Common\Security;
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
{
    /** Maximum upload size: 64 MB */
    private const int MAX_UPLOAD_SIZE = 67_108_864;

    private ?MediaSerializer $mediaSerializer = null;

    protected function getThumbnailService(): ThumbnailService
    {
        $cacheDir = $this->grav['locator']->findResource('cache://') . '/api/thumbnails';
        return new ThumbnailService($cacheDir);
    }

    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);
        }

        // Move the file to the target directory
        $targetPath = $targetDir . '/' . $filename;
        $file->moveTo($targetPath);

        // An SVG can carry executable <script>/event-handler payloads that run
        // when the file is later served inline as image/svg+xml — a stored XSS
        // vector (GHSA-7vhm-8x52-2r5p). The extension denylist can't cover it
        // because SVG is a legitimate media type, so sanitize the markup in
        // place with core's canonical routine (honors security.sanitize_svg;
        // quarantines and throws if it can't be cleaned). This matches how the
        // admin and Form plugin upload paths already protect SVG uploads.
        //
        // Decide by what the file IS, not by what it is called: keying the gate
        // on the literal extension "svg" let the same markup through under any
        // other name a browser still renders as SVG, `.svgz` above all
        // (GHSA-66xf-ggf4-6hmc).
        if ($this->isSvgUpload($targetPath, $filename)) {
            Security::sanitizeSVG($targetPath);
        }

        return $filename;
    }

    /**
     * Whether a stored upload is SVG markup and therefore has to go through
     * the sanitizer, regardless of the name it arrived under.
     *
     * Both the name and the bytes are consulted: the name catches the ordinary
     * `.svg`/`.svgz` upload, and the content sniff catches markup parked under
     * some other extension that a browser will still render inline. A gzipped
     * SVG (`.svgz`) is deliberately reported here even though the sanitizer
     * cannot read compressed markup — it fails closed, quarantining the file
     * and rejecting the upload, which is the right answer for a format we have
     * no reason to accept (GHSA-66xf-ggf4-6hmc).
     */
    protected function isSvgUpload(string $path, string $filename): bool
    {
        if (in_array(strtolower(pathinfo($filename, PATHINFO_EXTENSION)), ['svg', 'svgz'], true)) {
            return true;
        }

        $head = @file_get_contents($path, false, null, 0, 1024);
        if ($head === false || $head === '') {
            return false;
        }

        // Gzip magic — an SVG under any name, compressed. Sniff the inflated
        // head so a renamed .svgz is caught the same way.
        if (str_starts_with($head, "\x1f\x8b")) {
            $inflated = @gzdecode((string)@file_get_contents($path));
            $head = $inflated === false ? $head : substr($inflated, 0, 1024);
        }

        return stripos($head, '<svg') !== false;
    }

    /**
     * Validate that none of a filename's extensions are on the dangerous list.
     */
    protected function validateFileExtension(string $filename): void
    {
        $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.
        $parts = array_slice(explode('.', strtolower($filename)), 1);
        foreach ($parts as $part) {
            if (in_array($part, $dangerousExtensions, true)) {
                throw new ValidationException(
                    "File extension '.{$part}' 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;
    }
}
