<?php

/**
 * Part of the Joomla Framework Filesystem Package
 *
 * @copyright  Copyright (C) 2005 - 2021 Open Source Matters, Inc. All rights reserved.
 * @license    GNU General Public License version 2 or later; see LICENSE
 */

namespace Joomla\Filesystem;

use Joomla\Filesystem\Exception\FilesystemException;

/**
 * Joomla! Stream Interface
 *
 * The Joomla! stream interface is designed to handle files as streams
 * where as the legacy JFile static class treated files in a rather
 * atomic manner.
 *
 * This class adheres to the stream wrapper operations:
 *
 * @link   https://www.php.net/manual/en/function.stream-get-wrappers.php
 * @link   https://www.php.net/manual/en/intro.stream.php PHP Stream Manual
 * @link   https://www.php.net/manual/en/wrappers.php Stream Wrappers
 * @link   https://www.php.net/manual/en/filters.php Stream Filters
 * @link   https://www.php.net/manual/en/transports.php Socket Transports (used by some options, particularly HTTP proxy)
 * @since  1.0
 */
class Stream
{
    /**
     * File Mode
     *
     * @var    integer
     * @since  1.0
     */
    protected $filemode = 0644;

    /**
     * Directory Mode
     *
     * @var    integer
     * @since  1.0
     */
    protected $dirmode = 0755;

    /**
     * Default Chunk Size
     *
     * @var    integer
     * @since  1.0
     */
    protected $chunksize = 8192;

    /**
     * Filename
     *
     * @var    string
     * @since  1.0
     */
    protected $filename;

    /**
     * Prefix of the connection for writing
     *
     * @var    string
     * @since  1.0
     */
    protected $writeprefix;

    /**
     * Prefix of the connection for reading
     *
     * @var    string
     * @since  1.0
     */
    protected $readprefix;

    /**
     * Read Processing method
     *
     * @var    string  gz, bz, f
     * If a scheme is detected, fopen will be defaulted
     * To use compression with a network stream use a filter
     * @since  1.0
     */
    protected $processingmethod = 'f';

    /**
     * Filters applied to the current stream
     *
     * @var    array
     * @since  1.0
     */
    protected $filters = [];

    /**
     * File Handle
     *
     * @var    resource
     * @since  1.0
     */
    protected $fh;

    /**
     * File size
     *
     * @var    integer
     * @since  1.0
     */
    protected $filesize;

    /**
     * Context to use when opening the connection
     *
     * @var    string
     * @since  1.0
     */
    protected $context;

    /**
     * Context options; used to rebuild the context
     *
     * @var    array
     * @since  1.0
     */
    protected $contextOptions;

    /**
     * The mode under which the file was opened
     *
     * @var    string
     * @since  1.0
     */
    protected $openmode;

    /**
     * Constructor
     *
     * @param   string  $writeprefix  Prefix of the stream (optional). Unlike the JPATH_*, this has a final path separator!
     * @param   string  $readprefix   The read prefix (optional).
     * @param   array   $context      The context options (optional).
     *
     * @since   1.0
     */
    public function __construct($writeprefix = '', $readprefix = '', $context = [])
    {
        $this->writeprefix    = $writeprefix;
        $this->readprefix     = $readprefix;
        $this->contextOptions = $context;
        $this->_buildContext();
    }

    /**
     * Destructor
     *
     * @since   1.0
     */
    public function __destruct()
    {
        // Attempt to close on destruction if there is a file handle
        if ($this->fh) {
            @$this->close();
        }
    }

    /**
     * Creates a new stream object with appropriate prefix
     *
     * @param   boolean  $usePrefix  Prefix the connections for writing
     * @param   string   $ua         UA User agent to use
     * @param   boolean  $uamask     User agent masking (prefix Mozilla)
     *
     * @return  Stream
     *
     * @see     Stream
     * @since   1.0
     */
    public static function getStream($usePrefix = true, $ua = null, $uamask = false)
    {
        // Setup the context; Joomla! UA and overwrite
        $context = [];

        // Set the UA for HTTP
        $context['http']['user_agent'] = $ua ?: 'Joomla! Framework Stream';

        if ($usePrefix) {
            return new static(JPATH_ROOT . '/', JPATH_ROOT, $context);
        }

        return new static('', '', $context);
    }

