<?php

/**
 * Joomla! Content Management System
 *
 * @copyright  (C) 2013 Open Source Matters, Inc. <https://www.joomla.org>
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\CMS\Helper;

use enshrined\svgSanitize\Sanitizer;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\Filter\InputFilter;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Filesystem\File;
use Joomla\Registry\Registry;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Media helper class
 *
 * @since  3.2
 */
class MediaHelper
{
    /**
     * A special list of blocked executable extensions, skipping executables that are
     * typically executable in the webserver context as those are fetched from
     * Joomla\CMS\Filter\InputFilter
     *
     * @var    string[]
     * @since  4.0.0
     */
    public const EXECUTABLES = [
        'js', 'exe', 'dll', 'go', 'ade', 'adp', 'bat', 'chm', 'cmd', 'com', 'cpl', 'hta',
        'ins', 'isp', 'jse', 'lib', 'mde', 'msc', 'msp', 'mst', 'pif', 'scr', 'sct', 'shb',
        'sys', 'vb', 'vbe', 'vbs', 'vxd', 'wsc', 'wsf', 'wsh', 'html', 'htm', 'msi',
    ];

    /**
     * Checks if the file is an image
     *
     * @param   string  $fileName  The filename
     *
     * @return  boolean
     *
     * @since   3.2
     */
    public static function isImage($fileName)
    {
        static $imageTypes = 'xcf|odg|gif|jpg|jpeg|png|bmp|webp|avif';

        return preg_match("/\.(?:$imageTypes)$/i", $fileName);
    }

    /**
     * Gets the file extension for purposed of using an icon
     *
     * @param   string  $fileName  The filename
     *
     * @return  string  File extension to determine icon
     *
     * @since   3.2
     */
    public static function getTypeIcon($fileName)
    {
        return strtolower(substr($fileName, strrpos($fileName, '.') + 1));
    }

    /**
     * Get the Mime type
     *
     * @param   string   $file     The link to the file to be checked
     * @param   boolean  $isImage  True if the passed file is an image else false
     *
     * @return  mixed    the mime type detected false on error
     *
     * @since   3.7.2
     */
    public static function getMimeType($file, $isImage = false)
    {
        // If we can't detect anything mime is false
        $mime = false;

        try {
            if ($isImage && \function_exists('exif_imagetype')) {
                $mime = image_type_to_mime_type(exif_imagetype($file));
            } elseif ($isImage && \function_exists('getimagesize')) {
                $imagesize = getimagesize($file);
                $mime      = $imagesize['mime'] ?? false;
            } elseif (\function_exists('mime_content_type')) {
                // We have mime magic.
                $mime = mime_content_type($file);
            } elseif (\function_exists('finfo_open')) {
                // We have fileinfo
                $finfo = finfo_open(FILEINFO_MIME_TYPE);
                $mime  = finfo_file($finfo, $file);
                finfo_close($finfo);
            }
        } catch (\Exception) {
            // If we have any kind of error here => false;
            return false;
        }

        // If we can't detect the mime try it again
        if ($mime === 'application/octet-stream' && $isImage === true) {
            $mime = static::getMimeType($file, false);
        }

        if (
            ($mime === 'application/octet-stream' || $mime === 'image/svg' || $mime === 'image/svg+xml')
            && !$isImage && strtolower(pathinfo($file, PATHINFO_EXTENSION)) === 'svg' && self::isValidSvg($file, false)
        ) {
            return 'image/svg+xml';
        }

        // We have a mime here
        return $mime;
    }

    /**
     * Checks the Mime type
     *
     * @param   string  $mime       The mime to be checked
     * @param   string  $component  The optional name for the component storing the parameters
     *
     * @return  boolean  true if mime type checking is disabled or it passes the checks else false
     *
     * @since   3.7
     */
    private function checkMimeType($mime, $component = 'com_media'): bool
    {
        $params = ComponentHelper::getParams($component);

        if ($params->get('check_mime', 1)) {
            $allowedMime = $params->get(
                'upload_mime',
                'image/jpeg,image/gif,image/png,image/bmp,image/webp,image/avif,application/msword,' .
                    'application/excel,application/pdf,application/powerpoint,text/plain,application/x-zip'
            );

            // Get the mime type configuration
            $allowedMime = array_map('trim', explode(',', str_replace('\\', '', $allowedMime)));

            // Mime should be available and in the allowed list
            return !empty($mime) && \in_array($mime, $allowedMime);
        }

        // We don't check mime at all or it passes the checks
        return true;
    }

