<?php

declare(strict_types=1);

namespace App\Service\Flow;

use App\Utilities\File;
use GuzzleHttp\Psr7\LazyOpenStream;
use InvalidArgumentException;
use JsonSerializable;
use League\MimeTypeDetection\FinfoMimeTypeDetector;
use League\MimeTypeDetection\GeneratedExtensionToMimeTypeMap;
use Normalizer;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use RuntimeException;
use Symfony\Component\Filesystem\Path;

final class UploadedFile implements UploadedFileInterface, JsonSerializable
{
    private string $clientFilename;

    private string $clientFullPath;

    private string $file;

    private bool $moved = false;

    public function __construct(
        ?string $clientFilename,
        ?string $uploadedPath,
        ?string $tempDir
    ) {
        $tempDir ??= sys_get_temp_dir();
        $clientFilename ??= tempnam($tempDir, 'upload');

        if (!$clientFilename || !$tempDir) {
            throw new RuntimeException('Could not generate original filename.');
        }

        $this->clientFullPath = self::filterClientPath($clientFilename);
        $this->clientFilename = basename($this->clientFullPath);

        if (null === $uploadedPath) {
            $prefix = substr(bin2hex(random_bytes(5)), 0, 9);
            $this->file = $tempDir . '/' . $prefix . '_' . $this->clientFilename;
        } else {
            $uploadedPath = Path::canonicalize($uploadedPath);

            if (
                !Path::isBasePath(
                    $tempDir,
                    $uploadedPath
                )
            ) {
                throw new InvalidArgumentException('Uploaded path is not inside specified temporary directory.');
            }

            if (!is_file($uploadedPath)) {
                throw new InvalidArgumentException(sprintf('File does not exist at path: %s', $uploadedPath));
            }

            $this->file = $uploadedPath;
        }
    }

    public function getClientFilename(): string
    {
        return $this->clientFilename;
    }

    public function getClientFullPath(): string
    {
        return $this->clientFullPath;
    }

    public function getUploadedPath(): string
    {
        return $this->file;
    }

    public function getSize(): int
    {
        $this->validateActive();

        $size = filesize($this->file);
        if (false === $size) {
            throw new RuntimeException('Could not get file size of uploaded path.');
        }

        return $size;
    }

    public function readAndDeleteUploadedFile(): string
    {
        $this->validateActive();

        $contents = file_get_contents($this->file);
        $this->delete();

        return $contents ?: '';
    }

    public function delete(): void
    {
        $this->validateActive();

        @unlink($this->file);
        $this->moved = true;
    }

    public function getClientMediaType(): string
    {
        $this->validateActive();

        $fileMimeType = (new FinfoMimeTypeDetector())->detectMimeTypeFromFile($this->file);

        if ('application/octet-stream' === $fileMimeType || null === $fileMimeType) {
            $extensionMap = new GeneratedExtensionToMimeTypeMap();
            $extension = strtolower(pathinfo($this->file, PATHINFO_EXTENSION));

            $fileMimeType = $extensionMap->lookupMimeType($extension) ?? 'application/octet-stream';
        }

        return $fileMimeType;
    }

    public function getStream(): StreamInterface
    {
        $this->validateActive();

        return new LazyOpenStream($this->file, 'r+');
    }

    public function moveTo($targetPath): void
    {
        $this->validateActive();

        $this->moved = rename($this->file, $targetPath);

        if (!$this->moved) {
            throw new RuntimeException(
                sprintf('Uploaded file could not be moved to %s', $targetPath)
            );
        }
    }

    public function getError(): int
    {
        return UPLOAD_ERR_OK;
    }

    private function validateActive(): void
    {
        if ($this->moved) {
            throw new RuntimeException('Cannot retrieve stream after it has already been moved');
        }
    }

    /** @return mixed[] */
    public function jsonSerialize(): array
    {
        return [
            'originalFilename' => $this->clientFilename,
            'uploadedPath' => $this->file,
        ];
    }

    public static function fromArray(array $input, string $tempDir): self
    {
        if (!isset($input['originalFilename'], $input['uploadedPath'])) {
            throw new InvalidArgumentException('Uploaded file array is malformed.');
        }

        return new self($input['originalFilename'], $input['uploadedPath'], $tempDir);
    }

    public static function filterClientPath(string $name): string
    {
        $name = implode(
            '/',
            array_filter(
                array_map(
                    function (string $namePart): string|null {
                        if (empty($namePart) || '..' === $namePart) {
                            return null;
                        }

                        $normalizedName = Normalizer::normalize($namePart, Normalizer::FORM_KD);
                        if (false !== $normalizedName) {
                            $namePart = $normalizedName;
                        }

                        return File::sanitizeFileName($namePart);
                    },
                    explode('/', Path::canonicalize($name))
                )
            )
        );

        // Truncate filenames whose lengths are longer than 255 characters, while preserving extension.
        $thresholdLength = 255 - 10; // To allow for a prefix.
        if (strlen($name) > $thresholdLength) {
            $fileExt = pathinfo($name, PATHINFO_EXTENSION);
            $fileName = pathinfo($name, PATHINFO_FILENAME);
            $fileName = substr($fileName, 0, $thresholdLength - 1 - strlen($fileExt));
            $name = $fileName . '.' . $fileExt;
        }

        return $name;
    }
}