    /**
     * Generic File Operations
     *
     * Open a stream with some lazy loading smarts
     *
     * @param   string    $filename              Filename
     * @param   string    $mode                  Mode string to use
     * @param   boolean   $useIncludePath        Use the PHP include path
     * @param   resource  $context               Context to use when opening
     * @param   boolean   $usePrefix             Use a prefix to open the file
     * @param   boolean   $relative              Filename is a relative path (if false, strips JPATH_ROOT to make it relative)
     * @param   boolean   $detectprocessingmode  Detect the processing method for the file and use the appropriate function
     *                                           to handle output automatically
     *
     * @return  boolean
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function open(
        $filename,
        $mode = 'r',
        $useIncludePath = false,
        $context = null,
        $usePrefix = false,
        $relative = false,
        $detectprocessingmode = false
    ) {
        $filename = $this->_getFilename($filename, $mode, $usePrefix, $relative);

        if (!$filename) {
            throw new FilesystemException('Filename not set');
        }

        $this->filename = $filename;
        $this->openmode = $mode;

        $url = parse_url($filename);

        if (isset($url['scheme'])) {
            $scheme = ucfirst($url['scheme']);

            // Map to StringWrapper if required
            if ($scheme === 'String') {
                $scheme = 'StringWrapper';
            }

            // If we're dealing with a Joomla! stream, load it
            if (Helper::isJoomlaStream($scheme)) {
                require_once __DIR__ . '/Stream/' . $scheme . '.php';
            }

            // We have a scheme! force the method to be f
            $this->processingmethod = 'f';
        } elseif ($detectprocessingmode) {
            $ext = strtolower(pathinfo($this->filename, \PATHINFO_EXTENSION));

            switch ($ext) {
                case 'tgz':
                case 'gz':
                case 'gzip':
                    $this->processingmethod = 'gz';

                    break;

                case 'tbz2':
                case 'bz2':
                case 'bzip2':
                    $this->processingmethod = 'bz';

                    break;

                default:
                    $this->processingmethod = 'f';

                    break;
            }
        }

        // Capture PHP errors
        error_clear_last();

        // Decide which context to use:
        switch ($this->processingmethod) {
            // Gzip doesn't support contexts or streams
            case 'gz':
                $this->fh = gzopen($filename, $mode, $useIncludePath);

                break;

                // Bzip2 is much like gzip except it doesn't use the include path
            case 'bz':
                $this->fh = bzopen($filename, $mode);

                break;

                // Fopen can handle streams
            case 'f':
            default:
                // One supplied at open; overrides everything
                if ($context) {
                    $this->fh = @fopen($filename, $mode, $useIncludePath, $context);
                } elseif ($this->context) {
                    // One provided at initialisation
                    $this->fh = @fopen($filename, $mode, $useIncludePath, $this->context);
                } else {
                    // No context; all defaults
                    $this->fh = @fopen($filename, $mode, $useIncludePath);
                }

                break;
        }

        if (!$this->fh) {
            $error = error_get_last();

            if ($error === null || $error['message'] === '') {
                // Error but nothing from php? Create our own
                $error = [
                    'message' => sprintf('Unknown error opening file %s', $filename),
                ];
            }

            throw new FilesystemException($error['message']);
        }

        // Return the result
        return true;
    }

    /**
     * Attempt to close a file handle
     *
     * Will return false if it failed and true on success
     * If the file is not open the system will return true, this function destroys the file handle as well
     *
     * @return  boolean
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function close()
    {
        if (!$this->fh) {
            throw new FilesystemException('File not open');
        }

        // Capture PHP errors
        error_clear_last();

        switch ($this->processingmethod) {
            case 'gz':
                $res = gzclose($this->fh);

                break;

            case 'bz':
                $res = bzclose($this->fh);

                break;

            case 'f':
            default:
                $res = fclose($this->fh);

                break;
        }

        if (!$res) {
            $error = error_get_last();

            if ($error === null || $error['message'] === '') {
                // Error but nothing from php? Create our own
                $error = [
                    'message' => 'Unable to close stream',
                ];
            }

            throw new FilesystemException($error['message']);
        }

        // Reset this
        $this->fh = null;

        // If we wrote, chmod the file after it's closed
        if ($this->openmode[0] == 'w') {
            $this->chmod();
        }

        // Return the result
        return true;
    }

    /**
     * Work out if we're at the end of the file for a stream
     *
     * @return  boolean
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function eof()
    {
        if (!$this->fh) {
            throw new FilesystemException('File not open');
        }

        // Capture PHP errors
        error_clear_last();

        switch ($this->processingmethod) {
            case 'gz':
                $res = gzeof($this->fh);

                break;

            case 'bz':
            case 'f':
            default:
                $res = feof($this->fh);

                break;
        }

        $error = error_get_last();

        if ($error !== null && $error['message'] !== '') {
            throw new FilesystemException($error['message']);
        }

        // Return the result
        return $res;
    }

    /**
     * Retrieve the file size of the path
     *
     * @return  integer|boolean
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function filesize()
    {
        if (!$this->filename) {
            throw new FilesystemException('File not open');
        }

        // Capture PHP errors
        error_clear_last();

        $res = @filesize($this->filename);

        if (!$res) {
            $res = Helper::remotefsize($this->filename);
        }

        if (!$res) {
            $error = error_get_last();

            if ($error === null || $error['message'] === '') {
                // Error but nothing from php? Create our own
                $error = [
                    'message' => 'Failed to get file size. This may not work for all streams.',
                ];
            }

            throw new FilesystemException($error['message']);
        }

        $this->filesize = $res;

        // Return the result
        return $this->filesize;
    }

    /**
     * Get a line from the stream source.
     *
     * @param   integer  $length  The number of bytes (optional) to read.
     *
     * @return  string
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function gets($length = 0)
    {
        if (!$this->fh) {
            throw new FilesystemException('File not open');
        }

        // Capture PHP errors
        error_clear_last();

        switch ($this->processingmethod) {
            case 'gz':
                $res = $length ? gzgets($this->fh, $length) : gzgets($this->fh);

                break;

            case 'bz':
            case 'f':
            default:
                $res = $length ? fgets($this->fh, $length) : fgets($this->fh);

                break;
        }

        if (!$res) {
            $error = error_get_last();

            if ($error === null || $error['message'] === '') {
                // Error but nothing from php? Create our own
                $error = [
                    'message' => 'Unable to read from stream',
                ];
            }

            throw new FilesystemException($error['message']);
        }

        // Return the result
        return $res;
    }

    /**
     * Read a file
     *
     * Handles user space streams appropriately otherwise any read will return 8192
     *
     * @param   integer  $length  Length of data to read
     *
     * @return  string
     *
     * @link    https://www.php.net/manual/en/function.fread.php
     * @since   1.0
     * @throws  FilesystemException
     */
    public function read($length = 0)
    {
        if (!$this->fh) {
            throw new FilesystemException('File not open');
        }

        if (!$this->filesize && !$length) {
            // Get the filesize
            $this->filesize();

            if (!$this->filesize) {
                // Set it to the biggest and then wait until eof
                $length = -1;
            } else {
                $length = $this->filesize;
            }
        }

        $retval = false;

        // Capture PHP errors
        error_clear_last();

        $remaining = $length;

        do {
            // Do chunked reads where relevant
            switch ($this->processingmethod) {
                case 'bz':
                    $res = ($remaining > 0) ? bzread($this->fh, $remaining) : bzread($this->fh, $this->chunksize);

                    break;

                case 'gz':
                    $res = ($remaining > 0) ? gzread($this->fh, $remaining) : gzread($this->fh, $this->chunksize);

                    break;

                case 'f':
                default:
                    $res = ($remaining > 0) ? fread($this->fh, $remaining) : fread($this->fh, $this->chunksize);

                    break;
            }

            if (!$res) {
                $error = error_get_last();

                if ($error === null || $error['message'] === '') {
                    // Error but nothing from php? Create our own
                    $error = [
                        'message' => 'Unable to read from stream',
                    ];
                }

                throw new FilesystemException($error['message']);
            }

            if (!$retval) {
                $retval = '';
            }

            $retval .= $res;

            if (!$this->eof()) {
                $len = \strlen($res);
                $remaining -= $len;
            } else {
                // If it's the end of the file then we've nothing left to read; reset remaining and len
                $remaining = 0;
                $length    = \strlen($retval);
            }
        } while ($remaining || !$length);

        // Return the result
        return $retval;
    }

