<?php

/**
 * @package     JCE
 * @subpackage  Editor
 *
 * @copyright   Copyright (C) 2005 - 2020 Open Source Matters, Inc. All rights reserved.
 * @copyright   Copyright (c) 2009 - 2026 Ryan Demmer. All rights reserved
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

\defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Object\CMSObject;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Table\Table;
use Joomla\CMS\User\UserHelper;

class WFFileBrowser extends CMSObject
{
    /** @var array */
    private $_buttons = array();

    /** @var array */
    private $_actions = array();

    /** @var array */
    private $_events = array();

    /** @var array */
    private $_result = array('error' => array(), 'files' => array(), 'folders' => array());

    /** @var array */
    public $dir = array();

    /** @var WFFileSystem */
    public $filesystem = null;

    /** @var string */
    public $filetypes = 'jpg,jpeg,png,gif,webp';

    /** @var array */
    public $upload = array(
        'max_size' => 10240,
        'validate_mimetype' => 1,
        'add_random' => 0,
        'total_files' => 0,
        'total_size' => 0,
        'remove_exif' => 0,
    );

    /** @var int */
    public $folder_tree = 1;

    /** @var string */
    public $list_limit = 'all';

    /** @var array */
    public $features = array(
        'help' => 1,
        'upload' => 1,
        'folder' => array(
            'create' => 1,
            'delete' => 1,
            'rename' => 1,
            'move' => 1,
        ),
        'file' => array(
            'rename' => 1,
            'delete' => 1,
            'move' => 1,
        ),
    );

    /** @var string */
    public $date_format = '%d/%m/%Y, %H:%M';

    /** @var string */
    public $websafe_mode = 'utf-8';

    /** @var int */
    public $websafe_spaces = 0;

    /** @var string */
    public $websafe_textcase = '';

    /**
     * @param array $config Configuration array passed from the manager.
     */
    public function __construct($config = array())
    {
        // set file browser config
        $this->setConfig($config);

        // add actions
        $this->addDefaultActions();
        // add buttons
        $this->addDefaultButtons();

        // Setup XHR callback funtions
        $this->setRequest(array($this, 'getItems'));
        $this->setRequest(array($this, 'getFileDetails'));
        $this->setRequest(array($this, 'getFolderDetails'));
        $this->setRequest(array($this, 'getTree'));
        $this->setRequest(array($this, 'getTreeItem'));

        $this->setRequest(array($this, 'searchItems'));

        $this->setRequest(array($this, 'upload'));
    }

    /**
     * @return object The active JCE profile.
     */
    protected function getProfile()
    {
        return WFApplication::getInstance()->getActiveProfile();
    }

    /**
     * Display the browser.
     */
    public function display()
    {
        $filesystem = $this->getFileSystem();
        $buttons    = $filesystem->get('buttons', []);

        if (!empty($buttons)) {
            foreach ($buttons as $type => $items) {
                foreach ($items as $name => $options) {
                    $this->addButton($type, $name, $options);
                }
            }
        }

        $this->setProperties(array(
            'actions' => $this->getActions(),
            'buttons' => $this->getButtons(),
        ));

        // Get the Document instance
        $document = WFDocument::getInstance();

        $document->addScript(array('filebrowser.min'), 'media');
        $document->addStyleSheet(array('filebrowser.min'), 'media');
    }

    /**
     * Render the browser view.
     */
    public function render()
    {
        $session = Factory::getSession();

        $view = new WFView(array(
            'name' => 'filebrowser',
            'layout' => 'default',
        ));

        // assign session data
        $view->session = $session;

        // assign form action
        $view->action = $this->getFormAction();

        $view->list_limit_options = $this->get('list_limit_options', array());
        $view->list_limit = $this->get('list_limit', 25);

        // return view output
        $view->display();
    }

    /**
     * Set a WFRequest item.
     *
     * @param array $request
     */
    public function setRequest($request)
    {
        $xhr = WFRequest::getInstance();
        $xhr->setRequest($request);
    }

    /**
     * Build the form action URL for XHR plugin requests.
     *
     * @return string The absolute action URL.
     */
    protected function getFormAction()
    {
        $wf = WFEditorPlugin::getInstance();

        $context = Factory::getApplication()->input->getInt('context');

        $query = '';

        $args = array(
            'plugin' => $wf->getName(),
            'context' => $context,
        );

        foreach ($args as $k => $v) {
            $query .= '&' . $k . '=' . $v;
        }

        return Uri::base(true) . '/index.php?option=com_jce&task=plugin.rpc' . $query;
    }

    /**
     * @return WFFileSystem The active filesystem instance.
     */
    public function getFileSystem()
    {
        return $this->filesystem;
    }

    /**
     * @return string Comma-separated list of extensions that the browser can preview inline.
     */
    private function getViewable()
    {
        return 'jpeg,jpg,gif,png,webp,apng,svg,avi,wmv,wm,asf,asx,wmx,wvx,mov,qt,mpg,mp3,mp4,m4v,mpeg,ogg,ogv,webm,swf,flv,f4v,xml,dcr,rm,ra,ram,divx,html,htm,txt,rtf,pdf,doc,docx,xls,xlsx,ppt,pptx';
    }

    /**
     * Return a list of allowed file extensions in a specific format.
     *
     * @param string $format The desired format of the output ('map', 'array', 'list', 'json').
     * @param string $list Optional string of file types to use instead of the default.
     * @return mixed Formatted extension list.
     */
    public function getFileTypes($format = 'map', $list = '')
    {
        // If $list is empty, use the default filetypes from the object's property
        if (empty($list)) {
            $list = $this->get('filetypes');
        }

        return WFUtility::formatFileTypesList($format, $list);
    }


    /**
     * Converts the extensions map to a list.
     *
     * @param string $list The extensions map eg: images=jpg,jpeg,gif,png
     *
     * @return string jpg,jpeg,gif,png
     */
    private function listFileTypes($list = '')
    {
        return $this->getFileTypes('list', $list);
    }

    /**
     * Set allowed file types and sync the upload config.
     *
     * @param  string $list Comma-separated extension list, e.g. "jpg,png,gif".
     * @return void
     */
    public function setFileTypes($list = 'jpg,jpeg,png,gif')
    {
        if ($list && $list[0] === '=') {
            $list = substr($list, 1);
        }

        // get existing upload values
        $upload = $this->get('upload', array());

        // set updated filetypes
        $upload['filetypes'] = $list;

        // update filetypes
        $this->setProperties(array(
            'upload' => $upload,
        ));

        $this->set('filetypes', $list);
    }

    /**
     * @return array The current result array with 'error', 'files', and 'folders' keys.
     */
    public function getResult()
    {
        return $this->_result;
    }

    /**
     * Store a value in the result array.
     *
     * @param  mixed       $value The value to store.
     * @param  string|null $key   Result key ('files', 'folders', or 'error'); null replaces the entire result.
     * @return void
     */
    public function setResult($value, $key = null)
    {
        if ($key) {
            $this->_result[$key][] = $value;
        } else {
            $this->_result = $value;
        }
    }

    /**
     * Check whether a feature is enabled in the current profile.
     *
     * @param  string      $action The feature name (e.g. 'upload', 'delete', 'rename', 'move', 'create').
     * @param  string|null $type   Optional sub-type ('file' or 'folder').
     * @return bool True if the feature is enabled, false otherwise.
     */
    public function checkFeature($action, $type = null)
    {
        $features = $this->get('features');

        if ($type) {
            if (isset($features[$type])) {
                $type = $features[$type];

                if (isset($type[$action])) {
                    return (bool) $type[$action];
                }
            }
        } else {
            if (isset($features[$action])) {
                return (bool) $features[$action];
            }
        }

        return false;
    }

    /**
     * Resolve the containing directory of a file or directory path.
     * Returns an empty string for absolute URLs or paths that cannot be resolved.
     *
     * @param  string $path A relative file or directory path, or a URL.
     * @return string The resolved directory path, or an empty string.
     */
    public function getSourceDir($path)
    {
        $path = $this->get('source', $path);

        if (empty($path)) {
            return '';
        }

        // return nothing if absolute $path
        if (preg_match('#^(file|http(s)?):\/\/#', $path)) {
            return '';
        }

        $filesystem = $this->getFileSystem();

        $path = $this->extractPath($path);

        // directory path relative base directory, eg: images/2025
        if ($filesystem->is_dir($path)) {
            return $path;
        }

        // file url relative to site root
        if ($filesystem->is_file($path)) {
            return dirname($path);
        }

        return '';
    }

    /**
     * Determine whether a path is in complex "id:relative" form.
     *
     * A complex path begins with a 32-character hexadecimal MD5 prefix,
     * followed by a colon, and an optional relative path.
     *
     * @param   string  $path  The path string to test.
     *
     * @return  bool  True if the path has a valid MD5 prefix, false otherwise.
     */
    private function isComplexPath($path)
    {
        // Fast fail: no colon at all
        $pos = strpos($path, ':');

        // No colon found, so not a complex path
        if ($pos === false) {
            return false;
        }

        // Ignore protocols like "http://", "ftp://", "file://"
        if (strpos($path, '://') !== false) {
            return false;
        }

        // Candidate prefix before the colon
        $candidate = substr($path, 0, $pos);

        // Must be exactly 32 hex characters (MD5 hex)
        if (strlen($candidate) !== 32 || !ctype_xdigit($candidate)) {
            return false;
        }

        return true;
    }

    /**
     * Split a complex path "id:relative" into prefix and relative components.
     *
     * Returns true if split occurred, false otherwise. When false, $id will be empty
     * and $relative will contain the original $path value.
     *
     * Examples:
     *   abcdef...1234:images/foo.jpg → $id="abcdef...1234", $relative="images/foo.jpg"
     *   abcdef...1234:               → $id="abcdef...1234", $relative=""
     *   images/foo.jpg               → no split (false)
     *
     * @param   string  $path       The full path to parse.
     * @param   string  &$id        Output parameter for the 32-character prefix.
     * @param   string  &$relative  Output parameter for the relative path.
     *
     * @return  bool  True if the path was successfully split, false otherwise.
     */
    private function splitComplexPath($path, &$id, &$relative)
    {
        $id         = '';
        $relative   = $path;

        if (!$this->isComplexPath($path)) {
            // Not a complex path, so return false
            return false;
        }

        $pos = strpos($path, ':');

        // Candidate prefix before the colon
        $candidate = substr($path, 0, $pos);

        $id = strtolower($candidate);
        $relative = substr($path, $pos + 1);

        return true;
    }

    /**
     * Extract the simple relative path from a possibly complex "id:relative" value.
     * Returns the portion after the colon, or the original path if not complex.
     *
     * @param   string  $path  The path value to process.
     *
     * @return  string  The extracted relative path, or the original value.
     */
    private function extractPath($path)
    {
        if ($this->splitComplexPath($path, $id, $relative)) {
            return $relative;
        }

        return $path;
    }

    /**
     * Parse a path value to extract its prefix.
     *
     * Updates the input $path by reference to remove the prefix,
     * leaving only the relative portion (or empty string for root).
     *
     * @param   string  &$path  The path value to modify.
     *
     * @return  string  The extracted prefix, or an empty string if not complex.
     */
    private function parsePath(&$path)
    {
        $id = '';

        if ($this->splitComplexPath($path, $id, $relative)) {
            $path = $relative;
            return $id;
        }

        return '';
    }

    /**
     * Get the prefix from a complex path without modifying it.
     *
     * @param   string  $path  The path value to parse.
     *
     * @return  string  The prefix if complex, or an empty string otherwise.
     */
    private function getPathPrefix($path)
    {
        if ($this->splitComplexPath($path, $id, $relative)) {
            return $id;
        }

        return '';
    }

    /**
     * Resolve a path into its absolute filesystem location.
     *
     * If the path is in "id:relative" form, it resolves the prefix to its
     * corresponding directory store root and appends the relative portion.
     * If the path is simple, it is returned unchanged.
     *
     * Examples:
     *   abcdef...1234:foo/bar → images/foo/bar
     *   abcdef...1234:        → images
     *   foo/bar               → foo/bar (no change)
     *
     * @param   string  $path  The path to resolve.
     *
     * @return  string  The resolved absolute path or the input if simple.
     */
    public function resolvePath($path)
    {
        if (empty($path)) {
            return '';
        }

        // check for complex path
        if ($this->isComplexPath($path) === false) {
            // no prefix so return the path as is
            return $path;
        }

        // get the store array from the complex source path, eg: prefix:path
        $store = $this->getDirectoryStoreFromPath($path);

        if ($store) {
            // extract the path from the complex source path, eg: prefix:path
            $path = $this->extractPath($path);

            // make the source relative to the store path, eg: stories => images/stories
            $path = WFUtility::makePath($store['path'], $path);
        }

        return $path;
    }

    /**
     * Return the default upload/browse path (first store entry, in "prefix:" form).
     *
     * @return string Path prefix with trailing colon.
     */
    public function getDefaultPath()
    {
        $store = $this->getDirectoryStore();
        $values = reset($store); // get the first element

        return $values['prefix'] . ':';
    }

    /**
     * Build the canonical directory store from configured paths.
     * Missing directories are created on the fly; invalid entries are skipped.
     *
     * @return array Keyed by MD5 hash of each path; each entry has 'path', 'label', and 'prefix'.
     */
    private function getDirectoryStore()
    {
        $filesystem = $this->getFileSystem();

        // If Allow Root is enabled, return a single blank/root entry
        if (empty($filesystem->getRootDir())) {
            $hash = md5('__allow_root_access__');

            return array(
                $hash => array(
                    'path'   => '',
                    'label'  => '',
                    'prefix' => $hash,
                )
            );
        }

        $dir = (array) $this->get('dir');

        // Fallback to a single default directory if none set
        if (empty($dir)) {
            $path  = 'images';
            $label = '';

            Factory::getApplication()->triggerEvent('onWfFileSystemGetRootDir', array(&$path, &$label));

            $hash = md5($path);

            return array(
                $hash => array(
                    'path'   => $path,
                    'label'  => '',
                    'prefix' => $hash,
                )
            );
        }

        $newDir = array();

        foreach ($dir as $origKey => $item) {
            $item['path'] = isset($item['path']) ? trim($item['path']) : '';

            if ($item['path'] === '') {
                $item['path'] = 'images';
            }

            $processedPath = $this->processPath($item['path']);
            $label         = isset($item['label']) ? $item['label'] : '';

            if (count($dir) > 1 && $label === '') {
                $label = basename($processedPath);
            }

            // Process both path and label via the event
            Factory::getApplication()->triggerEvent('onWfFileSystemGetRootDir', array(&$processedPath, &$label));

            // Ensure the folder exists (create if missing)
            if ($filesystem->is_dir($processedPath) === false) {
                $name    = WFUtility::mb_basename($processedPath);
                $pathDir = WFUtility::mb_dirname($processedPath);

                if ($filesystem->createFolder($pathDir, $name) === false) {
                    // Skip this entry if it can't be created
                    continue;
                }
            }

            // New associative key from the (possibly changed) path
            $newKey = md5($processedPath);

            // Finalize fields
            $item['path']   = $processedPath;
            $item['label']  = htmlspecialchars((string) $label, ENT_QUOTES, 'UTF-8');
            $item['prefix'] = $newKey;

            // Write into rebuilt array (last one wins on key collision)
            $newDir[$newKey] = $item;
        }

        return $newDir;
    }

    /**
     * Return the store entry that corresponds to a given path.
     *
     * @param  string $path    The path to look up (may be in "prefix:relative" form).
     * @param  bool   $withKey If true, return the full store array instead of just the matching entry.
     * @return array  The matching store entry, the full store if $withKey is true, or an empty array.
     */
    public function getDirectoryStoreFromPath($path, $withKey = false)
    {
        $prefix = $this->parsePath($path); // get the prefix and remove it from the path value

        $store = $this->getDirectoryStore();

        if (empty($prefix)) {
            // no prefix, so return the default store
            foreach ($store as $key => $value) {
                // is this path with the default store?
                if (WFUtility::safe_strpos($path, $value['path']) === 0) {
                    // set the prefix to the store key
                    $prefix = $key;
                    break;
                }
            }
        }

        if (isset($store[$prefix])) {
            if ($withKey) {
                return $store;
            }

            // return the store entry for the prefix
            return $store[$prefix];
        }

        return array();
    }

    /**
     * Resolve a path to its store entry without modifying the path variable.
     *
     * @param  string       $path The path to resolve (may contain a prefix).
     * @return array|string The store entry array, an array of all entries for an empty path, or the original path if unresolved.
     */
    private function getPathFromDirectoryStore($path)
    {
        $path = trim($path, '/');

        // find the correct entry in the directory store
        $store = $this->getDirectoryStore();

        if (empty($path)) {
            return array_values($store);
        }

        // get the path prefix
        $prefix = $this->getPathPrefix($path);

        // no prefix?
        if (empty($prefix)) {
            $values  = array_values($store);
            $default = array_shift($values);
            return $default;
        }

        if (isset($store[$prefix])) {
            return $store[$prefix];
        }

        // no prefix found, return the path
        return $path;
    }

    /**
     * Build the path variable substitution tables (patterns and replacements).
     * Results are statically cached per request; the onWfFileSystemGetPathVariables event
     * fires on every call to allow plugins to inject dynamic values.
     *
     * @return array Keys: path_pattern, path_replacement, websafe_textcase, websafe_mode, websafe_allow_spaces.
     */
    private function getPathVariables()
    {
        static $variables;

        if (!isset($variables)) {
            $app = Factory::getApplication();
            $user = Factory::getUser();
            $wf = WFApplication::getInstance();
            $profile = $wf->getProfile();

            $groups = UserHelper::getUserGroups($user->id);

            // get keys only
            $groups = array_keys($groups);

            // get the first group
            $group_id = array_shift($groups);

            if (is_int($group_id)) {
                // usergroup table
                $group = Table::getInstance('Usergroup');
                $group->load($group_id);
                // usertype
                $usertype = $group->title;
            } else {
                $usertype = $group_id;
            }

            $context = $app->input->getInt('context', null);

            $contextName = '';

            if (is_int($context)) {
                foreach (ComponentHelper::getComponents() as $component) {
                    if ($context == $component->id) {
                        $contextName = $component->option;
                        break;
                    }
                }
            }

            // Replace any path variables
            $path_pattern = array(
                '/\$id/',
                '/\$username/',
                '/\$name/',
                '/\$user(group|type)/',
                '/\$(group|profile)/',
                '/\$context/',
                '/\$hour/',
                '/\$day/',
                '/\$month/',
                '/\$year/',
            );

            $path_replacement = array(
                'id' => $user->id,
                'username' => $user->username,
                'name' => $user->name,
                'usertype' => $usertype,
                'profile' => $profile->name,
                'context' => $contextName,
                'hour' => date('H'),
                'day' => date('d'),
                'month' => date('m'),
                'year' => date('Y')
            );

            // expose variables
            $variables = compact('path_pattern', 'path_replacement');

            Factory::getApplication()->triggerEvent('onWfFileSystemBeforeGetPathVariables', array(&$variables));

            // convert to array values
            $path_replacement = array_values($variables['path_replacement']);
            $path_pattern = array_values($variables['path_pattern']);

            // get websafe options
            $websafe_textcase = $wf->getParam('editor.websafe_textcase', '');
            $websafe_mode = $wf->getParam('editor.websafe_mode', 'utf-8');
            $websafe_allow_spaces = $wf->getParam('editor.websafe_allow_spaces', '_');

            // implode textcase array to create string
            if (is_array($websafe_textcase)) {
                $websafe_textcase = implode(',', $websafe_textcase);
            }

            // expose variables
            $variables = compact('path_pattern', 'path_replacement', 'websafe_textcase', 'websafe_mode', 'websafe_allow_spaces');
        }

        Factory::getApplication()->triggerEvent('onWfFileSystemGetPathVariables', array(&$variables));

        return $variables;
    }

    /**
     * Apply path variable substitution and websafe normalisation to a path in place.
     *
     * @param  string &$path The path to transform.
     * @return string        The normalised path (also updated via the reference).
     */
    public function processPath(&$path)
    {
        $path = preg_replace($this->get('path_pattern', array()), $this->get('path_replacement', array()), $path);

        // split into path parts to preserve /
        $parts = explode('/', $path);

        // clean path parts
        $parts = WFUtility::makeSafe($parts, $this->get('websafe_mode', 'utf-8'), $this->get('websafe_allow_spaces', '_'), $this->get('websafe_textcase', ''));

        // join path parts
        $path = implode('/', $parts);

        $path = trim($path, '/');

        return $path;
    }

    /**
     * Resolve a filter path relative to the store's base path.
     *
     * @param array  $store  The store array containing 'path' and 'prefix'.
     * @param string $filter The filter path to resolve.
     *
     * @return string The resolved filter path.
     */
    private function resolveFilterPath($store, $filter)
    {
        // remove leading and trailing slash
        $filter = trim($filter, '/');

        // make the source relative to the store path, eg: stories => images/stories
        $filterPath = WFUtility::makePath($store['path'], $filter);

        // trim leading and trailing slash
        return trim($filterPath, '/');
    }

    /**
     * Check if a path is accessible based on the defined filters.
     *
     * @param string $path The path to check.
     *
     * @return bool True if access is allowed, false otherwise.
     */
    public function checkPathAccess($path)
    {
        $path = trim($path, '/');

        $filters = $this->get('filter');

        // no filters set, allow all
        if (empty($filters)) {
            return true;
        }

        $allowFilters = [];
        $denyFilters = [];

        $store = $this->getPathFromDirectoryStore($path);

        // Categorize filters into allow and deny lists
        foreach ($filters as $filter) {
            // remove leading and trailing slash    
            $filter = trim($filter, '/');

            if (strpos($filter, '+') === 0) {
                $filter = substr($filter, 1);

                $filterPath = $this->resolveFilterPath($store, $filter);

                $allowFilters[] = $filterPath;
            } else if (strpos($filter, '-') === 0) {
                $filter = ltrim($filter, '-');

                $filterPath = $this->resolveFilterPath($store, $filter);

                $denyFilters[] = $filterPath;
            } else {
                $filterPath = $this->resolveFilterPath($store, $filter);

                $denyFilters[] = $filterPath;
            }
        }

        $access = true; // Default deny policy

        // Check allow filters
        foreach ($allowFilters as $filter) {
            $access = false;

            // process path for variables, text case etc.
            $this->processPath($filter);

            // Allow if path is empty (root ancestor), exact match, an ancestor of the
            // filter (so the user can navigate into it), or a descendant of the filter.
            if (
                empty($path) ||
                $path === $filter ||
                strpos($filter, $path . '/') === 0 ||
                strpos($path, $filter . '/') === 0
            ) {
                $access = true;
                break;
            }
        }

        if ($access === false) {
            return false;
        }

        // path is empty so no deny filters applied
        if (empty($path)) {
            return true;
        }

        // explode path to array for deny filter matching
        $path_parts = explode('/', $path);

        // Check deny filters
        foreach ($denyFilters as $filter) {
            if (strpos($filter, '*') === 0) {
                $filter = substr($filter, 1);

                // process path for variables, text case etc.
                $this->processPath($filter);

                // explode to array
                $filter_parts = explode('/', $filter);

                // filter match
                if (false === empty(array_intersect($filter_parts, $path_parts))) {
                    $access = false;
                    break;
                }
            } else {
                // process path for variables, text case etc.
                $this->processPath($filter);

                if ($path === $filter) {
                    $access = false;
                    break;
                }
            }
        }

        return $access;
    }

    /**
     * @return string The filesystem base directory.
     */
    public function getBaseDir()
    {
        $filesystem = $this->getFileSystem();

        return $filesystem->getBaseDir();
    }

    /**
     * Get the list of files in a folder, filtered by path access rules.
     *
     * @param  string $relative The relative path of the folder.
     * @param  string $filter   A regex filter applied to file names.
     * @param  string $sort     Sort order string passed to the filesystem.
     * @param  int    $limit    Maximum results to return (0 = unlimited).
     * @param  int    $start    Result offset for pagination.
     * @return array  List of file items that pass the access check.
     */
    private function getFiles($relative, $filter = '.', $sort = '', $limit = 0, $start = 0)
    {
        $filesystem = $this->getFileSystem();
        $list = $filesystem->getFiles($relative, $filter, $sort, $limit, $start);

        $list = array_filter($list, function ($item) {
            // must have an id set
            if (empty($item['id'])) {
                return true;
            }

            $path = dirname($item['id']);

            return $this->checkPathAccess($path);
        });

        return $list;
    }

    /**
     * Get the list of folders in a folder, filtered by path access rules.
     *
     * @param  string $relative The relative path of the folder.
     * @param  string $filter   A regex filter applied to folder names.
     * @param  string $sort     Sort order string passed to the filesystem.
     * @param  int    $limit    Maximum results to return (0 = unlimited).
     * @param  int    $start    Result offset for pagination.
     * @return array  List of folder items that pass the access check.
     */
    private function getFolders($relative, $filter = '', $sort = '', $limit = 0, $start = 0)
    {
        $filesystem = $this->getFileSystem();
        $list = $filesystem->getFolders($relative, $filter, $sort, $limit, $start);

        $list = array_filter($list, function ($item) {
            if (empty($item['id'])) {
                return true;
            }

            return $this->checkPathAccess($item['id']);
        });

        return $list;
    }

    /**
     * Sanitize a search term for safe use in a regex pattern.
     * Strips characters outside a known-safe set, preg_quote's the result,
     * then restores wildcard '*' as the regex '.*'.
     *
     * @param  string $term The raw search term.
     * @return string The sanitized, regex-safe search term.
     */
    private static function sanitizeSearchTerm($term)
    {
        try {
            $query = preg_replace('#[^a-zA-Z0-9_\.\-\:~\pL\pM\pN\s\* ]#u', '', $term);
        } catch (\Exception $e) {
            $query = preg_replace('#[^a-zA-Z0-9_\.\-\:~\s\* ]#', '', $term);
        }

        if (is_null($query) || $query === false) {
            $query = preg_replace('#[^a-zA-Z0-9_\.\-\:~\s\* ]#', '', $term);
        }

        $query = trim($query);

        // quote first
        $query = preg_quote($query, '/');

        // then restore wildcards (escaped \* becomes real regex .*)
        $query = str_replace('\*', '.*', $query);

        return $query;
    }

    /**
     * Search files and folders by keyword or extension query.
     *
     * @param  string $path  Directory path to search within (empty = all configured roots).
     * @param  int    $limit Maximum results per type; 0 = unlimited.
     * @param  int    $start Result offset for pagination.
     * @param  string $query Search query — keywords and/or extension filters (e.g. "cat *.jpg").
     * @param  string $sort  Sort order passed to the filesystem.
     * @return array  Result array with 'files', 'folders', 'total', 'path', and 'search' keys.
     */
    public function searchItems($path, $limit = 25, $start = 0, $query = '', $sort = '')
    {
        $path = rawurldecode($path);
        WFUtility::checkPath($path);

        $result = array(
            'folders' => array(),
            'files' => array(),
            'total' => array(
                'folders' => 0,
                'files' => 0,
            ),
            'path' => '',
            'search' => true
        );

        // no query value? bail...
        if ($query == '') {
            return $result;
        }

        $filesystem = $this->getFileSystem();

        if (method_exists($filesystem, 'searchItems') === false) {
            return $this->getItems($path, $limit, $start, $query, $sort);
        }

        // define and configure seach parameters
        $filetypes = (array) $this->getFileTypes('array');

        // Split query by "OR" or "|" operators
        $terms = array_map('trim', preg_split('/\s*(?:\bOR\b|\|)\s*/i', $query, -1, PREG_SPLIT_NO_EMPTY));

        $extensions = [];
        $keywords = [];

        foreach ($terms as $term) {
            if (
                strpos($term, '.') === 0 ||                            // ".jpg"
                (strpos($term, '*.') === 0 && strlen($term) > 2)       // "*.jpg"
            ) {
                // It's an extension
                $extensions[] = WFUtility::makeSafe($term);
            } elseif ($term !== '') {
                // It's a keyword, clean and convert wildcards
                foreach (preg_split('/\s+/', $term, -1, PREG_SPLIT_NO_EMPTY) as $subterm) {
                    $keywords[] = self::sanitizeSearchTerm($subterm);
                }
            }
        }

        // Filter filetypes
        if (!empty($extensions)) {
            $filetypes = array_filter($filetypes, function ($value) use ($extensions) {
                return in_array($value, $extensions, true);
            });
        }

        // Build keyword regex (match any of the keywords, case-insensitive)
        $filter = '';

        if (!empty($keywords)) {
            $filter = '^(?i).*(' . implode('|', $keywords) . ').*';
        }

        // query filter
        /*$keyword = '^(?i).*' . $keyword . '.*';

        if ($query[0] === '.') {
            // clean query removing leading .
            $extension = WFUtility::makeSafe($query);

            $filetypes = array_filter($filetypes, function ($value) use ($extension) {
                return $value === $extension;
            });

            $filter = '';
        }*/

        // get search depth
        $depth = $this->get('search_depth', 3);

        // trim the passed in path if any
        $path = trim($path, '/');

        // no path value or root folder so get the default directories
        if (empty($path)) {
            $store = $this->getDirectoryStore();

            if (!empty($store)) {
                // case to array values as we don't need the keys
                $storeArray = array_values($store);
            }
        } else {
            // get the store array from the complex source path, eg: prefix:path
            $store = $this->getPathFromDirectoryStore($path);

            // extract the path from the complex source path, eg: prefix:path
            $path = $this->extractPath($path);

            $storeArray = array($store);
        }

        // search each store item
        foreach ($storeArray as $storeItem) {
            // define the prefix from the store array
            $prefix = $storeItem['prefix'];

            // make the source relative to the store path, eg: stories => images/stories
            $source = WFUtility::makePath($storeItem['path'], $path);

            // trim leading and trailing slash
            $source = trim($source, '/');

            $list = $filesystem->searchItems($source, $filter, $filetypes, $sort, $depth);

            $items = array_merge($list['folders'], $list['files']);

            // get properties for found items by type
            foreach ($items as $item) {
                $type = $item['type'];

                // remove the $store['path'] value from the beginning of the id, must be multibyte safe
                if (WFUtility::safe_strpos($item['id'], $storeItem['path']) === 0) {
                    $item['id'] = WFUtility::safe_substr($item['id'], WFUtility::safe_strlen($storeItem['path']));

                    // trim leading and trailing slash
                    $item['id'] = trim($item['id'], '/');
                }

                if ($type === 'files') {
                    $item['classes'] = '';

                    $item['path'] = WFUtility::makePath($storeItem['path'], $item['id']);

                    if (empty($item['properties'])) {
                        $item['properties'] = $filesystem->getFileDetails($item);
                    }
                }

                if ($type === 'folders') {
                    $item['path'] = WFUtility::makePath($storeItem['path'], $item['id']);

                    if (empty($item['properties'])) {
                        $item['properties'] = $filesystem->getFolderDetails($item);
                    }
                }

                $item['id'] = $prefix . ':' . htmlspecialchars($item['id'], ENT_QUOTES, 'UTF-8');

                $item['name'] = WFUtility::mb_basename($item['name']);
                $item['name'] = htmlspecialchars($item['name'], ENT_QUOTES, 'UTF-8');

                $result[$type][] = $item;
            }
        }

        // walk through the folders and files, reducing the result by the limit value if > 0
        if ($limit > 0) {
            $result['folders']  = array_slice($result['folders'], $start, $limit);
            $result['files']    = array_slice($result['files'], $start, $limit);
        }

        $result['total']['folder'] = count($result['folders']);
        $result['total']['files'] = count($result['files']);

        // Fire Event passing result as reference
        $this->fireEvent('onSearchItems', array(&$result));

        return $result;
    }

    /**
     * @param  string $source A source path.
     * @return string         The source path unchanged (hook for subclasses).
     */
    public function getRootDir($source)
    {
        return $source;
    }

    /**
     * Get file and folder listings for a directory.
     *
     * @param  string $source The current directory path (relative, or "prefix:relative" form).
     * @param  int    $limit  Maximum items to return per type (0 = unlimited).
     * @param  int    $start  Result offset for pagination.
     * @param  string $filter Optional name filter; prefix with '.' to filter by extension.
     * @param  string $sort   Sort order passed to the filesystem.
     * @return array  Result array with 'folders', 'files', 'total', and 'path' keys.
     */
    public function getItems($source, $limit = 25, $start = 0, $filter = '', $sort = '')
    {
        // decode path
        $source = rawurldecode($source);

        // check if source is a valid path
        WFUtility::checkPath($source);

        $filesystem = $this->getFileSystem();

        $files = array();
        $folders = array();

        clearstatcache();

        // trim source to path variable
        $path = trim($source, '/');

        // if a value is set process as possible return file, ie: check for prefix
        if ($path) {
            $prefix = $this->getPathPrefix($path);

            // may be a passed in value, eg: images/stories/fruit.jpg
            if (!$prefix) {
                // get source dir from path eg: images/stories/fruit.jpg = images/stories
                $path = $this->getSourceDir($path);
            }
        }

        // get the store array from the complex path path, eg: prefix:path
        $store = $this->getDirectoryStoreFromPath($path);

        // no path so get the default directories
        if (empty($path) || empty($store)) {
            $store = $this->getDirectoryStore();

            if (!empty($store)) {
                $storeArray = array_values($store);

                // defined list of directories
                if (count($storeArray) > 1) {
                    $folders = [];

                    foreach ($storeArray as $items) {
                        $folders[] = array(
                            'id'            => $items['prefix'] . ':',
                            'name'          => $items['label'],
                            'type'          => 'folders',
                            'properties'    => array(),
                        );
                    }

                    // return an array of root folder items
                    return array(
                        'folders' => $folders,
                        'files' => array(),
                        'total' => array(
                            'folders' => count($folders),
                            'files' => 0,
                        ),
                    );
                }

                // no defined directories, so use the first one for backward compatibility
                $store = $storeArray[0];
            }
        } else {
            // get the store array from the complex path path, eg: prefix:path
            if (!$prefix) {
                // make relative to the store path, eg: images/stories => stories
                if (WFUtility::safe_strpos($path, $store['path']) === 0) {
                    $path = WFUtility::safe_substr($path, WFUtility::safe_strlen($store['path']));
                    // trim
                    $path = trim($path, '/');
                }
            } else {
                // extract the path from the complex source path, eg: prefix:path
                $path = $this->extractPath($path);
            }
        }

        // define the prefix from the store array
        $prefix = $store['prefix'];

        // make the source relative to the store path, eg: stories => images/stories
        $fullpath = WFUtility::makePath($store['path'], $path);

        // revert to store path if the path is not a directory
        if (!$filesystem->is_dir($fullpath)) {
            $fullpath = $store['path'];
            $path = ''; // reset path to empty
        }

        // trim leading and trailing slash
        $fullpath = trim($fullpath, '/');

        $filetypes = (array) $this->getFileTypes('array');

        $name = '';

        if ($filter) {
            if ($filter[0] == '.') {
                $ext = WFUtility::makeSafe($filter);

                for ($i = 0; $i < count($filetypes); ++$i) {
                    if (preg_match('#^' . $ext . '#', $filetypes[$i]) === false) {
                        unset($filetypes[$i]);
                    }
                }
            } else {
                $name = '^(?i).*' . WFUtility::makeSafe($filter) . '.*';
            }
        }

        // get file list by filter
        $files = $this->getFiles($fullpath, $name . '\.(?i)(' . implode('|', $filetypes) . ')$', $sort, $limit, $start);

        if (empty($filter) || $filter[0] != '.') {
            // get folder list
            $folders = $this->getFolders($fullpath, '^(?i).*' . WFUtility::makeSafe($filter) . '.*', $sort, $limit, $start);
        }

        $folderArray = array();
        $fileArray = array();

        $items = array_merge($folders, $files);

        if (count($items)) {
            if (intval($limit) > 0) {
                $items = array_slice($items, $start, $limit);
            }

            foreach ($items as $item) {
                $item['classes'] = '';

                // remove the $store['path'] value from the beginning of the id, must be multibyte safe
                if (WFUtility::safe_strpos($item['id'], $store['path']) === 0) {
                    $item['id'] = WFUtility::safe_substr($item['id'], WFUtility::safe_strlen($store['path']));
                }

                // trim $id removing leading and trailing slashes
                $item['id'] = trim($item['id'], '/');
                // encode id for html
                $item['id'] = htmlspecialchars($item['id'], ENT_QUOTES, 'UTF-8');

                // ensure name is relative
                $item['name'] = WFUtility::mb_basename($item['name']);

                // encode name for html
                $item['name'] = htmlspecialchars($item['name'], ENT_QUOTES, 'UTF-8');

                // create path
                $item['path'] = WFUtility::makePath($store['path'], $item['id']);

                // add the path prefix to the id
                $item['id'] = $prefix . ':' . $item['id'];

                if ($item['type'] == 'folders') {
                    if (empty($item['properties'])) {
                        $item['properties'] = $filesystem->getFolderDetails($item);
                    }

                    $folderArray[] = $item;
                } else {
                    // check for selected item
                    $item['selected'] = $filesystem->isMatch($item['url'], $source);

                    if (empty($item['properties'])) {
                        $item['properties'] = $filesystem->getFileDetails($item);
                    }

                    $fileArray[] = $item;
                }
            }
        }

        $result = array(
            'folders' => $folderArray,
            'files' => $fileArray,
            'total' => array(
                'folders' => count($folders),
                'files' => count($files),
            ),
            'path' => $prefix . ':' . $path,
        );

        // Fire Event passing result as reference
        $this->fireEvent('onGetItems', array(&$result));

        return $result;
    }

    /**
     * Get the immediate child folders for a tree node.
     *
     * @param  string $path The directory path (empty = root).
     * @return array  Array with 'label' and 'folders' keys.
     */
    public function getTreeItem($path = "")
    {
        $path = rawurldecode($path);

        WFUtility::checkPath($path);

        $path = trim($path, '/');

        $folders = array();

        $label = '';

        if (empty($path)) {
            $store = $this->getDirectoryStore();
            $storeArray = array_values($store);

            if (count($storeArray) > 1) {
                foreach ($storeArray as $item) {
                    $folders[] = array(
                        'id'    => $item['prefix'] . ':',
                        'name'  => $item['label'],
                        'path'  => $item['path'],
                        'class' => 'folder'
                    );
                }
            } else {
                $store = $storeArray[0];
                $folders = $this->getFolders($store['path']);

                $label = isset($store['label']) ? $store['label'] : '';

                array_walk($folders, function (&$item) use ($store) {
                    $path = $item['id'];

                    // remove the $store['path'] value from the beginning of the id, must be multibyte safe
                    if (WFUtility::safe_strpos($item['id'], $store['path']) === 0) {
                        $item['id'] = WFUtility::safe_substr($item['id'], WFUtility::safe_strlen($store['path']));
                    }

                    $item['id'] = trim($item['id'], '/');
                    $path = trim($path, '/');

                    $item['id']     = $store['prefix'] . ':' . $item['id'];
                    $item['path']   = $path;
                    $item['class']  = 'folder';
                });
            }
        } else {
            // get the store array from the complex source path, eg: prefix:path
            $store = $this->getDirectoryStoreFromPath($path);

            // extract the path from the complex source path, eg: prefix:path
            $path = $this->extractPath($path);

            // make the source relative to the store path, eg: stories => images/stories
            $path = WFUtility::makePath($store['path'], $path);

            // get source dir from path eg: images/stories/fruit.jpg = images/stories
            $source = $this->getSourceDir($path);

            // get folder list
            $folders = $this->getFolders($source);

            array_walk($folders, function (&$item) use ($store, $path) {
                // remove the $store['path'] value from the beginning of the id, must be multibyte safe
                if (WFUtility::safe_strpos($item['id'], $store['path']) === 0) {
                    $item['id'] = WFUtility::safe_substr($item['id'], WFUtility::safe_strlen($store['path']));
                }

                $item['id'] = trim($item['id'], '/');

                $item['id']     = $store['prefix'] . ':' . $item['id'];
                $item['path']   = WFUtility::makePath($path, $item['name']);
                $item['class']  = 'folder';
            });
        }

        $result = array(
            'label'     => $label,
            'folders'   => $folders
        );

        return $result;
    }

    /**
     * Build a full folder tree as an HTML string.
     *
     * @param  string $path The starting directory path (empty = root).
     * @return string The rendered HTML tree.
     */
    public function getTree($path = '')
    {
        // decode path
        $path = rawurldecode($path);

        WFUtility::checkPath($path);

        $result = $this->getTreeItems($path);

        return $result;
    }

    /**
     * Recursively render folder tree nodes as an HTML list.
     *
     * @param  string $path The current directory path.
     * @param  bool   $root Whether this is the outermost call (wraps output in a root list item).
     * @param  bool   $init Whether this is the initial call (loads root node data).
     * @return string The rendered HTML fragment.
     */
    public function getTreeItems($path, $root = true, $init = true)
    {
        $result = '';

        static $treedir = null;

        $folders = [];

        if ($init) {
            $treedir = $path;

            $items = $this->getTreeItem();
            $folders = $items['folders'];

            $label = $items['label'] ? $items['label'] :  Text::_('WF_LABEL_HOME', 'Home');

            if ($root) {
                $result .= '
                <ul>
                    <li data-id="/" class="uk-tree-open uk-tree-root uk-padding-remove">
                        <div class="uk-tree-row">
                            <a href="#">
                                <span class="uk-tree-icon" role="presentation">
                                    <i class="uk-icon uk-icon-home"></i>
                                </span>
                                <span class="uk-tree-text">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>
                            </a>
                        </div>
                ';
            }
        } else {
            $items = $this->getTreeItem($path);
            $folders = $items['folders'];
        }

        if (count($folders)) {
            $result .= '<ul class="uk-tree-node">';

            $open = false;

            foreach ($folders as $folder) {
                $id = trim($folder['id'], '/');

                if ($treedir) {
                    // resolve $treedir
                    $resolved = $this->resolvePath($treedir);

                    // check if the folder is open, ie: the path matches the current directory
                    $open = (bool) preg_match('#' . preg_quote($folder['path']) . '\b#', $resolved);
                }

                $result .= '
                <li data-id="' . htmlspecialchars($id, ENT_QUOTES, 'UTF-8') . '" class="' . ($open ? 'uk-tree-open' : '') . '">
                    <div class="uk-tree-row">
                        <a href="#">
                            <span class="uk-tree-icon" role="presentation"></span>
                            <span class="uk-tree-text uk-text-truncate" title="' . $folder['name'] . '">' . $folder['name'] . '</span>
                        </a>
                    </div>';

                if ($open) {
                    $result .= $this->getTreeItems($id, false, false);
                }

                $result .= '</li>';
            }

            $result .= '</ul>';
        }

        if ($init && $root) {
            $result .= '</li></ul>';
        }

        $init = false;

        return $result;
    }

    /**
     * Get a folder's properties (date, file count, etc.).
     *
     * @param  string $dir Relative folder path.
     * @return array  Folder properties array.
     */
    public function getFolderDetails($dir)
    {
        WFUtility::checkPath($dir);

        $filesystem = $this->getFileSystem();

        // get array with folder date and content count eg: array('date'=>'00-00-000', 'folders'=>1, 'files'=>2);
        return $filesystem->getFolderDetails($dir);
    }

    /**
     * Get a file's properties (size, date, dimensions, etc.).
     *
     * @param  string $file Relative file path.
     * @return array  File properties array.
     */
    public function getFileDetails($file)
    {
        WFUtility::checkPath($file);

        $filesystem = $this->getFileSystem();

        // get array with folder date and content count eg: array('date'=>'00-00-000', 'folders'=>1, 'files'=>2);
        return $filesystem->getFileDetails($file);
    }

    /**
     * Create default actions based on access.
     */
    private function addDefaultActions()
    {
        if ($this->checkFeature('help')) {
            $this->addAction('help', array('title' => Text::_('WF_BUTTON_HELP')));
        }

        if ($this->checkFeature('upload')) {
            $this->addAction('upload');
            $this->setRequest(array($this, 'upload'));
        }

        if ($this->checkFeature('create', 'folder')) {
            $this->addAction('folder_new');
            $this->setRequest(array($this, 'folderNew'));
        }
    }

    /**
     * Add an action to the list.
     *
     * @param string $name    Action name
     * @param array  $options Array of options
     */
    public function addAction($name, $options = array())
    {
        if (!is_array($options)) {
            list($name, $options['icon'], $options['action'], $options['title']) = func_get_args();
        }

        $options = array_merge(array('name' => $name), $options);

        // set some defaults
        if (!array_key_exists('icon', $options)) {
            $options['icon'] = '';
        }

        if (!array_key_exists('action', $options)) {
            $options['action'] = '';
        }

        if (!array_key_exists('title', $options)) {
            $options['title'] = Text::_('WF_BUTTON_' . strtoupper($name));
        }

        $this->_actions[$name] = $options;
    }

    /**
     * Get all actions.
     *
     * @return object
     */
    private function getActions()
    {
        return array_reverse($this->_actions);
    }

    /**
     * Remove an action from the list by name.
     *
     * @param string $name Action name to remove
     */
    public function removeAction($name)
    {
        if (isset($this->_actions[$name])) {
            unset($this->_actions[$name]);
        }
    }

    /**
     * Create all standard buttons based on access.
     */
    private function addDefaultButtons()
    {
        if ($this->checkFeature('delete', 'folder')) {
            $this->addButton('folder', 'delete', array('multiple' => true));

            $this->setRequest(array($this, 'deleteItem'));
        }
        if ($this->checkFeature('rename', 'folder')) {
            $this->addButton('folder', 'rename');

            $this->setRequest(array($this, 'renameItem'));
        }
        if ($this->checkFeature('move', 'folder')) {
            $this->addButton('folder', 'copy', array('multiple' => true));
            $this->addButton('folder', 'cut', array('multiple' => true));

            $this->addButton('folder', 'paste', array('multiple' => true, 'trigger' => true));

            $this->setRequest(array($this, 'copyItem'));
            $this->setRequest(array($this, 'moveItem'));
        }
        if ($this->checkFeature('rename', 'file')) {
            $this->addButton('file', 'rename');

            $this->setRequest(array($this, 'renameItem'));
        }
        if ($this->checkFeature('delete', 'file')) {
            $this->addButton('file', 'delete', array('multiple' => true));

            $this->setRequest(array($this, 'deleteItem'));
        }
        if ($this->checkFeature('move', 'file')) {
            $this->addButton('file', 'copy', array('multiple' => true));
            $this->addButton('file', 'cut', array('multiple' => true));

            $this->addButton('file', 'paste', array('multiple' => true, 'trigger' => true));

            $this->setRequest(array($this, 'copyItem'));
            $this->setRequest(array($this, 'moveItem'));
        }
        $this->addButton('file', 'view', array('restrict' => $this->getViewable()));
    }

    /**
     * Add a button to the button bar.
     *
     * @param  string $type    Button type: 'file' or 'folder'.
     * @param  string $name    Button name (used to derive the default title and icon).
     * @param  array  $options Optional overrides: icon, action, title, multiple, trigger, restrict.
     * @return void
     */
    public function addButton($type, $name, $options = array())
    {
        $options = array_merge(array('name' => $name), $options);

        // set some defaults
        if (!array_key_exists('icon', $options)) {
            $options['icon'] = '';
        }

        if (!array_key_exists('action', $options)) {
            $options['action'] = '';
        }

        if (!array_key_exists('title', $options)) {
            $options['title'] = Text::_('WF_BUTTON_' . strtoupper($name));
        }

        if (!array_key_exists('multiple', $options)) {
            $options['multiple'] = false;
        }

        if (!array_key_exists('trigger', $options)) {
            $options['trigger'] = false;
        }

        if (!array_key_exists('restrict', $options)) {
            $options['restrict'] = '';
        }

        $this->_buttons[$type][$name] = $options;
    }

    /**
     * Return an object list of all buttons.
     *
     * @return object
     */
    private function getButtons()
    {
        return $this->_buttons;
    }

    /**
     * Remove a button.
     *
     * @param string $type Button type
     * @param string $name Button name
     */
    public function removeButton($type, $name)
    {
        if (array_key_exists($name, $this->_buttons[$type])) {
            unset($this->_buttons[$type][$name]);
        }
    }

    /**
     * Change a buttons properties.
     *
     * @param string $type Button type
     * @param string $name Button name
     * @param array $keys Button keys
     */
    public function changeButton($type, $name, $keys)
    {
        foreach ($keys as $key => $value) {
            if (isset($this->_buttons[$type][$name][$key])) {
                $this->_buttons[$type][$name][$key] = $value;
            }
        }
    }

    /**
     * Add an event.
     *
     * @param string $name     Event name
     * @param string $function Event function name
     */
    public function addEvent($name, $function)
    {
        $this->_events[$name] = $function;
    }

    /**
     * Execute a registered event handler.
     *
     * @param  string     $name Event name.
     * @param  array|null $args Arguments to pass to the handler.
     * @return mixed      The handler's return value, or an empty array if no handler is registered.
     */
    protected function fireEvent($name, $args = null)
    {
        if (array_key_exists($name, $this->_events)) {
            $event = $this->_events[$name];

            if (is_array($event)) {
                return call_user_func_array($event, $args);
            } else {
                return call_user_func($event, $args);
            }
        }

        return array();
    }

    /**
     * Validate an uploaded file: presence, upload integrity, name safety, content safety,
     * size, and MIME type. Deletes the temp file and throws on any failure.
     *
     * @param  array $file $_FILES entry for the uploaded file.
     * @return true  Always returns true on success.
     * @throws InvalidArgumentException On any validation failure.
     */
    private function validateUploadedFile($file)
    {
        // check the POST data array
        if (empty($file) || empty($file['tmp_name'])) {
            throw new InvalidArgumentException('Upload Failed: No data');
        }

        // check for tmp_name and is valid uploaded file
        if (!is_uploaded_file($file['tmp_name'])) {
            throw new InvalidArgumentException('Upload Failed: Not an uploaded file');
        }

        $upload = $this->get('upload');

        // reject null bytes in the filename before any further processing
        if (strpos($file['name'], "\x00") !== false) {
            @unlink($file['tmp_name']);
            throw new InvalidArgumentException('Upload Failed: The file name contains a null byte.');
        }

        // fetch profile-allowed extensions first so they can inform filename validation
        $allowed = (array) $this->getFileTypes('array');

        // validate the full filename, passing profile-allowed extensions so explicitly permitted
        // types (e.g. svg) are not blocked on the final extension
        if (WFUtility::validateFileName($file['name'], $allowed) === false) {
            @unlink($file['tmp_name']);
            throw new InvalidArgumentException(Text::_('WF_MANAGER_UPLOAD_INVALID_EXT_ERROR'));
        }

        // now safe to extract the extension from the validated filename
        $ext = WFUtility::getExtension($file['name'], true);

        // check file content for PHP tags, phar stubs, invalid images, etc.
        WFUtility::isSafeFile($file, $allowed);

        if (is_array($allowed) && !empty($allowed) && in_array($ext, $allowed) === false) {
            @unlink($file['tmp_name']);
            throw new InvalidArgumentException(Text::_('WF_MANAGER_UPLOAD_INVALID_EXT_ERROR'));
        }

        $size = round(filesize($file['tmp_name']) / 1024);

        if (empty($upload['max_size'])) {
            $upload['max_size'] = 10240;
        }

        // validate size
        if ($size > (int) $upload['max_size']) {
            @unlink($file['tmp_name']);

            throw new InvalidArgumentException(Text::sprintf('WF_MANAGER_UPLOAD_SIZE_ERROR', $file['name'], $size, $upload['max_size']));
        }

        // validate mimetype
        if ($upload['validate_mimetype']) {
            if (WFMimeType::check($file['name'], $file['tmp_name']) === false) {
                @unlink($file['tmp_name']);
                throw new InvalidArgumentException(Text::_('WF_MANAGER_UPLOAD_MIME_ERROR'));
            }
        }

        return true;
    }

    /**
     * Validate the upload destination directory and quota limits.
     * Returns the resolved directory path on success; throws on any failure.
     *
     * @param  string $dir    Raw destination path (post-event)
     * @param  array  $upload Upload settings from profile config
     * @return string         Resolved, validated directory path
     */
    private function validateUploadDirectory($dir, $upload)
    {
        WFUtility::checkPath($dir);

        if (empty($dir)) {
            $dir = $this->getDefaultPath();
        }

        $dir = $this->resolvePath($dir);

        if (empty($dir)) {
            throw new InvalidArgumentException('Upload Failed: Invalid target directory');
        }

        $filesystem = $this->getFileSystem();

        if (!$filesystem->is_dir($dir)) {
            throw new InvalidArgumentException('Upload Failed: The target directory does not exist');
        }

        if (!$this->checkPathAccess($dir)) {
            throw new InvalidArgumentException('Upload Failed: Access to the target directory is restricted');
        }

        if (!empty($upload['total_files'])) {
            if ($filesystem->countFiles($dir, true) > $upload['total_files']) {
                throw new InvalidArgumentException(Text::_('WF_MANAGER_FILE_LIMIT_ERROR'));
            }
        }

        if (!empty($upload['total_size'])) {
            $size = $filesystem->getTotalSize($dir);

            if (($size / 1024 / 1024) > $upload['total_size']) {
                throw new InvalidArgumentException(Text::_('WF_MANAGER_FILE_SIZE_LIMIT_ERROR'));
            }
        }

        return $dir;
    }

    /**
     * Handle a file upload request.
     *
     * @return array Result array with 'files' on success or 'error' on failure.
     * @throws Exception              If the upload feature is not permitted.
     * @throws InvalidArgumentException On validation or filesystem failure.
     */
    public function upload()
    {
        // Check for request forgeries
        Session::checkToken('request') or jexit(Text::_('JINVALID_TOKEN'));

        // check for feature access
        if (!$this->checkFeature('upload')) {
            throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
        }

        $app = Factory::getApplication();

        $filesystem = $this->getFileSystem();

        // get uploaded file
        $file = $app->input->files->get('file', array(), 'raw');

        // validate file
        $this->validateUploadedFile($file);

        $allowed = (array) $this->getFileTypes('array');

        // derive and normalise the extension from the actual uploaded file
        $ext = WFUtility::getExtension($file['name'], true);
        $ext = trim($ext);
        $ext = WFUtility::makeSafe($ext, $this->get('websafe_mode', 'utf-8'), $this->get('websafe_spaces'), $this->get('websafe_textcase'));

        if (empty($ext) || $ext === $file['name']) {
            @unlink($file['tmp_name']);
            throw new InvalidArgumentException('Upload Failed: The file name does not contain a valid extension.');
        }

        // validate and process the user-supplied destination name
        $name = (string) $app->input->get('name', $file['name'], 'STRING');
        $name = rawurldecode($name);

        if (WFUtility::validateFileName($name, $allowed) === false) {
            @unlink($file['tmp_name']);
            throw new InvalidArgumentException('Upload Failed: The file name is invalid.');
        }

        try {
            WFUtility::checkPath($name);
        } catch (InvalidArgumentException $e) {
            @unlink($file['tmp_name']);
            throw $e;
        }

        $name = WFUtility::stripExtension($name);
        $name = WFUtility::makeSafe($name, $this->get('websafe_mode', 'utf-8'), $this->get('websafe_spaces'), $this->get('websafe_textcase'));

        if (WFUtility::validateFileName($name, $allowed) === false) {
            @unlink($file['tmp_name']);
            throw new InvalidArgumentException('Upload Failed: The file name is invalid.');
        }

        $upload = $this->get('upload');

        if ($upload['add_random']) {
            $name = $name . '_' . substr(md5(uniqid(rand(), 1)), 0, 5);
        }

        $name = $name . '.' . $ext;

        $dir = (string) $app->input->get('upload-dir', '', 'STRING');
        $dir = rawurldecode($dir);

        $this->fireEvent('onBeforeUpload', array(&$file, &$dir, &$name));

        // re-validate name in case an event handler modified it
        if (WFUtility::validateFileName($name, $allowed) === false) {
            @unlink($file['tmp_name']);
            throw new InvalidArgumentException('Upload Failed: The file name is invalid.');
        }

        try {
            WFUtility::checkPath($name);
        } catch (InvalidArgumentException $e) {
            @unlink($file['tmp_name']);
            throw $e;
        }

        try {
            $dir = $this->validateUploadDirectory($dir, $upload);
        } catch (InvalidArgumentException $e) {
            @unlink($file['tmp_name']);
            throw $e;
        }

        $result = $filesystem->upload('multipart', trim($file['tmp_name']), $dir, $name);

        @unlink($file['tmp_name']);

        // upload finished
        if ($result instanceof WFFileSystemResult) {
            if (!$result->state) {
                if (empty($result->message)) {
                    $result->message = Text::_('WF_MANAGER_UPLOAD_ERROR');
                }

                $result->code = 103;
            }

            if ($result->state === true) {
                $name = WFUtility::mb_basename($result->path);

                if (empty($result->url)) {
                    /*$relative = WFUtility::makePath($dir, $name);
                    $result->url = WFUtility::makePath($filesystem->getBaseURL(), $relative);*/

                    $result->url = WFUtility::makePath($dir, $name);
                }

                // trim slashes
                $result->url = trim($result->url, '/');

                // run events
                $data = $this->fireEvent('onUpload', array($result->path, $result->url));

                $data['name'] = $name;

                $this->setResult($data, 'files');
            } else {
                $this->setResult($result->message, 'error');
            }
        }

        return $this->getResult();
    }

    /**
     * Delete one or more files or folders.
     *
     * @param  string $items Comma-separated list of relative paths to delete.
     * @return array  Result array with deleted items or error messages.
     * @throws Exception              If the delete feature is not permitted.
     * @throws InvalidArgumentException On path or access validation failure.
     */
    public function deleteItem($items)
    {
        // check for feature access
        if (!$this->checkFeature('delete', 'folder') && !$this->checkFeature('delete', 'file')) {
            throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
        }

        $filesystem = $this->getFileSystem();
        $items = explode(',', rawurldecode((string) $items));

        foreach ($items as $item) {
            // decode and cast as string
            $item = (string) rawurldecode($item);

            // check path
            WFUtility::checkPath($item);

            $item = $this->resolvePath($item);

            if ($filesystem->is_file($item)) {
                if ($this->checkFeature('delete', 'file') === false) {
                    throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
                }

                // check file name is not blocked (executable extensions etc.)
                if (WFUtility::validateFileName(WFUtility::mb_basename($item)) === false) {
                    throw new InvalidArgumentException('Delete Failed: The file name is invalid.');
                }

                // check extension is allowed
                $ext     = WFUtility::getExtension($item, true);
                $allowed = (array) $this->getFileTypes('array');

                if (is_array($allowed) && !empty($allowed) && in_array($ext, $allowed) === false) {
                    throw new InvalidArgumentException('Delete Failed: Invalid file extension.');
                }

                $path = $item;
            } elseif ($filesystem->is_dir($item)) {
                if ($this->checkFeature('delete', 'folder') === false) {
                    throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
                }

                $path = dirname($item);
            } else {
                throw new InvalidArgumentException('Delete Failed: Item does not exist.');
            }

            // check access
            if (!$this->checkPathAccess($path)) {
                throw new InvalidArgumentException('Delete Failed: Access to the target directory is restricted');
            }

            $result = $filesystem->delete($item);

            if ($result instanceof WFFileSystemResult) {
                if (!$result->state) {
                    if ($result->message) {
                        $this->setResult($result->message, 'error');
                    } else {
                        $this->setResult(Text::sprintf('WF_MANAGER_DELETE_' . strtoupper($result->type) . '_ERROR', WFUtility::mb_basename($item)), 'error');
                    }
                } else {
                    $this->fireEvent('on' . ucfirst($result->type) . 'Delete', array($item));
                    $this->setResult($item, $result->type);
                }
            }
        }

        return $this->getResult();
    }

    /**
     * Rename a file or folder.
     * Source and destination are read via func_get_args() for legacy compatibility.
     *
     * @return array Result array with renamed item data or error messages.
     * @throws Exception              If the rename feature is not permitted.
     * @throws InvalidArgumentException On path or access validation failure.
     */
    public function renameItem()
    {
        // check for feature access
        if (!$this->checkFeature('rename', 'folder') && !$this->checkFeature('rename', 'file')) {
            throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
        }

        $args = func_get_args();

        $source = array_shift($args);
        $destination = array_shift($args);

        // decode and cast as string
        $source = (string) rawurldecode($source);

        // decode and cast as string
        $destination = (string) rawurldecode($destination);

        WFUtility::checkPath($source);
        WFUtility::checkPath($destination);

        $allowed = (array) $this->getFileTypes('array');

        // check source file name is not blocked (executable extensions etc.)
        if (WFUtility::validateFileName(WFUtility::mb_basename($source)) === false) {
            throw new InvalidArgumentException('Rename Failed: The source file name is invalid.');
        }

        // check for extension in destination name
        if (WFUtility::validateFileName($destination, $allowed) === false) {
            throw new InvalidArgumentException('Rename Failed: The file name is invalid.');
        }

        // extract the path from the complex path, removing the prefix
        $source = $this->resolvePath($source);

        $filesystem = $this->getFileSystem();

        if ($filesystem->is_file($source)) {
            if ($this->checkFeature('rename', 'file') === false) {
                throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
            }

            $path = dirname($source);
        } elseif ($filesystem->is_dir($source)) {
            if ($this->checkFeature('rename', 'folder') === false) {
                throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
            }

            $path = $source;
        } else {
            throw new InvalidArgumentException('Rename Failed: Item does not exist.');
        }

        // check access
        if (!$this->checkPathAccess($path)) {
            throw new InvalidArgumentException('Rename Failed: Access to the target directory is restricted');
        }

        // apply filesystem options
        $destination = WFUtility::makeSafe($destination, $this->get('websafe_mode'), $this->get('websafe_spaces'), $this->get('websafe_textcase'));
        $result = $filesystem->rename($source, $destination, $args);

        if ($result instanceof WFFileSystemResult) {
            if (!$result->state) {
                $this->setResult(Text::sprintf('WF_MANAGER_RENAME_' . strtoupper($result->type) . '_ERROR', WFUtility::mb_basename($source)), 'error');
                if ($result->message) {
                    $this->setResult($result->message, 'error');
                }
            } else {
                $data = array(
                    'name' => WFUtility::mb_basename($result->path),
                );

                $event = $this->fireEvent('on' . ucfirst($result->type) . 'Rename', array($destination));

                // merge event data with default values
                $data = array_merge($data, $event);

                $this->setResult($data, $result->type);
            }
        }

        return $this->getResult();
    }

    /**
     * Copy one or more files or folders to a destination directory.
     *
     * @param  string $items       Comma-separated list of relative paths to copy.
     * @param  string $destination Relative path of the destination directory.
     * @param  string $conflict    Conflict resolution: 'copy', 'replace', or empty to prompt.
     * @return array  Result array with copied item data or error messages.
     * @throws Exception              If the move feature is not permitted.
     * @throws InvalidArgumentException On path or access validation failure.
     */
    public function copyItem($items, $destination, $conflict = '')
    {
        // check for feature access
        if (!$this->checkFeature('move', 'folder') && !$this->checkFeature('move', 'file')) {
            throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
        }

        $filesystem = $this->getFileSystem();

        $items = explode(',', rawurldecode((string) $items));

        // decode and cast as string
        $destination = (string) rawurldecode($destination);

        // check destination path
        WFUtility::checkPath($destination);

        // extract the path from the complex path, removing the prefix
        $destination = $this->resolvePath($destination);

        if (empty($destination)) {
            throw new InvalidArgumentException('Copy Failed:Invalid destination path.');
        }

        $allowed = (array) $this->getFileTypes('array');

        // check for extension in destination name
        if (WFUtility::validateFileName($destination, $allowed) === false) {
            throw new InvalidArgumentException('Copy Failed: The file name is invalid.');
        }

        // check path exists
        if (!$filesystem->is_dir($destination)) {
            throw new InvalidArgumentException('Copy Failed: The target directory does not exist');
        }

        // check access
        if (!$this->checkPathAccess($destination)) {
            throw new InvalidArgumentException('Copy Failed: Access to the target directory is restricted');
        }

        foreach ($items as $item) {
            // decode and cast as string
            $item = (string) rawurldecode($item);

            // check source path
            WFUtility::checkPath($item);

            if (WFUtility::validateFileName($item, $allowed) === false) {
                throw new InvalidArgumentException('Copy Failed: The file name is invalid.');
            }

            $item = $this->resolvePath($item);

            if ($filesystem->is_file($item)) {
                if ($this->checkFeature('move', 'file') === false) {
                    throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
                }

                $path = dirname($item);
            } elseif ($filesystem->is_dir($item)) {
                if ($this->checkFeature('move', 'folder') === false) {
                    throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
                }

                $path = $item;
            } else {
                throw new InvalidArgumentException('Copy Failed: Item does not exist.');
            }

            $target = WFUtility::makePath($destination, WFUtility::mb_basename($item));

            if ($filesystem->is_file($target)) {
                // target is the same as the source so paste as copy
                if ($target === $item) {
                    $conflict = 'copy';
                    // file exists and is being copied into a new folder
                } else {
                    // conflict action not set so confirm
                    if (!$conflict) {
                        $this->setResult($item, 'confirm');
                        return $this->getResult();
                    }
                }
            }

            // check access
            if (!$this->checkPathAccess($path)) {
                throw new InvalidArgumentException('Copy Failed: Access to the target directory is restricted');
            }

            $result = $filesystem->copy($item, $destination, $conflict);

            if ($result instanceof WFFileSystemResult) {
                if (!$result->state) {
                    if ($result->message) {
                        $this->setResult($result->message, 'error');
                    } else {
                        $this->setResult(Text::sprintf('WF_MANAGER_COPY_' . strtoupper($result->type) . '_ERROR', WFUtility::mb_basename($item)), 'error');
                    }
                } else {
                    $data = array(
                        'name' => $filesystem->toRelative($result->path),
                    );

                    $event = $this->fireEvent('on' . ucfirst($result->type) . 'Copy', array($item));

                    // merge event data with default values
                    $data = array_merge($data, $event);

                    $this->setResult($data, $result->type);
                }
            }
        }

        return $this->getResult();
    }

    /**
     * Move one or more files or folders to a destination directory.
     *
     * @param  string $items       Comma-separated list of relative paths to move.
     * @param  string $destination Relative path of the destination directory.
     * @param  bool   $overwrite   Whether to overwrite existing items at the destination.
     * @return array  Result array with moved item data or error messages.
     * @throws Exception              If the move feature is not permitted.
     * @throws InvalidArgumentException On path or access validation failure.
     */
    public function moveItem($items, $destination, $overwrite = false)
    {
        // check for feature access
        if (!$this->checkFeature('move', 'folder') && !$this->checkFeature('move', 'file')) {
            throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
        }

        $filesystem = $this->getFileSystem();

        $items = explode(',', rawurldecode((string) $items));

        // decode and cast as string
        $destination = (string) rawurldecode($destination);

        // check destination path
        WFUtility::checkPath($destination);

        // resolve the path to the directory store, eg: files/foo.pdf => images/files/foo.pdf
        $destination = $this->resolvePath($destination);

        if (empty($destination)) {
            throw new InvalidArgumentException('Move Failed: The destination path is invalid.');
        }

        $allowed = (array) $this->getFileTypes('array');

        // check for extension in destination name
        if (WFUtility::validateFileName($destination, $allowed) === false) {
            throw new InvalidArgumentException('Move Failed: The file name is invalid.');
        }

        // check path exists
        if (!$filesystem->is_dir($destination)) {
            throw new InvalidArgumentException('Move Failed: The target directory does not exist');
        }

        // check access
        if (!$this->checkPathAccess($destination)) {
            throw new InvalidArgumentException('Move Failed: Access to the target directory is restricted');
        }

        foreach ($items as $item) {
            // decode and cast as string
            $item = (string) rawurldecode($item);

            // check source path
            WFUtility::checkPath($item);

            // extract the path from the complex path, removing the prefix
            $item = $this->resolvePath($item);

            if (WFUtility::validateFileName($item, $allowed) === false) {
                throw new InvalidArgumentException('Move Failed: The file name is invalid.');
            }

            if ($filesystem->is_file($item)) {
                if ($this->checkFeature('move', 'file') === false) {
                    throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
                }
            } elseif ($filesystem->is_dir($item)) {
                if ($this->checkFeature('move', 'folder') === false) {
                    throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
                }
            }

            if ($filesystem->is_file(WFUtility::makePath($destination, WFUtility::mb_basename($item))) && $overwrite === false) {
                $this->setResult($item, 'confirm');

                return $this->getResult();
            }

            $result = $filesystem->move($item, $destination);

            if ($result instanceof WFFileSystemResult) {
                if (!$result->state) {
                    if ($result->message) {
                        $this->setResult($result->message, 'error');
                    } else {
                        $this->setResult(Text::sprintf('WF_MANAGER_MOVE_' . strtoupper($result->type) . '_ERROR', WFUtility::mb_basename($item)), 'error');
                    }
                } else {
                    $data = array(
                        'name' => $filesystem->toRelative($result->path),
                    );

                    $event = $this->fireEvent('on' . ucfirst($result->type) . 'Move', array($item));

                    // merge event data with default values
                    $data = array_merge($data, $event);

                    $this->setResult($data, $result->type);
                }
            }
        }

        return $this->getResult();
    }

    /**
     * Create a new folder in a target directory.
     * Target path and folder name are read via func_get_args() for legacy compatibility.
     *
     * @return array Result array with new folder data or error messages.
     * @throws Exception              If the folder create feature is not permitted.
     * @throws InvalidArgumentException On path or access validation failure.
     */
    public function folderNew()
    {
        // check if the user has access to create a folder
        if ($this->checkFeature('create', 'folder') === false) {
            throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'));
        }

        $args = func_get_args();

        // path where the new folder will be created
        $target = array_shift($args);

        // a folder cannot be created in the primary directory tree
        if (empty($target)) {
            throw new InvalidArgumentException('Action Failed: Invalid target directory');
        }

        // the name of the new folder
        $new = array_shift($args);

        // decode and cast as string
        $target = (string) rawurldecode($target);
        $new = (string) rawurldecode($new);

        $target = $this->resolvePath($target);

        // check access
        if (!$this->checkPathAccess($target)) {
            throw new InvalidArgumentException('Action Failed: Access to the target directory is restricted');
        }

        $filesystem = $this->getFileSystem();

        $name = WFUtility::makeSafe($new, $this->get('websafe_mode'), $this->get('websafe_spaces'), $this->get('websafe_textcase'));

        // check for extension in destination name
        if (WFUtility::validateFileName($name) === false) {
            throw new InvalidArgumentException('Action Failed: The file name is invalid.');
        }

        $result = $filesystem->createFolder($target, $name, $args);

        if ($result instanceof WFFileSystemResult) {
            if (!$result->state) {
                if ($result->message) {
                    $this->setResult($result->message, 'error');
                } else {
                    $this->setResult(Text::sprintf('WF_MANAGER_NEW_FOLDER_ERROR', WFUtility::mb_basename($new)), 'error');
                }
            } else {
                $data = array(
                    'name'  => WFUtility::mb_basename($new),
                    'id'    => WFUtility::mb_basename($new),
                );

                $event = $this->fireEvent('onFolderNew', array($new));

                // merge event data with default values
                $data = array_merge($data, $event);

                $this->setResult($data, $result->type);
            }
        }

        return $this->getResult();
    }

    /**
     * Get the dimensions of a file.
     *
     * @param string $file The file to get dimensions for
     * @return array The dimensions of the file
     */
    public function getDimensions($file)
    {
        return $this->getFileSystem()->getDimensions($file);
    }

    /**
     * Convert a file to an absolute path.
     *
     * @param string $file The file to convert
     * @return string The absolute path
     */
    public function toAbsolute($file)
    {
        $path = $this->resolvePath($file);

        return $this->getFileSystem()->toAbsolute($path);
    }

    /**
     * Convert a file to a relative path.
     *
     * @param string $file The file to convert
     * @return string The relative path
     */
    public function toRelative($file)
    {
        $path = $this->resolvePath($file);

        return $this->getFileSystem()->toRelative($path);
    }

    /**
     * Proxy for the filesystem read method.
     *
     * @param string $file The file to read
     * @return string The file contents
     */
    public function readFile($file)
    {
        $path = $this->resolvePath($file);

        return $this->getFileSystem()->read($path);
    }

    /**
     * Write data to a file via the filesystem proxy.
     *
     * @param  string $file The relative file path.
     * @param  string $data The data to write.
     * @return mixed        Result from the filesystem write method.
     */
    public function writeFile($file, $data)
    {
        $path = $this->resolvePath($file);

        return $this->getFileSystem()->write($path, $data);
    }

    /**
     * Proxy for the filesystem is_file method.
     * @param string $file The file to check
     * @return bool True if the file exists
     */
    public function is_file($file)
    {
        $path = $this->resolvePath($file);

        return $this->getFileSystem()->is_file($path);
    }

    /**
     * Proxy for the filesystem is_dir method.
     *
     * @param string $path The path to check
     * @return boolean True if the path is a directory
     */
    public function is_dir($path)
    {
        $path = $this->resolvePath($path);

        return $this->getFileSystem()->is_dir($path);
    }

    /**
     * Get the effective PHP upload size limit in bytes (lower of upload_max_filesize and post_max_size).
     *
     * @return int The upload size limit in bytes.
     */
    private function getUploadValue()
    {
        $upload = trim(ini_get('upload_max_filesize'));
        $post = trim(ini_get('post_max_size'));

        $upload = WFUtility::convertSize($upload);
        $post = WFUtility::convertSize($post);

        if (intval($upload) <= intval($post)) {
            return $upload;
        }

        return $post;
    }

    /**
     * Build the default upload config array, capping max_size against PHP ini limits.
     *
     * @return array Upload settings including max_size, filetypes, and any filesystem overrides.
     */
    private function getUploadDefaults()
    {
        $filesystem = $this->getFileSystem();
        $features = $filesystem->get('upload');

        $upload_max = $this->getUploadValue();

        $upload = $this->get('upload');

        // get max size as kilobytes
        if (empty($upload['max_size'])) {
            $upload['max_size'] = 10240;
        }

        // get upload size as integer
        $size = intval(preg_replace('/[^0-9]/', '', $upload['max_size']));

        // must not exceed server maximum if > 0
        if (!empty($upload_max)) {
            if ((int) $size * 1024 > (int) $upload_max) {
                $size = $upload_max / 1024;
            }
        }

        $upload = array_merge($upload, array(
            'max_size' => $size,
            'filetypes' => $this->listFileTypes(),
        ));

        if (isset($features['elements'])) {
            $upload['elements'] = $features['elements'];
        }

        if (isset($features['dialog'])) {
            $upload['dialog'] = $features['dialog'];
        }

        return $upload;
    }

    /**
     * Initialise browser configuration from the passed config array, filesystem settings,
     * and computed path variables.
     *
     * @param  array $config Configuration array from the manager (features, dir store, upload, etc.).
     * @return void
     */
    private function setConfig($config = array())
    {
        // apply passed in properties (this must be done before initialising filesystem!)
        if (!empty($config)) {
            $this->setProperties($config);
        }

        $filesystem = $this->getFileSystem();

        $default = array(
            'upload' => $this->getUploadDefaults(),
        );

        $properties = array('base', 'delete', 'rename', 'folder_new', 'copy', 'move', 'list_limit');

        foreach ($properties as $property) {
            if ($filesystem->get($property)) {
                $default[$property] = $filesystem->get($property);
            }
        }

        $pathVariables = $this->getPathVariables();

        foreach ($pathVariables as $key => $value) {
            $default[$key] = $value;
        }

        // apply default properties
        $this->setProperties($default);
    }
}
