<?php

namespace Velis\Model;

use Exception;
use Gaufrette\Exception\FileNotFound;
use Imagick;
use Phalcon\Http\Response;
use RuntimeException;
use Velis\App;
use Velis\DuplicateRequestException;
use Velis\Exception as VelisException;
use Velis\Exception\BusinessLogicException;
use Velis\Filesystem\FilesystemInterface;
use Velis\Filesystem\FilesystemTrait;
use Velis\Filter;
use Velis\Image\Processor\BulkProcessor;
use Velis\Image\Processor\Decorator;
use Velis\Lang;

/**
 * Base class for any uploaded files
 * @author Olek Procki <olo@velis.pl>
 */
abstract class File extends DataObject implements Sanitizable, FileInterface
{
    public const RECENT_HASHES_SESSION_KEY = 'recentUploadHashes';
    public const RECENT_HASHES_COUNT = 10;

    public const DOWNSIZE_DEFAULT = 200;
    public const DOWNSIZE_LARGE = 800;

    // Variant of images
    public const IMAGE_DEFAULT = '';
    public const IMAGE_SMALL = '_m';
    public const IMAGE_LARGE = '_l';
    public const IMAGE_THUMB = '_m';

    public const UNSUPPORTED_IMAGE_TYPES = [
        'image/svg+xml',
        'image/x-dwg',
        'image/vnd.dwg',
    ];

    /**
     * File contents
     * @var string
     */
    protected $_contents;


    /**
     * Rotate and create thumbnails flag
     */
    protected $_proceedImage = false;


    /**
     * @var string
     */
    protected static $_filenameField = 'filename';


    /**
     * @var string
     */
    protected static $_typeField = 'type';


    /**
     * @var FilesystemInterface
     */
    private $filesystem;


    /**
     * @return string
     */
    abstract protected function _getStorageDir();


    /**
     * @deprecated
     *
     * @return string
     */
    protected static function _getPrefixForStorageDir()
    {
        if (empty(App::$config->upload->filesystem)) {
            return App::$config->upload->alternativeDir ?: DATA_PATH;
        }

        return '';
    }


    /**
     * Overloaded constructor
     * @param mixed $data
     * @param string $contents
     * @throws DataObject\NoColumnsException
     */
    public function __construct($data = null, $contents = null)
    {
        $this->setContents($contents);
        parent::__construct($data);
    }


    /**
     * @return FilesystemInterface
     */
    protected function _getFilesystem()
    {
        if (!$this->filesystem) {
            $this->filesystem = self::_createFilesystem();
        }

        return $this->filesystem;
    }


    /**
     * @return FilesystemInterface
     */
    protected static function _createFilesystem()
    {
        return App::$di->get('filesystem');
    }

    public function getFilename(): string
    {
        return $this[static::$_filenameField];
    }

    public function getExt(): string
    {
        $filename = $this->getFilename();
        $ext = pathinfo($filename, PATHINFO_EXTENSION);

        return $ext ?: '';
    }


    /**
     * Returns temp filename (as uploaded)
     * @return string
     * @throws Exception
     */
    public function getTempName()
    {
        $this->_validateTempName();

        return $this['tmp_name'] ?? '';
    }

    /**
     * {@inheritDoc}
     */
    public function getType(): ?string
    {
        if ($this[static::$_typeField]) {
            $mime = $this[static::$_typeField];
        } else {
            $mime = self::getMimeType($this->getStoragePath(), $this->getFilename());
        }

        return (is_string($mime) && !empty($mime)) ? $mime : null;
    }


    /**
     * Returns filename for storage
     * @return string
     */
    protected function _getStorageFilename()
    {
        return str_pad(Filter::filterInt($this->id()), 10, '0', STR_PAD_LEFT);
    }

    public function getStoragePath(bool|string|null $thumb = false): string
    {
        if ($thumb) {
            if ($thumb === 'large') {
                return $this->_getStorageDir() . $this->_getStorageFilename() . '_l';
            }
            return $this->_getStorageDir() . $this->_getStorageFilename() . '_m';
        } else {
            return $this->_getStorageDir() . $this->_getStorageFilename();
        }
    }