    /**
     * Seek the file
     *
     * Note: the return value is different to that of fseek
     *
     * @param   integer  $offset  Offset to use when seeking.
     * @param   integer  $whence  Seek mode to use.
     *
     * @return  boolean  True on success, false on failure
     *
     * @link    https://www.php.net/manual/en/function.fseek.php
     * @since   1.0
     * @throws  FilesystemException
     */
    public function seek($offset, $whence = \SEEK_SET)
    {
        if (!$this->fh) {
            throw new FilesystemException('File not open');
        }

        // Capture PHP errors
        error_clear_last();

        switch ($this->processingmethod) {
            case 'gz':
                $res = gzseek($this->fh, $offset, $whence);

                break;

            case 'bz':
            case 'f':
            default:
                $res = fseek($this->fh, $offset, $whence);

                break;
        }

        // Seek, interestingly, returns 0 on success or -1 on failure.
        if ($res == -1) {
            $error = error_get_last();

            if ($error === null || $error['message'] === '') {
                // Error but nothing from php? Create our own
                $error = [
                    'message' => 'Unable to seek in stream',
                ];
            }

            throw new FilesystemException($error['message']);
        }

        // Return the result
        return true;
    }

    /**
     * Returns the current position of the file read/write pointer.
     *
     * @return  integer
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function tell()
    {
        if (!$this->fh) {
            throw new FilesystemException('File not open');
        }

        // Capture PHP errors
        error_clear_last();

        switch ($this->processingmethod) {
            case 'gz':
                $res = gztell($this->fh);

                break;

            case 'bz':
            case 'f':
            default:
                $res = ftell($this->fh);

                break;
        }

        // May return 0 so check if it's really false
        if ($res === false) {
            $error = error_get_last();

            if ($error === null || $error['message'] === '') {
                // Error but nothing from php? Create our own
                $error = [
                    'message' => 'Unable to determine the current position in stream',
                ];
            }

            throw new FilesystemException($error['message']);
        }

        // Return the result
        return $res;
    }

    /**
     * File write
     *
     * Whilst this function accepts a reference, the underlying fwrite
     * will do a copy! This will roughly double the memory allocation for
     * any write you do. Specifying chunked will get around this by only
     * writing in specific chunk sizes. This defaults to 8192 which is a
     * sane number to use most of the time (change the default with
     * Stream::set('chunksize', newsize);)
     * Note: This doesn't support gzip/bzip2 writing like reading does
     *
     * @param   string   $string  Reference to the string to write.
     * @param   integer  $length  Length of the string to write.
     * @param   integer  $chunk   Size of chunks to write in.
     *
     * @return  boolean
     *
     * @link    https://www.php.net/manual/en/function.fwrite.php
     * @since   1.0
     * @throws  FilesystemException
     */
    public function write(&$string, $length = 0, $chunk = 0)
    {
        if (!$this->fh) {
            throw new FilesystemException('File not open');
        }

        if ($this->openmode == 'r') {
            throw new \RuntimeException('File is in readonly mode');
        }

        // If the length isn't set, set it to the length of the string.
        if (!$length) {
            $length = \strlen($string);
        }

        // If the chunk isn't set, set it to the default.
        if (!$chunk) {
            $chunk = $this->chunksize;
        }

        $retval = true;

        // Capture PHP errors
        error_clear_last();

        $remaining = $length;
        $start     = 0;

        do {
            // If the amount remaining is greater than the chunk size, then use the chunk
            $amount = ($remaining > $chunk) ? $chunk : $remaining;
            $res    = fwrite($this->fh, substr($string, $start), $amount);

            // Returns false on error or the number of bytes written
            if ($res === false) {
                $error = error_get_last();

                if ($error === null || $error['message'] === '') {
                    // Error but nothing from php? Create our own
                    $error = [
                        'message' => 'Unable to write to stream',
                    ];
                }

                throw new FilesystemException($error['message']);
            }

            if ($res === 0) {
                // Wrote nothing?
                throw new FilesystemException('Warning: No data written');
            }

            // Wrote something
            $start += $amount;
            $remaining -= $res;
        } while ($remaining);

        // Return the result
        return $retval;
    }

