<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Class \core_h5p\file_storage.
 *
 * @package    core_h5p
 * @copyright  2019 Victor Deniz <victor@moodle.com>, base on code by Joubel AS
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace core_h5p;

use stored_file;
use Moodle\H5PCore;
use Moodle\H5peditorFile;
use Moodle\H5PFileStorage;

// phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod

/**
 * Class to handle storage and export of H5P Content.
 *
 * @package    core_h5p
 * @copyright  2019 Victor Deniz <victor@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class file_storage implements H5PFileStorage {

    /** The component for H5P. */
    public const COMPONENT   = 'core_h5p';
    /** The library file area. */
    public const LIBRARY_FILEAREA = 'libraries';
    /** The content file area */
    public const CONTENT_FILEAREA = 'content';
    /** The cached assest file area. */
    public const CACHED_ASSETS_FILEAREA = 'cachedassets';
    /** The export file area */
    public const EXPORT_FILEAREA = 'export';
    /** The export css file area */
    public const CSS_FILEAREA = 'css';
    /** The icon filename */
    public const ICON_FILENAME = 'icon.svg';
    /** The custom CSS filename */
    private const CUSTOM_CSS_FILENAME = 'custom_h5p.css';

    /**
     * @var \context $context Currently we use the system context everywhere.
     * Don't feel forced to keep it this way in the future.
     */
    protected $context;

    /** @var \file_storage $fs File storage. */
    protected $fs;

    /**
     * Initial setup for file_storage.
     */
    public function __construct() {
        // Currently everything uses the system context.
        $this->context = \context_system::instance();
        $this->fs = get_file_storage();
    }

    /**
     * Stores a H5P library in the Moodle filesystem.
     *
     * @param array $library Library properties.
     */
    public function saveLibrary($library) {
        $options = [
            'contextid' => $this->context->id,
            'component' => self::COMPONENT,
            'filearea' => self::LIBRARY_FILEAREA,
            'filepath' => '/' . H5PCore::libraryToFolderName($library) . '/',
            'itemid' => $library['libraryId'],
        ];

        // Easiest approach: delete the existing library version and copy the new one.
        $this->delete_library($library);
        $this->copy_directory($library['uploadDirectory'], $options);
    }

    /**
     * Delete library folder.
     *
     * @param array $library
     */
    public function deleteLibrary($library) {
        // Although this class had a method (delete_library()) for removing libraries before this was added to the interface,
        // it's not safe to call it from here because looking at the place where it's called, it's not clear what are their
        // expectation. This method will be implemented once more information will be added to the H5P technical doc.
    }


    /**
     * Store the content folder.
     *
     * @param string $source Path on file system to content directory.
     * @param array $content Content properties
     */
    public function saveContent($source, $content) {
        $options = [
                'contextid' => $this->context->id,
                'component' => self::COMPONENT,
                'filearea' => self::CONTENT_FILEAREA,
                'itemid' => $content['id'],
                'filepath' => '/',
        ];

        $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
        // Copy content directory into Moodle filesystem.
        $this->copy_directory($source, $options);
    }

    /**
     * Remove content folder.
     *
     * @param array $content Content properties
     */
    public function deleteContent($content) {

        $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
    }

    /**
     * Creates a stored copy of the content folder.
     *
     * @param string $id Identifier of content to clone.
     * @param int $newid The cloned content's identifier
     */
    public function cloneContent($id, $newid) {
        // Not implemented in Moodle.
    }

    /**
     * Get path to a new unique tmp folder.
     * Please note this needs to not be a directory.
     *
     * @return string Path
     */
    public function getTmpPath(): string {
        return make_request_directory() . '/' . uniqid('h5p-');
    }

    /**
     * Fetch content folder and save in target directory.
     *
     * @param int $id Content identifier
     * @param string $target Where the content folder will be saved
     */
    public function exportContent($id, $target) {
        $this->export_file_tree($target, $this->context->id, self::CONTENT_FILEAREA, '/', $id);
    }

    /**
     * Fetch library folder and save in target directory.
     *
     * @param array $library Library properties
     * @param string $target Where the library folder will be saved
     */
    public function exportLibrary($library, $target) {
        $folder = H5PCore::libraryToFolderName($library);
        $this->export_file_tree($target . '/' . $folder, $this->context->id, self::LIBRARY_FILEAREA,
                '/' . $folder . '/', $library['libraryId']);
    }

    /**
     * Save export in file system
     *
     * @param string $source Path on file system to temporary export file.
     * @param string $filename Name of export file.
     */
    public function saveExport($source, $filename) {
        global $USER;

        // Remove old export.
        $this->deleteExport($filename);

        $filerecord = [
            'contextid' => $this->context->id,
            'component' => self::COMPONENT,
            'filearea' => self::EXPORT_FILEAREA,
            'itemid' => 0,
            'filepath' => '/',
            'filename' => $filename,
            'userid' => $USER->id
        ];
        $this->fs->create_file_from_pathname($filerecord, $source);
    }

    /**
     * Removes given export file
     *
     * @param string $filename filename of the export to delete.
     */
    public function deleteExport($filename) {
        $file = $this->get_export_file($filename);
        if ($file) {
            $file->delete();
        }
    }

    /**
     * Check if the given export file exists
     *
     * @param string $filename The export file to check.
     * @return boolean True if the export file exists.
     */
    public function hasExport($filename) {
        return !!$this->get_export_file($filename);
    }

    /**
     * Will concatenate all JavaScrips and Stylesheets into two files in order
     * to improve page performance.
     *
     * @param array $files A set of all the assets required for content to display
     * @param string $key Hashed key for cached asset
     */
    public function cacheAssets(&$files, $key) {

        foreach ($files as $type => $assets) {
            if (empty($assets)) {
                continue;
            }

            // Create new file for cached assets.
            $ext = ($type === 'scripts' ? 'js' : 'css');
            $filename = $key . '.' . $ext;
            $fileinfo = [
                'contextid' => $this->context->id,
                'component' => self::COMPONENT,
                'filearea' => self::CACHED_ASSETS_FILEAREA,
                'itemid' => 0,
                'filepath' => '/',
                'filename' => $filename
            ];

            // Store concatenated content.
            $this->fs->create_file_from_string($fileinfo, $this->concatenate_files($assets, $type, $this->context));
            $files[$type] = [
                (object) [
                    'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . $filename,
                    'version' => ''
                ]
            ];
        }
    }

    /**
     * Will check if there are cache assets available for content.
     *
     * @param string $key Hashed key for cached asset
     * @return array
     */
    public function getCachedAssets($key) {
        $files = [];

        $js = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/', "{$key}.js");
        if ($js && $js->get_filesize() > 0) {
            $files['scripts'] = [
                (object) [
                    'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . "{$key}.js",
                    'version' => ''
                ]
            ];
        }

        $css = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/', "{$key}.css");
        if ($css && $css->get_filesize() > 0) {
            $files['styles'] = [
                (object) [
                    'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . "{$key}.css",
                    'version' => ''
                ]
            ];
        }

        return empty($files) ? null : $files;
    }

    /**
     * Remove the aggregated cache files.
     *
     * @param array $keys The hash keys of removed files
     */
    public function deleteCachedAssets($keys) {

        if (empty($keys)) {
            return;
        }

        foreach ($keys as $hash) {
            foreach (['js', 'css'] as $type) {
                $cachedasset = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/',
                        "{$hash}.{$type}");
                if ($cachedasset) {
                    $cachedasset->delete();
                }
            }
        }
    }

    /**
     * Read file content of given file and then return it.
     *
     * @param string $filepath
     * @return string contents
     */
    public function getContent($filepath) {
        list(
            'filearea' => $filearea,
            'filepath' => $filepath,
            'filename' => $filename,
            'itemid' => $itemid
        ) = $this->get_file_elements_from_filepath($filepath);

        if (!$itemid) {
            throw new \file_serving_exception('Could not retrieve the requested file, check your file permissions.');
        }

        // Locate file.
        $file = $this->fs->get_file($this->context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);

        // Return content.
        return $file->get_content();
    }

    /**
     * Save files uploaded through the editor.
     *
     * @param H5peditorFile $file
     * @param int $contentid
     *
     * @return int The id of the saved file.
     */
    public function saveFile($file, $contentid) {
        global $USER;

        $context = $this->context->id;
        $component = self::COMPONENT;
        $filearea = self::CONTENT_FILEAREA;
        if ($contentid === 0) {
            $usercontext = \context_user::instance($USER->id);
            $context = $usercontext->id;
            $component = 'user';
            $filearea = 'draft';
        }

        $record = array(
            'contextid' => $context,
            'component' => $component,
            'filearea' => $filearea,
            'itemid' => $contentid,
            'filepath' => '/' . $file->getType() . 's/',
            'filename' => $file->getName()
        );

        $storedfile = $this->fs->create_file_from_pathname($record, $_FILES['file']['tmp_name']);

        return $storedfile->get_id();
    }

    /**
     * Copy a file from another content or editor tmp dir.
     * Used when copy pasting content in H5P.
     *
     * @param string $file path + name
     * @param string|int $fromid Content ID or 'editor' string
     * @param \stdClass $tocontent Target Content
     *
     * @return void
     */
    public function cloneContentFile($file, $fromid, $tocontent): void {
        // Determine source filearea and itemid.
        if ($fromid === 'editor') {
            $sourcefilearea = 'draft';
            $sourceitemid = 0;
        } else {
            $sourcefilearea = self::CONTENT_FILEAREA;
            $sourceitemid = (int)$fromid;
        }

        $filepath = '/' . dirname($file) . '/';
        $filename = basename($file);

        // Check to see if source exists.
        $sourcefile = $this->get_file($sourcefilearea, $sourceitemid, $file);
        if ($sourcefile === null) {
            return; // Nothing to copy from.
        }

        // Check to make sure that file doesn't exist already in target.
        $targetfile = $this->get_file(self::CONTENT_FILEAREA, $tocontent->id, $file);
        if ( $targetfile !== null) {
            return; // File exists, no need to copy.
        }

        // Create new file record.
        $record = [
            'contextid' => $this->context->id,
            'component' => self::COMPONENT,
            'filearea' => self::CONTENT_FILEAREA,
            'itemid' => $tocontent->id,
            'filepath' => $filepath,
            'filename' => $filename,
        ];

        $this->fs->create_file_from_storedfile($record, $sourcefile);
    }

    /**
     * Copy content from one directory to another.
     * Defaults to cloning content from the current temporary upload folder to the editor path.
     *
     * @param string $source path to source directory
     * @param string $contentid Id of content
     *
     */
    public function moveContentDirectory($source, $contentid = null) {
        $contentidint = (int)$contentid;

        if ($source === null) {
            return;
        }

        // Get H5P and content json.
        $contentsource = $source . '/content';

        // Move all temporary content files to editor.
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($contentsource, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );

        $it->rewind();
        while ($it->valid()) {
            $item = $it->current();
            $pathname = $it->getPathname();
            if (!$item->isDir() && !($item->getFilename() === 'content.json')) {
                $this->move_file($pathname, $contentidint);
            }
            $it->next();
        }
    }

    /**
     * Get the file URL or given library and then return it.
     *
     * @param int $itemid
     * @param string $machinename
     * @param int $majorversion
     * @param int $minorversion
     * @return string url or false if the file doesn't exist
     */
    public function get_icon_url(int $itemid, string $machinename, int $majorversion, int $minorversion) {
        $filepath = '/' . "{$machinename}-{$majorversion}.{$minorversion}" . '/';
        if ($file = $this->fs->get_file(
            $this->context->id,
            self::COMPONENT,
            self::LIBRARY_FILEAREA,
            $itemid,
            $filepath,
            self::ICON_FILENAME)
        ) {
            $iconurl  = \moodle_url::make_pluginfile_url(
                $this->context->id,
                self::COMPONENT,
                self::LIBRARY_FILEAREA,
                $itemid,
                $filepath,
                $file->get_filename());

            // Return image URL.
            return $iconurl->out();
        }

        return false;
    }

    /**
     * Checks to see if an H5P content has the given file.
     *
     * @param string $file File path and name.
     * @param int $content Content id.
     *
     * @return int|null File ID or NULL if not found
     */
    public function getContentFile($file, $content): ?int {
        if (is_object($content)) {
            $content = $content->id;
        }
        $contentfile = $this->get_file(self::CONTENT_FILEAREA, $content, $file);

        return ($contentfile === null ? null : $contentfile->get_id());
    }

    /**
     * Remove content files that are no longer used.
     *
     * Used when saving content.
     *
     * @param string $file File path and name.
     * @param int $contentid Content id.
     *
     * @return void
     */
    public function removeContentFile($file, $contentid): void {
        // Although the interface defines $contentid as int, object given in H5peditor::processParameters.
        if (is_object($contentid)) {
            $contentid = $contentid->id;
        }
        $existingfile = $this->get_file(self::CONTENT_FILEAREA, $contentid, $file);
        if ($existingfile !== null) {
            $existingfile->delete();
        }
    }

    /**
     * Check if server setup has write permission to
     * the required folders
     *
     * @return bool True if server has the proper write access
     */
    public function hasWriteAccess() {
        // Moodle has access to the files table which is where all of the folders are stored.
        return true;
    }

    /**
     * Check if the library has a presave.js in the root folder
     *
     * @param string $libraryname
     * @param string $developmentpath
     * @return bool
     */
    public function hasPresave($libraryname, $developmentpath = null) {
        return false;
    }

    /**
     * Check if upgrades script exist for library.
     *
     * @param string $machinename
     * @param int $majorversion
     * @param int $minorversion
     * @return string Relative path
     */
    public function getUpgradeScript($machinename, $majorversion, $minorversion) {
        $path = '/' . "{$machinename}-{$majorversion}.{$minorversion}" . '/';
        $file = 'upgrade.js';
        $itemid = $this->get_itemid_for_file(self::LIBRARY_FILEAREA, $path, $file);
        if ($this->fs->get_file($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $itemid, $path, $file)) {
            return '/' . self::LIBRARY_FILEAREA . $path. $file;
        } else {
            return null;
        }
    }

    /**
     * Store the given stream into the given file.
     *
     * @param string $path
     * @param string $file
     * @param resource $stream
     * @return bool|int
     */
    public function saveFileFromZip($path, $file, $stream) {
        $fullpath = $path . '/' . $file;
        check_dir_exists(pathinfo($fullpath, PATHINFO_DIRNAME));
        return file_put_contents($fullpath, $stream);
    }

    /**
     * Deletes a library from the file system.
     *
     * @param  array $library Library details
     */
    public function delete_library(array $library): void {
        global $DB;

        // A library ID of false would result in all library files being deleted, which we don't want. Return instead.
        if (empty($library['libraryId'])) {
            return;
        }

        $areafiles = $this->fs->get_area_files($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $library['libraryId']);
        $this->delete_directory($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $library['libraryId']);
        $librarycache = \cache::make('core', 'h5p_library_files');
        foreach ($areafiles as $file) {
            if (!$DB->record_exists('files', array('contenthash' => $file->get_contenthash(),
                                                   'component' => self::COMPONENT,
                                                   'filearea' => self::LIBRARY_FILEAREA))) {
                $librarycache->delete($file->get_contenthash());
            }
        }
    }

    /**
     * Remove an H5P directory from the filesystem.
     *
     * @param int $contextid context ID
     * @param string $component component
     * @param string $filearea file area or all areas in context if not specified
     * @param int $itemid item ID or all files if not specified
     */
    private function delete_directory(int $contextid, string $component, string $filearea, int $itemid): void {

        $this->fs->delete_area_files($contextid, $component, $filearea, $itemid);
    }

    /**
     * Copy an H5P directory from the temporary directory into the file system.
     *
     * @param  string $source  Temporary location for files.
     * @param  array  $options File system information.
     */
    private function copy_directory(string $source, array $options): void {
        $librarycache = \cache::make('core', 'h5p_library_files');
        $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::SELF_FIRST);

        $root = $options['filepath'];

        $it->rewind();
        while ($it->valid()) {
            $item = $it->current();
            $subpath = $it->getSubPath();
            if (!$item->isDir()) {
                $options['filename'] = $it->getFilename();
                if (!$subpath == '') {
                    $options['filepath'] = $root . $subpath . '/';
                } else {
                    $options['filepath'] = $root;
                }

                $file = $this->fs->create_file_from_pathname($options, $item->getPathName());

                if ($options['filearea'] == self::LIBRARY_FILEAREA) {
                    if (!$librarycache->has($file->get_contenthash())) {
                        $librarycache->set($file->get_contenthash(), file_get_contents($item->getPathName()));
                    }
                }
            }
            $it->next();
        }
    }

    /**
     * Copies files from storage to temporary folder.
     *
     * @param string $target Path to temporary folder
     * @param int $contextid context where the files are found
     * @param string $filearea file area
     * @param string $filepath file path
     * @param int $itemid Optional item ID
     */
    private function export_file_tree(string $target, int $contextid, string $filearea, string $filepath, int $itemid = 0): void {
        // Make sure target folder exists.
        check_dir_exists($target);

        // Read source files.
        $files = $this->fs->get_directory_files($contextid, self::COMPONENT, $filearea, $itemid, $filepath, true);

        $librarycache = \cache::make('core', 'h5p_library_files');

        foreach ($files as $file) {
            $path = $target . str_replace($filepath, DIRECTORY_SEPARATOR, $file->get_filepath());
            if ($file->is_directory()) {
                check_dir_exists(rtrim($path));
            } else {
                if ($filearea == self::LIBRARY_FILEAREA) {
                    $cachedfile = $librarycache->get($file->get_contenthash());
                    if (empty($cachedfile)) {
                        $file->copy_content_to($path . $file->get_filename());
                        $librarycache->set($file->get_contenthash(), file_get_contents($path . $file->get_filename()));
                    } else {
                        file_put_contents($path . $file->get_filename(), $cachedfile);
                    }
                } else {
                    $file->copy_content_to($path . $file->get_filename());
                }
            }
        }
    }

    /**
     * Adds all files of a type into one file.
     *
     * @param  array    $assets  A list of files.
     * @param  string   $type    The type of files in assets. Either 'scripts' or 'styles'
     * @param  \context $context Context
     * @return string All of the file content in one string.
     */
    private function concatenate_files(array $assets, string $type, \context $context): string {
        $content = '';
        foreach ($assets as $asset) {
            // Find location of asset.
            list(
                'filearea' => $filearea,
                'filepath' => $filepath,
                'filename' => $filename,
                'itemid' => $itemid
            ) = $this->get_file_elements_from_filepath($asset->path);

            if ($itemid === false) {
                continue;
            }

            // Locate file.
            $file = $this->fs->get_file($context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);

            // Get file content and concatenate.
            if ($type === 'scripts') {
                $content .= $file->get_content() . ";\n";
            } else {
                // Rewrite relative URLs used inside stylesheets.
                $content .= preg_replace_callback(
                    '/url\([\'"]?([^"\')]+)[\'"]?\)/i',
                    function ($matches) use ($filearea, $filepath, $itemid) {
                        if (preg_match("/^(data:|([a-z0-9]+:)?\/)/i", $matches[1]) === 1) {
                            return $matches[0]; // Not relative, skip.
                        }
                        // Find "../" in matches[1].
                        // If it exists, we have to remove "../".
                        // And switch the last folder in the filepath for the first folder in $matches[1].
                        // For instance:
                        // $filepath: /H5P.Question-1.4/styles/
                        // $matches[1]: ../images/plus-one.svg
                        // We want to avoid this: H5P.Question-1.4/styles/ITEMID/../images/minus-one.svg
                        // We want this: H5P.Question-1.4/images/ITEMID/minus-one.svg.
                        if (preg_match('/\.\.\//', $matches[1], $pathmatches)) {
                            $path = preg_split('/\//', $filepath, -1, PREG_SPLIT_NO_EMPTY);
                            $pathfilename = preg_split('/\//', $matches[1], -1, PREG_SPLIT_NO_EMPTY);
                            // Remove the first element: ../.
                            array_shift($pathfilename);
                            // Replace pathfilename into the filepath.
                            $path[count($path) - 1] = $pathfilename[0];
                            $filepath = '/' . implode('/', $path) . '/';
                            // Remove the element used to replace.
                            array_shift($pathfilename);
                            $matches[1] = implode('/', $pathfilename);
                        }
                        return 'url("../' . $filearea . $filepath . $itemid . '/' . $matches[1] . '")';
                    },
                    $file->get_content()) . "\n";
            }
        }
        return $content;
    }

    /**
     * Get files ready for export.
     *
     * @param  string $filename File name to retrieve.
     * @return bool|\stored_file Stored file instance if exists, false if not
     */
    public function get_export_file(string $filename) {
        return $this->fs->get_file($this->context->id, self::COMPONENT, self::EXPORT_FILEAREA, 0, '/', $filename);
    }

    /**
     * Converts a relative system file path into Moodle File API elements.
     *
     * @param  string $filepath The system filepath to get information from.
     * @return array File information.
     */
    private function get_file_elements_from_filepath(string $filepath): array {
        $sections = explode('/', $filepath);
        // Get the filename.
        $filename = array_pop($sections);
        // Discard first element.
        if (empty($sections[0])) {
            array_shift($sections);
        }
        // Get the filearea.
        $filearea = array_shift($sections);
        $itemid = array_shift($sections);
        // Get the filepath.
        $filepath = implode('/', $sections);
        $filepath = '/' . $filepath . '/';

        return ['filearea' => $filearea, 'filepath' => $filepath, 'filename' => $filename, 'itemid' => $itemid];
    }

    /**
     * Returns the item id given the other necessary variables.
     *
     * @param  string $filearea The file area.
     * @param  string $filepath The file path.
     * @param  string $filename The file name.
     * @return mixed the specified value false if not found.
     */
    private function get_itemid_for_file(string $filearea, string $filepath, string $filename) {
        global $DB;
        return $DB->get_field('files', 'itemid', ['component' => self::COMPONENT, 'filearea' => $filearea, 'filepath' => $filepath,
                'filename' => $filename]);
    }

    /**
     * Helper to make it easy to load content files.
     *
     * @param string $filearea File area where the file is saved.
     * @param int $itemid Content instance or content id.
     * @param string $file File path and name.
     *
     * @return stored_file|null
     */
    private function get_file(string $filearea, int $itemid, string $file): ?stored_file {
        global $USER;

        $component = self::COMPONENT;
        $context = $this->context->id;
        if ($filearea === 'draft') {
            $itemid = 0;
            $component = 'user';
            $usercontext = \context_user::instance($USER->id);
            $context = $usercontext->id;
        }

        $filepath = '/'. dirname($file). '/';
        $filename = basename($file);

        // Load file.
        $existingfile = $this->fs->get_file($context, $component, $filearea, $itemid, $filepath, $filename);
        if (!$existingfile) {
            return null;
        }

        return $existingfile;
    }

    /**
     * Move a single file
     *
     * @param string $sourcefile Path to source file
     * @param int $contentid Content id or 0 if the file is in the editor file area
     *
     * @return void
     */
    private function move_file(string $sourcefile, int $contentid): void {
        $pathparts = pathinfo($sourcefile);
        $filename  = $pathparts['basename'];
        $filepath  = $pathparts['dirname'];
        $foldername = basename($filepath);

        // Create file record for content.
        $record = array(
            'contextid' => $this->context->id,
            'component' => $contentid > 0 ? self::COMPONENT : 'user',
            'filearea' => $contentid > 0 ? self::CONTENT_FILEAREA : 'draft',
            'itemid' => $contentid > 0 ? $contentid : 0,
            'filepath' => '/' . $foldername . '/',
            'filename' => $filename
        );

        $file = $this->fs->get_file(
            $record['contextid'], $record['component'],
            $record['filearea'], $record['itemid'], $record['filepath'],
            $record['filename']
        );

        if ($file) {
            // Delete it to make sure that it is replaced with correct content.
            $file->delete();
        }

        $this->fs->create_file_from_pathname($record, $sourcefile);
    }

    /**
     * Generate H5P custom styles if any.
     */
    public static function generate_custom_styles(): void {
        $record = self::get_custom_styles_file_record();
        $cssfile = self::get_custom_styles_file($record);
        if ($cssfile) {
            // The CSS file needs to be updated, so delete and recreate it
            // if there is CSS in the 'h5pcustomcss' setting.
            $cssfile->delete();
        }

        $css = get_config('core_h5p', 'h5pcustomcss');
        if (!empty($css)) {
            $fs = get_file_storage();
            $fs->create_file_from_string($record, $css);
        }
    }

    /**
     * Get H5P custom styles if any.
     *
     * @throws \moodle_exception If the CSS setting is empty but there is a file to serve
     * or there is no file but the CSS setting is not empty.
     * @return array|null If there is CSS then an array with the keys 'cssurl'
     * and 'cssversion' is returned otherwise null.  'cssurl' is a link to the
     * generated 'custom_h5p.css' file and 'cssversion' the md5 hash of its contents.
     */
    public static function get_custom_styles(): ?array {
        $record = self::get_custom_styles_file_record();

        $css = get_config('core_h5p', 'h5pcustomcss');
        if (self::get_custom_styles_file($record)) {
            if (empty($css)) {
                // The custom CSS file exists and yet the setting 'h5pcustomcss' is empty.
                // This prevents an invalid content hash.
                throw new \moodle_exception(
                    'The H5P \'h5pcustomcss\' setting is empty and yet the custom CSS file \''.
                    $record['filename'].
                    '\' exists.',
                    'core_h5p'
                );
            }
            // File exists, so generate the url and version hash.
            $cssurl = \moodle_url::make_pluginfile_url(
                $record['contextid'],
                $record['component'],
                $record['filearea'],
                null,
                $record['filepath'],
                $record['filename']
            );
            return ['cssurl' => $cssurl, 'cssversion' => md5($css)];
        } else if (!empty($css)) {
            // The custom CSS file does not exist and yet should do.
            throw new \moodle_exception(
                'The H5P custom CSS file \''.
                $record['filename'].
                '\' does not exist and yet there is CSS in the \'h5pcustomcss\' setting.',
                'core_h5p'
            );
        }
        return null;
    }

    /**
     * Get H5P custom styles file record.
     *
     * @return array File record for the CSS custom styles.
     */
    private static function get_custom_styles_file_record(): array {
        return [
            'contextid' => \context_system::instance()->id,
            'component' => self::COMPONENT,
            'filearea' => self::CSS_FILEAREA,
            'itemid' => 0,
            'filepath' => '/',
            'filename' => self::CUSTOM_CSS_FILENAME,
        ];
    }

    /**
     * Get H5P custom styles file.
     *
     * @param array $record The H5P custom styles file record.
     *
     * @return stored_file|bool stored_file instance if exists, false if not.
     */
    private static function get_custom_styles_file($record): stored_file|bool {
        $fs = get_file_storage();
        return $fs->get_file(
            $record['contextid'],
            $record['component'],
            $record['filearea'],
            $record['itemid'],
            $record['filepath'],
            $record['filename']
        );
    }
}