    public function getImagePath(string $variant = self::IMAGE_DEFAULT): string
    {
        return $this->_getStorageDir() . $this->_getStorageFilename() . $variant;
    }

    public function getContents(bool $force = false): string
    {
        if (!isset($this->_contents) || $force) {
            $content = $this->_getFilesystem()
                ->read($this->getStoragePath());

            $this->setContents($content);
        }

        return $this->_contents;
    }

    /**
     * Returns file content as base64
     * @return string|null
     */
    public function getDataURI(bool $thumb = true): ?string
    {
        if (!$this->exists() || !$this->isImage()) {
            return null;
        }

        $fileData  = $this->_getFilesystem()->read($this->getStoragePath($thumb));
        if (empty($fileData)) {
            return null;
        }

        $type = $this->getType();
        $base64 = base64_encode($fileData);

        return 'data:' . $type . ';base64,' . $base64;
    }

    /**
     * @return $this
     */
    public function setContents(?string $contents): static
    {
        $this->_contents = $contents;

        return $this;
    }

    /**
     * Attaches file stream to response
     * @param bool $thumb
     */
    public function getStream($thumb = false, bool $setContentType = true, bool $inline = false): void
    {
        /** @var Response $response */
        $response = App::$di['response'];
        $filesystem = $this->_getFilesystem();

        if (!$filesystem->has($this->getStoragePath($thumb))) {
            $response->setStatusCode(404);

            return;
        }

        if ($setContentType) {
            $response->setContentType($this->getType());
        }

        if ($inline) {
            $response->setHeader('Content-Disposition', 'inline');
        } else {
            $response->setHeader('Content-Disposition', 'attachment; filename="' . $this->getFilename() . '"');
        }

        $this->_resolveContent($response, $thumb);

        $response->send();
    }

    /**
     * Resolves content length and bytes range
     * @param Response $response
     * @param bool $thumb
     */
    protected function _resolveContent($response, $thumb = false)
    {
        if ($range = App::getService('request')->getHeader('Range')) {
            $range = explode('-', str_replace('bytes=', '', $range));
        }

        $storagePath = $this->getStoragePath($thumb);
        $filesystem = $this->_getFilesystem();

        if (is_array($range) && $range[1]) {
            $length = (int) $range[1] - (int) $range[0] + 1;

            $response->setStatusCode(206, 'Partial Content');
            $response->setHeader('Accept-Ranges', 'bytes');
            $response->setHeader('Content-Range', 'bytes ' . $range[0] . '-' . $range[1] . '/' . $filesystem->size($storagePath));

            $stream = $filesystem->createStream($storagePath);
            $stream->open();
            $stream->seek($range[0], SEEK_SET);
            $data = $stream->read($length);
            $stream->close();

            $response->setContent($data);
            $response->setHeader('Content-Length', $length);
        } else {
            // Disable output buffering - buffer cleaning and disable
            // (required for large files)
            while (ob_end_clean()) {
                // do nothing - just clear buffer
            }
            $response->setContent($filesystem->read($storagePath));
            $response->setHeader('Content-Length', $filesystem->size($storagePath));
        }
    }


    /**
     * @deprecated
     */
    public function prepareDir()
    {
    }


    /**
     * Stores file
     * @return $this
     */
    public function store()
    {
        if ($this->id()) {
            $this->_getFilesystem()
                ->write($this->getStoragePath(), $this->_contents);
        }

        return $this;
    }


    /**
     * Erases file
     */
    protected function _erase()
    {
        $filesystem = $this->_getFilesystem();

        if ($filesystem->has($this->getStoragePath(true))) {
            $filesystem->delete($this->getStoragePath(true));
        }

        if ($filesystem->has($this->getStoragePath())) {
            $filesystem->delete($this->getStoragePath());
        }
    }


    /**
     * Removes file & row from database
     *
     * @param bool $erase
     * @return bool
     */
    protected function _remove($erase = true)
    {
        if ($erase) {
            $this->_erase();
        }

        return parent::_remove();
    }