    /**
     * Chmod wrapper
     *
     * @param   string  $filename  File name.
     * @param   mixed   $mode      Mode to use.
     *
     * @return  boolean
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function chmod($filename = '', $mode = 0)
    {
        if (!$filename) {
            if (!isset($this->filename) || !$this->filename) {
                throw new FilesystemException('Filename not set');
            }

            $filename = $this->filename;
        }

        // If no mode is set use the default
        if (!$mode) {
            $mode = $this->filemode;
        }

        // Capture PHP errors
        error_clear_last();

        $sch = parse_url($filename, \PHP_URL_SCHEME);

        // Scheme specific options; ftp's chmod support is fun.
        switch ($sch) {
            case 'ftp':
            case 'ftps':
                $res = Helper::ftpChmod($filename, $mode);

                break;

            default:
                $res = chmod($filename, $mode);

                break;
        }

        if ($res === false) {
            $error = error_get_last();

            if ($error === null || $error['message'] === '') {
                // Error but nothing from php? Create our own
                $error = [
                    'message' => 'Unable to change mode of stream',
                ];
            }

            throw new FilesystemException($error['message']);
        }

        // Return the result
        return true;
    }

    /**
     * Get the stream metadata
     *
     * @return  array  header/metadata
     *
     * @link    https://www.php.net/manual/en/function.stream-get-meta-data.php
     * @since   1.0
     * @throws  FilesystemException
     */
    public function get_meta_data()
    {
        if (!$this->fh) {
            throw new FilesystemException('File not open');
        }

        return stream_get_meta_data($this->fh);
    }