    /**
     * Checks the file extension
     *
     * @param   string  $extension  The extension to be checked
     * @param   string  $component  The optional name for the component storing the parameters
     *
     * @return  boolean  true if it passes the checks else false
     *
     * @since   4.0.0
     */
    public static function checkFileExtension($extension, $component = 'com_media', $allowedExecutables = []): bool
    {
        $params = ComponentHelper::getParams($component);

        // Media file names should never have executable extensions buried in them.
        $executables = array_merge(self::EXECUTABLES, InputFilter::FORBIDDEN_FILE_EXTENSIONS);

        // Remove allowed executables from array
        if (\count($allowedExecutables)) {
            $executables = array_diff($executables, $allowedExecutables);
        }

        if (\in_array($extension, $executables, true)) {
            return false;
        }

        $allowable = array_map('trim', explode(',', $params->get('restrict_uploads_extensions', 'bmp,gif,jpg,jpeg,png,webp,avif,ico,mp3,m4a,mp4a,ogg,mp4,mp4v,mpeg,mov,odg,odp,ods,odt,pdf,ppt,txt,xcf,xls,csv')));
        $ignored   = array_map('trim', explode(',', $params->get('ignore_extensions', '')));

        if ($extension === '' || (!\in_array($extension, $allowable, true) && !\in_array($extension, $ignored, true))) {
            return false;
        }

        // We don't check mime at all or it passes the checks
        return true;
    }