    /**
     * Adds element and stores if contents set
     *
     * @param bool $updateObjectId
     * @return $this
     * @throws DuplicateRequestException
     */
    public function add($updateObjectId = true)
    {
        $commit = self::$_db->startTrans();

        try {
            // retrieve temp name before file is saved (because we're dealing with Sanitizable instance)
            $tempName = $this->getTempName() ?: null;

            if ($this->_hasField(static::$_filenameField) && !empty($this[static::$_filenameField])) {
                $this[static::$_filenameField] = Filter::filterXss($this[static::$_filenameField]);
            }

            parent::add($updateObjectId);

            $filesystem = $this->_getFilesystem();

            if (isset($this->_contents)) {
                $this->store();
            } elseif ($tempName) {
                $recentHashes = App::$session->get(self::RECENT_HASHES_SESSION_KEY) ?: [];

                if ($filesystem->has($tempName)) {
                    if ($filesystem->rename($tempName, $this->getStoragePath())) {
                        $recentHashes[] = $tempName;

                        if (count($recentHashes) > self::RECENT_HASHES_COUNT) {
                            array_shift($recentHashes);
                        }

                        App::$session->set(self::RECENT_HASHES_SESSION_KEY, $recentHashes);
                    } else {
                        $message = Lang::get('GENERAL_COPY_ERROR_TMP');
                        if (App::devMode() || App::isSuper()) {
                            $message .= ' (' . $tempName . ') ' . Lang::get('GENERAL_END') . ': ' . $this->getStoragePath();
                        }

                        throw new RuntimeException($message);
                    }
                } elseif (in_array($tempName, $recentHashes)) {
                    throw new DuplicateRequestException();
                }
            }
            $this->proceedImage();

            if ($commit) {
                self::$_db->commit();
            }
        } catch (DuplicateRequestException $e) {
            if ($commit) {
                self::$_db->rollback();
            }
            throw $e;
        } catch (Exception $e) {
            if ($commit) {
                self::$_db->rollback();
            }

            if ($e instanceof RuntimeException) {
                throw $e;
            } else {
                throw new RuntimeException(Lang::get('GENERAL_SAVING_ERROR') . ': ' . $e->getMessage());
            }
        }

        return $this;
    }


    /**
     * Overridden modification method (allowed overwriting stored file)
     *
     * @param bool $checkDiff
     * @return $this
     * @throws Exception
     * @throws RuntimeException
     */
    public function modify($checkDiff = false)
    {
        if (!self::$_db->isTransactionStarted()) {
            self::$_db->startTrans();
            $commit = true;
        } else {
            $commit = false;
        }

        try {
            // retrieve temp name before file is saved (because we're dealing with Sanitizable instance)
            $tempName = $this->getTempName() ?: null;

            parent::modify($checkDiff);

            $filesystem = $this->_getFilesystem();

            if (isset($this->_contents)) {
                $this->store();
            } elseif ($tempName && $filesystem->has($tempName)) {
                if (!$filesystem->rename($tempName, $this->getStoragePath())) {
                    $message = Lang::get('GENERAL_COPY_ERROR_TMP');
                    if (App::devMode() || App::isSuper()) {
                        $message .= ' (' . $tempName . ') ' . Lang::get('GENERAL_END') . ': ' . $this->getStoragePath();
                    }

                    throw new RuntimeException($message);
                }
            }

            $this->proceedImage();

            if ($commit) {
                self::$_db->commit();
            }
        } catch (Exception $e) {
            if ($commit) {
                self::$_db->rollback();
            }

            if ($e instanceof RuntimeException) {
                throw $e;
            } else {
                throw new RuntimeException(Lang::get('GENERAL_SAVING_ERROR') . ': ' . $e->getMessage(), $e->getCode(), $e);
            }
        }

        return $this;
    }