    /**
     * Stream contexts
     * Builds the context from the array
     *
     * @return  void
     *
     * @since   1.0
     */
    public function _buildContext()
    {
        // According to the manual this always works!
        if (\count($this->contextOptions)) {
            $this->context = @stream_context_create($this->contextOptions);
        } else {
            $this->context = null;
        }
    }

    /**
     * Updates the context to the array
     *
     * Format is the same as the options for stream_context_create
     *
     * @param   array  $context  Options to create the context with
     *
     * @return  void
     *
     * @link    https://www.php.net/stream_context_create
     * @since   1.0
     */
    public function setContextOptions($context)
    {
        $this->contextOptions = $context;
        $this->_buildContext();
    }

    /**
     * Adds a particular options to the context
     *
     * @param   string  $wrapper  The wrapper to use
     * @param   string  $name     The option to set
     * @param   string  $value    The value of the option
     *
     * @return  void
     *
     * @link    https://www.php.net/stream_context_create Stream Context Creation
     * @link    https://www.php.net/manual/en/context.php Context Options for various streams
     * @since   1.0
     */
    public function addContextEntry($wrapper, $name, $value)
    {
        $this->contextOptions[$wrapper][$name] = $value;
        $this->_buildContext();
    }

    /**
     * Deletes a particular setting from a context
     *
     * @param   string  $wrapper  The wrapper to use
     * @param   string  $name     The option to unset
     *
     * @return  void
     *
     * @link    https://www.php.net/stream_context_create
     * @since   1.0
     */
    public function deleteContextEntry($wrapper, $name)
    {
        // Check whether the wrapper is set
        if (isset($this->contextOptions[$wrapper])) {
            // Check that entry is set for that wrapper
            if (isset($this->contextOptions[$wrapper][$name])) {
                // Unset the item
                unset($this->contextOptions[$wrapper][$name]);

                // Check that there are still items there
                if (!\count($this->contextOptions[$wrapper])) {
                    // Clean up an empty wrapper context option
                    unset($this->contextOptions[$wrapper]);
                }
            }
        }

        // Rebuild the context and apply it to the stream
        $this->_buildContext();
    }