    /**
     * Checks if the file can be uploaded
     *
     * @param   array     $file                File information
     * @param   string    $component           The option name for the component storing the parameters
     * @param   string[]  $allowedExecutables  Array of executable file types that shall be allowed
     *
     * @return  boolean
     *
     * @since   3.2
     */
    public function canUpload($file, $component = 'com_media', $allowedExecutables = [])
    {
        $app    = Factory::getApplication();
        $params = ComponentHelper::getParams($component);

        if (empty($file['name'])) {
            $app->enqueueMessage(Text::_('JLIB_MEDIA_ERROR_UPLOAD_INPUT'), 'error');

            return false;
        }

        if ($file['name'] !== File::makeSafe($file['name'])) {
            $app->enqueueMessage(Text::_('JLIB_MEDIA_ERROR_WARNFILENAME'), 'error');

            return false;
        }

        $filetypes = explode('.', $file['name']);

        if (\count($filetypes) < 2) {
            // There seems to be no extension
            $app->enqueueMessage(Text::_('JLIB_MEDIA_ERROR_WARNFILETYPE'), 'error');

            return false;
        }

        array_shift($filetypes);

        // Media file names should never have executable extensions buried in them.
        $executables = array_merge(self::EXECUTABLES, InputFilter::FORBIDDEN_FILE_EXTENSIONS);

        // Remove allowed executables from array
        if (\count($allowedExecutables)) {
            $executables = array_diff($executables, $allowedExecutables);
        }

        // Ensure lowercase extension
        $filetypes = array_map('strtolower', $filetypes);

        $check = array_intersect($filetypes, $executables);

        if (!empty($check)) {
            $app->enqueueMessage(Text::_('JLIB_MEDIA_ERROR_WARNFILETYPE'), 'error');

            return false;
        }

        $filetype = array_pop($filetypes);

        $allowable = array_map('trim', explode(',', $params->get('restrict_uploads_extensions', 'bmp,gif,jpg,jpeg,png,webp,avif,ico,mp3,m4a,mp4a,ogg,mp4,mp4v,mpeg,mov,odg,odp,ods,odt,pdf,png,ppt,txt,xcf,xls,csv')));
        $ignored   = array_map('trim', explode(',', $params->get('ignore_extensions', '')));

        if ($filetype === '' || !$filetype || (!\in_array($filetype, $allowable) && !\in_array($filetype, $ignored))) {
            $app->enqueueMessage(Text::_('JLIB_MEDIA_ERROR_WARNFILETYPE'), 'error');

            return false;
        }

        $maxSize = (int) ($params->get('upload_maxsize', 0) * 1024 * 1024);

        if ($maxSize > 0 && (int) $file['size'] > $maxSize) {
            $app->enqueueMessage(Text::_('JLIB_MEDIA_ERROR_WARNFILETOOLARGE'), 'error');

            return false;
        }

        if ($params->get('restrict_uploads', 1)) {
            $allowedExtensions = array_map('trim', explode(',', $params->get('restrict_uploads_extensions', 'bmp,gif,jpg,jpeg,png,webp,avif,ico,mp3,m4a,mp4a,ogg,mp4,mp4v,mpeg,mov,odg,odp,ods,odt,pdf,png,ppt,txt,xcf,xls,csv')));

            if (\in_array($filetype, $allowedExtensions)) {
                // If tmp_name is empty, then the file was bigger than the PHP limit
                if (!empty($file['tmp_name'])) {
                    // Get the mime type this is an image file
                    $mime = static::getMimeType($file['tmp_name'], static::isImage($file['tmp_name']));

                    // Did we get anything useful?
                    if ($mime !== false) {
                        $result = $this->checkMimeType($mime, $component);

                        // If the mime type is not allowed we don't upload it and show the mime code error to the user
                        if ($result === false) {
                            $app->enqueueMessage(Text::sprintf('JLIB_MEDIA_ERROR_WARNINVALID_MIMETYPE', $mime), 'error');

                            return false;
                        }
                    } else {
                        // We can't detect the mime type so it looks like an invalid image
                        $app->enqueueMessage(Text::_('JLIB_MEDIA_ERROR_WARNINVALID_IMG'), 'error');

                        return false;
                    }
                } else {
                    $app->enqueueMessage(Text::_('JLIB_MEDIA_ERROR_WARNFILETOOLARGE'), 'error');

                    return false;
                }
            } elseif (!\in_array($filetype, $ignored)) {
                // Get the mime type this is not an image file
                $mime = static::getMimeType($file['tmp_name'], false);

                // Did we get anything useful?
                if ($mime !== false) {
                    $result = $this->checkMimeType($mime, $component);

                    // If the mime type is not allowed we don't upload it and show the mime code error to the user
                    if ($result === false) {
                        $app->enqueueMessage(Text::sprintf('JLIB_MEDIA_ERROR_WARNINVALID_MIMETYPE', $mime), 'error');

                        return false;
                    }
                } else {
                    // We can't detect the mime type so it looks like an invalid file
                    $app->enqueueMessage(Text::_('JLIB_MEDIA_ERROR_WARNINVALID_MIME'), 'error');

                    return false;
                }

                if (!Factory::getUser()->authorise('core.manage', $component)) {
                    $app->enqueueMessage(Text::_('JLIB_MEDIA_ERROR_WARNNOTADMIN'), 'error');

                    return false;
                }
            }
        }

        if ($filetype === 'svg') {
            return self::isValidSvg($file['tmp_name'], true);
        }

        return true;
    }

    /**
     * Calculate the size of a resized image
     *
     * @param   integer  $width   Image width
     * @param   integer  $height  Image height
     * @param   integer  $target  Target size
     *
     * @return  array  The new width and height
     *
     * @since   3.2
     */
    public static function imageResize($width, $height, $target)
    {
        /*
         * Takes the larger size of the width and height and applies the
         * formula accordingly. This is so this script will work
         * dynamically with any size image
         */
        if ($width > $height) {
            $percentage = ($target / $width);
        } else {
            $percentage = ($target / $height);
        }

        // Gets the new value and applies the percentage, then rounds the value
        $width  = round($width * $percentage);
        $height = round($height * $percentage);

        return [$width, $height];
    }