    /**
     * Return file's MIME type
     * @param string $file
     * @param string $fileName
     * @return string
     */
    public static function getMimeType($file, $fileName)
    {
        $filesystem = self::_createFilesystem();

        try {
            $fileType = $filesystem->mimeType($file);
        } catch (FileNotFound $e) {
            return null;
        }

        if ($fileType == 'application/zip') {
            $officeAddTypes = [
                'docm' => 'application/vnd.ms-word.document.macroEnabled.12',
                'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
                'potm' => 'application/vnd.ms-powerpoint.template.macroEnabled.12',
                'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template',
                'ppam' => 'application/vnd.ms-powerpoint.addin.macroEnabled.12',
                'ppsm' => 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12',
                'ppsx' => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
                'pptm' => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
                'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
                'xlam' => 'application/vnd.ms-excel.addin.macroEnabled.12',
                'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
                'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',
                'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
                'xltm' => 'application/vnd.ms-excel.template.macroEnabled.12',
                'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
            ];

            $aFilename = explode('.', $fileName);
            $ext = end($aFilename);

            if (isset($officeAddTypes[$ext])) {
                return $officeAddTypes[$ext];
            }
        }

        return $fileType;
    }


    /**
     * Checks if file content could be displayed in web browser
     * @return bool
     */
    public function canPreview()
    {
        return ($this->isImage() || $this->isVideo() || $this->isPdf());
    }

    public function isPdf(): bool
    {
        return stripos($this->getType(), 'pdf');
    }

    /**
     * Returns true if image is allowed to be previewed
     * @param bool $deepCheck
     * @return bool|null
     */
    public function isImage(): bool
    {
        $type = $this->getType();

        return $type && self::isSupportedImageType($type);
    }

    public static function isSupportedImageType(string $type): bool
    {
        return strpos($type, 'image') !== false && !in_array($type, self::UNSUPPORTED_IMAGE_TYPES);
    }

    /**
     * Returns true if file is an audio record
     * @return bool
     */
    public function isAudio(): bool
    {
        return strpos($this->getType(), 'audio') !== false;
    }

    /**
     * Returns true if file is a video file
     * @return bool
     */
    public function isVideo(): bool
    {
        return strpos($this->getType(), 'video') !== false;
    }

    /**
     * Checks if file exists
     * @return bool
     */
    public function exists(): bool
    {
        return $this->_getFilesystem()->has($this->getStoragePath());
    }


    /**
     * Validates temporary directory
     * @throws Exception
     */
    protected function _validateTempName()
    {
        if (!isset($this['tmp_name'])) {
            return;
        }
        $tempDir = FilesystemTrait::normalizePath($this['tmp_name']);

        $allowedTmpDirs = [
            'temp/',
            '/tmp/',
        ];

        if ($tempDir == '/tmp/') {
            if (!is_uploaded_file($this['tmp_name'])) {
                throw new Exception('File not uploaded');
            }
        }

        if (!in_array($tempDir, $allowedTmpDirs)) {
            throw new Exception(sprintf('"%s" is not valid temporary directory', mb_strimwidth($tempDir, 0, 100, '...')));
        }
    }