    /**
     * Applies the current context to the stream
     *
     * Use this to change the values of the context after you've opened a stream
     *
     * @return  boolean
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function applyContextToStream()
    {
        $retval = false;

        if ($this->fh) {
            // Capture PHP errors
            error_clear_last();

            $retval = @stream_context_set_option($this->fh, $this->contextOptions);

            if (!$retval) {
                $error = error_get_last();

                if ($error === null || $error['message'] === '') {
                    // Error but nothing from php? Create our own
                    $error = [
                        'message' => 'Unable to apply context to stream',
                    ];
                }

                throw new FilesystemException($error['message']);
            }
        }

        return $retval;
    }

    /**
     * Stream filters
     * Append a filter to the chain
     *
     * @param   string   $filtername  The key name of the filter.
     * @param   integer  $readWrite   Optional. Defaults to STREAM_FILTER_READ.
     * @param   array    $params      An array of params for the stream_filter_append call.
     *
     * @return  resource|boolean
     *
     * @link    https://www.php.net/manual/en/function.stream-filter-append.php
     * @since   1.0
     * @throws  FilesystemException
     */
    public function appendFilter($filtername, $readWrite = \STREAM_FILTER_READ, $params = [])
    {
        $res = false;

        if ($this->fh) {
            // Capture PHP errors
            error_clear_last();

            $res = @stream_filter_append($this->fh, $filtername, $readWrite, $params);

            if (!$res) {
                $error = error_get_last();

                if ($error !== null && $error['message'] !== '') {
                    throw new FilesystemException($error['message']);
                }
            }

            $this->filters[] = &$res;
        }

        return $res;
    }

    /**
     * Prepend a filter to the chain
     *
     * @param   string   $filtername  The key name of the filter.
     * @param   integer  $readWrite   Optional. Defaults to STREAM_FILTER_READ.
     * @param   array    $params      An array of params for the stream_filter_prepend call.
     *
     * @return  resource|boolean
     *
     * @link    https://www.php.net/manual/en/function.stream-filter-prepend.php
     * @since   1.0
     * @throws  FilesystemException
     */
    public function prependFilter($filtername, $readWrite = \STREAM_FILTER_READ, $params = [])
    {
        $res = false;

        if ($this->fh) {
            // Capture PHP errors
            error_clear_last();

            $res = @stream_filter_prepend($this->fh, $filtername, $readWrite, $params);

            if (!$res) {
                $error = error_get_last();

                if ($error !== null && $error['message'] !== '') {
                    throw new FilesystemException($error['message']);
                }
            }

            array_unshift($this->filters, '');
            $this->filters[0] = &$res;
        }

        return $res;
    }