    /**
     * Counts the files and directories in a directory that are not php or html files.
     *
     * @param   string  $dir  Directory name
     *
     * @return  array  The number of media files and directories in the given directory
     *
     * @since   3.2
     */
    public function countFiles($dir)
    {
        $total_file = 0;
        $total_dir  = 0;

        if (is_dir($dir)) {
            $d = dir($dir);

            while (($entry = $d->read()) !== false) {
                if ($entry[0] !== '.' && !str_contains($entry, '.html') && !str_contains($entry, '.php') && is_file($dir . DIRECTORY_SEPARATOR . $entry)) {
                    $total_file++;
                }

                if ($entry[0] !== '.' && is_dir($dir . DIRECTORY_SEPARATOR . $entry)) {
                    $total_dir++;
                }
            }

            $d->close();
        }

        return [$total_file, $total_dir];
    }

    /**
     * Small helper function that properly converts any
     * configuration options to their byte representation.
     *
     * @param   string|integer  $val  The value to be converted to bytes.
     *
     * @return integer The calculated bytes value from the input.
     *
     * @since 3.3
     */
    public function toBytes($val)
    {
        switch ($val[\strlen($val) - 1]) {
            case 'M':
            case 'm':
                return (int) $val * 1048576;
            case 'K':
            case 'k':
                return (int) $val * 1024;
            case 'G':
            case 'g':
                return (int) $val * 1073741824;
            default:
                return $val;
        }
    }

    /**
     * Method to check if the given directory is a directory configured in FileSystem - Local plugin
     *
     * @param   string  $directory
     *
     * @return  boolean
     *
     * @since   4.0.0
     */
    public static function isValidLocalDirectory($directory)
    {
        $plugin = PluginHelper::getPlugin('filesystem', 'local');

        if ($plugin) {
            $params = new Registry($plugin->params);

            $directories = $params->get('directories', '[{"directory": "images"}]');

            // Do a check if default settings are not saved by user
            // If not initialize them manually
            if (\is_string($directories)) {
                $directories = json_decode($directories);
            }

            foreach ($directories as $directoryEntity) {
                if ($directoryEntity->directory === $directory) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Helper method get clean data for value stores in a Media form field by removing adapter information
     * from the value if available (in this case, the value will have this format:
     * images/headers/blue-flower.jpg#joomlaImage://local-images/headers/blue-flower.jpg?width=700&height=180)
     *
     * @param   string  $value
     *
     * @return  string
     *
     * @since   4.0.0
     */
    public static function getCleanMediaFieldValue($value)
    {
        if ($pos = strpos($value, '#')) {
            return substr($value, 0, $pos);
        }

        return $value;
    }

    /**
     * Check if a file is a valid SVG
     *
     * @param  string  $file
     * @param  bool    $shouldLogErrors
     *
     * @return  boolean
     *
     * @since   4.3.0
     */
    private static function isValidSvg($file, $shouldLogErrors = true): bool
    {
        $sanitizer = new Sanitizer();

        $isValid = $sanitizer->sanitize(file_get_contents($file));

        $svgErrors = $sanitizer->getXmlIssues();

        /**
         * We allow comments and temp fix for bugs in svg-santitizer
         * https://github.com/darylldoyle/svg-sanitizer/issues/64
         * https://github.com/darylldoyle/svg-sanitizer/issues/63
         * https://github.com/darylldoyle/svg-sanitizer/pull/65
         * https://github.com/darylldoyle/svg-sanitizer/issues/82
         */
        foreach ($svgErrors as $i => $error) {
            if (
                ($error['message'] === 'Suspicious node \'#comment\'')
                || ($error['message'] === 'Suspicious attribute \'space\'')
                || ($error['message'] === 'Suspicious attribute \'enable-background\'')
                || ($error['message'] === 'Suspicious node \'svg\'')
            ) {
                unset($svgErrors[$i]);
            }
        }

        if ($isValid === false || \count($svgErrors)) {
            if ($shouldLogErrors) {
                Factory::getApplication()->enqueueMessage(Text::_('JLIB_MEDIA_ERROR_WARNIEXSS'), 'error');
            }

            return false;
        }

        return true;
    }
}