    /**
     * @return void
     */
    public function proceedImage(): void
    {
        $typesToProceed = [
            'image/gif',
            'image/jpeg',
            'image/jpg',
            'image/png',
            'image/webp',
            'image/jfif',
            'image/tiff',
            'image/heic',
        ];

        $filesystem = $this->_getFilesystem();

        if ($this->_proceedImage && in_array($this[static::$_typeField], $typesToProceed) && $filesystem->has($this->getStoragePath())) {
            if (extension_loaded('imagick')) {
                try {
                    $convertMimeTypes = [
                        'image/tiff',
                        'image/heic',
                        'image/heif',
                        'image/jfif',
                    ];

                    $imageBulkProcessor = (new BulkProcessor([
                        new Decorator\ConvertDecorator($this->_getFilesystem(), $convertMimeTypes, 'jpg'),
                        new Decorator\RotateDecorator($this->_getFilesystem()),
                        new Decorator\RemoveExifDecorator($this->_getFilesystem()),
                        // Save image file as large thumbnail
                        new Decorator\SaveAsDecorator($this->_getFilesystem(), $this->getStoragePath('large')),
                        // Save image file as small thumbnail
                        new Decorator\SaveAsDecorator($this->_getFilesystem(), $this->getStoragePath(true)),
                    ]));
                    $imageBulkProcessor->process($this->getStoragePath());

                    if (in_array($this->getType(), $convertMimeTypes)) {
                        // If no exception was thrown, image was processed successfully so we can update the type
                        $this[static::$_typeField] = 'image/jpeg';
                        $this[static::$_filenameField] = pathinfo($this->getFilename(), PATHINFO_FILENAME) . '.jpg';

                        $this->disableImageProceeding();
                        $this->modify();
                        $this->disableImageProceeding(false);
                    }

                    // Save image file as large thumbnail
                    (new BulkProcessor([
                        new Decorator\ResizeDecorator($this->_getFilesystem(), static::DOWNSIZE_LARGE, static::DOWNSIZE_LARGE),
                    ]))->process($this->getStoragePath('large'));

                    // Save image file as thumbnail
                    (new BulkProcessor([
                        new Decorator\ResizeDecorator($this->_getFilesystem(), static::DOWNSIZE_DEFAULT, static::DOWNSIZE_DEFAULT),
                    ]))->process($this->getStoragePath(true));

                } catch (\ImagickException $ex) {
                    throw new RuntimeException(sprintf('Unsupported image type (%s)', $this->getType()), previous: $ex);
                }
            }
        }
    }

    /**
     * @return $this
     * @throws DuplicateRequestException
     * @throws DataObject\NoColumnsException
     * @throws BusinessLogicException
     */
    public function copy(array $overrideValues = []): self
    {
        $sourcePath = $this->getStoragePath();
        $sourceThumbPath = $this->getStoragePath(true);
        $sourceLargeThumbPath = $this->getStoragePath('large');

        $values = $this->getArrayCopy();
        $newInstance = new static($values);

        $primaryKeyFields = $this->_getPrimaryKeyField();
        if (!is_array($primaryKeyFields)) {
            $primaryKeyFields = [$primaryKeyFields];
        }

        foreach ($primaryKeyFields as $field) {
            unset($newInstance[$field]);
        }

        $newInstance->append($overrideValues);
        $newInstance->add();

        $filesystem = $this->_getFilesystem();
        if (!$filesystem->has($sourcePath)) {
            throw new BusinessLogicException(sprintf('%s: %s', Lang::get('GENERAL_NO_FILE'), $sourcePath));
        }
        $filesystem->copy($sourcePath, $newInstance->getStoragePath());

        if ($filesystem->has($sourceThumbPath)) {
            $targetThumbPath = $newInstance->getStoragePath(true);
            $filesystem->copy($sourceThumbPath, $targetThumbPath);
        }

        if ($filesystem->has($sourceLargeThumbPath)) {
            $targetLargeThumbPath = $newInstance->getStoragePath('large');
            $filesystem->copy($sourceLargeThumbPath, $targetLargeThumbPath);
        }

        return $newInstance;
    }

    /**
     * Returns file size
     * @return int
     */
    public function getSize(): int
    {
        if (!isset($this['size'])) {
            $filesystem = $this->_getFilesystem();
            $path = $this->getStoragePath();

            if ($filesystem->has($path)) {
                $this['size'] = $filesystem->size($path);
            } else {
                $this['size'] = 0;
            }
        }

        return $this['size'];
    }

    /**
     * {@inheritDoc}
     */
    public function disableImageProceeding(bool $disable = true): static
    {
        $this->_proceedImage = !$disable;

        return $this;
    }

    public function getImageFormat(Imagick $img): string
    {
        $supportedFormats = Imagick::queryFormats();
        $formatByFileExtension = pathinfo($this->getFilename(), PATHINFO_EXTENSION);

        if (in_array(strtoupper($formatByFileExtension), $supportedFormats)) {
            return $formatByFileExtension;
        }

        return $img->getImageFormat();
    }
}