    /**
     * Remove a filter, either by resource (handed out from the append or prepend function)
     * or via getting the filter list)
     *
     * @param   resource  $resource  The resource.
     * @param   boolean   $byindex   The index of the filter.
     *
     * @return  boolean   Result of operation
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function removeFilter(&$resource, $byindex = false)
    {
        // Capture PHP errors
        error_clear_last();

        if ($byindex) {
            $res = stream_filter_remove($this->filters[$resource]);
        } else {
            $res = stream_filter_remove($resource);
        }

        if (!$res) {
            $error = error_get_last();

            if ($error === null || $error['message'] === '') {
                // Error but nothing from php? Create our own
                $error = [
                    'message' => 'Unable to remove filter from stream',
                ];
            }

            throw new FilesystemException($error['message']);
        }

        return $res;
    }

    /**
     * Copy a file from src to dest
     *
     * @param   string    $src        The file path to copy from.
     * @param   string    $dest       The file path to copy to.
     * @param   resource  $context    A valid context resource (optional) created with stream_context_create.
     * @param   boolean   $usePrefix  Controls the use of a prefix (optional).
     * @param   boolean   $relative   Determines if the filename given is relative. Relative paths do not have JPATH_ROOT stripped.
     *
     * @return  boolean
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function copy($src, $dest, $context = null, $usePrefix = true, $relative = false)
    {
        // Capture PHP errors
        error_clear_last();

        $chmodDest = $this->_getFilename($dest, 'w', $usePrefix, $relative);

        // Since we're going to open the file directly we need to get the filename.
        // We need to use the same prefix so force everything to write.
        $src  = $this->_getFilename($src, 'w', $usePrefix, $relative);
        $dest = $this->_getFilename($dest, 'w', $usePrefix, $relative);

        // One supplied at copy; overrides everything
        if ($context) {
            // Use the provided context
            $res = @copy($src, $dest, $context);
        } elseif ($this->context) {
            // One provided at initialisation
            $res = @copy($src, $dest, $this->context);
        } else {
            // No context; all defaults
            $res = @copy($src, $dest);
        }

        if (!$res) {
            $error = error_get_last();

            if ($error !== null && $error['message'] !== '') {
                throw new FilesystemException($error['message']);
            }
        }

        $this->chmod($chmodDest);

        return $res;
    }

    /**
     * Moves a file
     *
     * @param   string    $src        The file path to move from.
     * @param   string    $dest       The file path to move to.
     * @param   resource  $context    A valid context resource (optional) created with stream_context_create.
     * @param   boolean   $usePrefix  Controls the use of a prefix (optional).
     * @param   boolean   $relative   Determines if the filename given is relative. Relative paths do not have JPATH_ROOT stripped.
     *
     * @return  boolean
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function move($src, $dest, $context = null, $usePrefix = true, $relative = false)
    {
        // Capture PHP errors
        error_clear_last();

        $src  = $this->_getFilename($src, 'w', $usePrefix, $relative);
        $dest = $this->_getFilename($dest, 'w', $usePrefix, $relative);

        if ($context) {
            // Use the provided context
            $res = @rename($src, $dest, $context);
        } elseif ($this->context) {
            // Use the object's context
            $res = @rename($src, $dest, $this->context);
        } else {
            // Don't use any context
            $res = @rename($src, $dest);
        }

        if (!$res) {
            $error = error_get_last();

            if ($error === null || $error['message'] === '') {
                // Error but nothing from php? Create our own
                $error = [
                    'message' => 'Unable to move stream',
                ];
            }

            throw new FilesystemException($error['message']);
        }

        $this->chmod($dest);

        return $res;
    }

    /**
     * Delete a file
     *
     * @param   string    $filename   The file path to delete.
     * @param   resource  $context    A valid context resource (optional) created with stream_context_create.
     * @param   boolean   $usePrefix  Controls the use of a prefix (optional).
     * @param   boolean   $relative   Determines if the filename given is relative. Relative paths do not have JPATH_ROOT stripped.
     *
     * @return  boolean
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function delete($filename, $context = null, $usePrefix = true, $relative = false)
    {
        // Capture PHP errors
        error_clear_last();

        $filename = $this->_getFilename($filename, 'w', $usePrefix, $relative);

        if ($context) {
            // Use the provided context
            $res = @unlink($filename, $context);
        } elseif ($this->context) {
            // Use the object's context
            $res = @unlink($filename, $this->context);
        } else {
            // Don't use any context
            $res = @unlink($filename);
        }

        if (!$res) {
            $error = error_get_last();

            if ($error === null || $error['message'] === '') {
                // Error but nothing from php? Create our own
                $error = [
                    'message' => 'Unable to delete stream',
                ];
            }

            throw new FilesystemException($error['message']);
        }

        return $res;
    }

    /**
     * Upload a file
     *
     * @param   string    $src        The file path to copy from (usually a temp folder).
     * @param   string    $dest       The file path to copy to.
     * @param   resource  $context    A valid context resource (optional) created with stream_context_create.
     * @param   boolean   $usePrefix  Controls the use of a prefix (optional).
     * @param   boolean   $relative   Determines if the filename given is relative. Relative paths do not have JPATH_ROOT stripped.
     *
     * @return  boolean
     *
     * @since   1.0
     * @throws  FilesystemException
     */
    public function upload($src, $dest, $context = null, $usePrefix = true, $relative = false)
    {
        if (is_uploaded_file($src)) {
            // Make sure it's an uploaded file
            return $this->copy($src, $dest, $context, $usePrefix, $relative);
        }

        throw new FilesystemException('Not an uploaded file.');
    }

    /**
     * Writes a chunk of data to a file.
     *
     * @param   string   $filename      The file name.
     * @param   string   $buffer        The data to write to the file.
     * @param   boolean  $appendToFile  Append to the file and not overwrite it.
     *
     * @return  boolean
     *
     * @since   1.0
     */
    public function writeFile($filename, &$buffer, $appendToFile = false)
    {
        $fileMode = 'w';

        // Switch the filemode when we want to append to the file
        if ($appendToFile) {
            $fileMode = 'a';
        }

        if ($this->open($filename, $fileMode)) {
            $result = $this->write($buffer);
            $this->chmod();
            $this->close();

            return $result;
        }

        return false;
    }

    /**
     * Determine the appropriate 'filename' of a file
     *
     * @param   string   $filename   Original filename of the file
     * @param   string   $mode       Mode string to retrieve the filename
     * @param   boolean  $usePrefix  Controls the use of a prefix
     * @param   boolean  $relative   Determines if the filename given is relative. Relative paths do not have JPATH_ROOT stripped.
     *
     * @return  string
     *
     * @since   1.0
     */
    public function _getFilename($filename, $mode, $usePrefix, $relative)
    {
        if ($usePrefix) {
            // Get rid of binary or t, should be at the end of the string
            $tmode = trim($mode, 'btf123456789');

            $stream   = explode('://', $filename, 2);
            $scheme   = '';
            $filename = $stream[0];

            if (\count($stream) >= 2) {
                $scheme   = $stream[0] . '://';
                $filename = $stream[1];
            }

            // Check if it's a write mode then add the appropriate prefix
            if (\in_array($tmode, Helper::getWriteModes())) {
                $prefixToUse = $this->writeprefix;
            } else {
                $prefixToUse = $this->readprefix;
            }

            // Get rid of JPATH_ROOT (legacy compat)
            if (!$relative && $prefixToUse) {
                $pos = strpos($filename, JPATH_ROOT);

                if ($pos !== false) {
                    $filename = substr_replace($filename, '', $pos, \strlen(JPATH_ROOT));
                }
            }

            $filename = ($prefixToUse ? $prefixToUse : '') . $filename;
        }

        return $filename;
    }

    /**
     * Return the internal file handle
     *
     * @return  File handler
     *
     * @since   1.0
     */
    public function getFileHandle()
    {
        return $this->fh;
    }

    /**
     * Modifies a property of the object, creating it if it does not already exist.
     *
     * @param   string  $property  The name of the property.
     * @param   mixed   $value     The value of the property to set.
     *
     * @return  mixed  Previous value of the property.
     *
     * @since   1.0
     */
    public function set($property, $value = null)
    {
        $previous        = $this->$property ?? null;
        $this->$property = $value;

        return $previous;
    }

    /**
     * Returns a property of the object or the default value if the property is not set.
     *
     * @param   string  $property  The name of the property.
     * @param   mixed   $default   The default value.
     *
     * @return  mixed    The value of the property.
     *
     * @since   1.0
     */
    public function get($property, $default = null)
    {
        if (isset($this->$property)) {
            return $this->$property;
        }

        return $default;
    }
}
