<?php

namespace SilverStripe\View;

use Exception;
use InvalidArgumentException;
use SilverStripe\Assets\File;
use SilverStripe\Assets\Storage\GeneratedAssetHandler;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Convert;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Manifest\ModuleResourceLoader;
use SilverStripe\Core\Manifest\ResourceURLGenerator;
use SilverStripe\Core\Path;
use SilverStripe\Dev\Debug;
use SilverStripe\Dev\Deprecation;
use SilverStripe\i18n\i18n;
use SilverStripe\ORM\FieldType\DBField;

class Requirements_Backend
{
    use Injectable;

    /**
     * Whether to add caching query params to the requests for file-based requirements.
     * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
     * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
     * while automatically busting this cache every time the file is changed.
     *
     * @var bool
     */
    protected $suffixRequirements = true;

    /**
     * Whether to combine CSS and JavaScript files
     *
     * @var bool|null
     */
    protected $combinedFilesEnabled = null;

    /**
     * Determine if files should be combined automatically on dev mode.
     *
     * By default combined files will not be combined except in test or
     * live environments. Turning this on will allow for pre-combining of files in development mode.
     *
     * @config
     * @var bool
     */
    private static $combine_in_dev = false;

    /**
     * Paths to all required JavaScript files relative to docroot
     *
     * @var array
     */
    protected $javascript = [];

    /**
     * Map of included scripts to array of contained files.
     * To be used alongside front-end combination mechanisms.
     *
     * @var array Map of providing filepath => array(provided filepaths)
     */
    protected $providedJavascript = [];

    /**
     * Paths to all required CSS files relative to the docroot.
     *
     * @var array
     */
    protected $css = [];

    /**
     * All custom javascript code that is inserted into the page's HTML
     *
     * @var array
     */
    protected $customScript = [];

    /**
     * All custom CSS rules which are inserted directly at the bottom of the HTML `<head>` tag
     *
     * @var array
     */
    protected $customCSS = [];

    /**
     * All custom HTML markup which is added before the closing `<head>` tag, e.g. additional
     * metatags.
     *
     * @var array
     */
    protected $customHeadTags = [];

    /**
     * Remembers the file paths or uniquenessIDs of all Requirements cleared through
     * {@link clear()}, so that they can be restored later.
     *
     * @var array
     */
    protected $disabled = [];

    /**
     * The file paths (relative to docroot) or uniquenessIDs of any included requirements which
     * should be blocked when executing {@link inlcudeInHTML()}. This is useful, for example,
     * to block scripts included by a superclass without having to override entire functions and
     * duplicate a lot of code.
     *
     * Use {@link unblock()} or {@link unblock_all()} to revert changes.
     *
     * @var array
     */
    protected $blocked = [];

    /**
     * A list of combined files registered via {@link combine_files()}. Keys are the output file
     * names, values are lists of input files.
     *
     * @var array
     */
    protected $combinedFiles = [];

    /**
     * Use the injected minification service to minify any javascript file passed to {@link combine_files()}.
     *
     * @var bool
     */
    protected $minifyCombinedFiles = false;

    /**
     * Whether or not file headers should be written when combining files
     *
     * @var boolean
     */
    protected $writeHeaderComment = true;

    /**
     * Where to save combined files. By default they're placed in assets/_combinedfiles, however
     * this may be an issue depending on your setup, especially for CSS files which often contain
     * relative paths.
     *
     * @var string
     */
    protected $combinedFilesFolder = null;

    /**
     * Put all JavaScript includes at the bottom of the template before the closing `<body>` tag,
     * rather than the default behaviour of placing them at the end of the `<head>` tag. This means
     * script downloads won't block other HTTP requests, which can be a performance improvement.
     *
     * @var bool
     */
    public $writeJavascriptToBody = true;

    /**
     * Force the JavaScript to the bottom of the page, even if there's a script tag in the body already
     *
     * @var boolean
     */
    protected $forceJSToBottom = false;

    /**
     * Configures the default prefix for combined files.
     *
     * This defaults to `_combinedfiles`, and is the folder within the configured asset backend that
     * combined files will be stored in. If using a backend shared with other systems, it is usually
     * necessary to distinguish combined files from other assets.
     *
     * @config
     * @var string
     */
    private static $default_combined_files_folder = '_combinedfiles';

    /**
     * Flag to include the hash in the querystring instead of the filename for combined files.
     *
     * By default the `<hash>` of the source files is appended to the end of the combined file
     * (prior to the file extension). If combined files are versioned in source control or running
     * in a distributed environment (such as one where the newest version of a file may not always be
     * immediately available) then it may sometimes be necessary to disable this. When this is set to true,
     * the hash will instead be appended via a querystring parameter to enable cache busting, but not in
     * the filename itself. I.e. `assets/_combinedfiles/name.js?m=<hash>`
     *
     * @config
     * @var bool
     */
    private static $combine_hash_querystring = false;

    /**
     * @var GeneratedAssetHandler
     */
    protected $assetHandler = null;

    /**
     * @var Requirements_Minifier
     */
    protected $minifier = null;

    /**
     * Gets the backend storage for generated files
     *
     * @return GeneratedAssetHandler
     */
    public function getAssetHandler()
    {
        return $this->assetHandler;
    }

    /**
     * Set a new asset handler for this backend
     *
     * @param GeneratedAssetHandler $handler
     */
    public function setAssetHandler(GeneratedAssetHandler $handler)
    {
        $this->assetHandler = $handler;
    }

    /**
     * Gets the minification service for this backend
     *
     * @deprecated 4.0.0:5.0.0
     * @return Requirements_Minifier
     */
    public function getMinifier()
    {
        return $this->minifier;
    }

    /**
     * Set a new minification service for this backend
     *
     * @param Requirements_Minifier $minifier
     */
    public function setMinifier(Requirements_Minifier $minifier = null)
    {
        $this->minifier = $minifier;
    }

    /**
     * Enable or disable the combination of CSS and JavaScript files
     *
     * @param bool $enable
     */
    public function setCombinedFilesEnabled($enable)
    {
        $this->combinedFilesEnabled = (bool)$enable;
    }

    /**
     * Check if header comments are written
     *
     * @return bool
     */
    public function getWriteHeaderComment()
    {
        return $this->writeHeaderComment;
    }

    /**
     * Flag whether header comments should be written for each combined file
     *
     * @param bool $write
     * @return $this
     */
    public function setWriteHeaderComment($write)
    {
        $this->writeHeaderComment = $write;
        return $this;
    }

    /**
     * Set the folder to save combined files in. By default they're placed in _combinedfiles,
     * however this may be an issue depending on your setup, especially for CSS files which often
     * contain relative paths.
     *
     * This must not include any 'assets' prefix
     *
     * @param string $folder
     */
    public function setCombinedFilesFolder($folder)
    {
        $this->combinedFilesFolder = $folder;
    }

    /**
     * Retrieve the combined files folder prefix
     *
     * @return string
     */
    public function getCombinedFilesFolder()
    {
        if ($this->combinedFilesFolder) {
            return $this->combinedFilesFolder;
        }
        return Config::inst()->get(__CLASS__, 'default_combined_files_folder');
    }

    /**
     * Set whether to add caching query params to the requests for file-based requirements.
     * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by
     * filemtime. This has the benefit of allowing the browser to cache the URL infinitely,
     * while automatically busting this cache every time the file is changed.
     *
     * @param bool $var
     */
    public function setSuffixRequirements($var)
    {
        $this->suffixRequirements = $var;
    }

    /**
     * Check whether we want to suffix requirements
     *
     * @return bool
     */
    public function getSuffixRequirements()
    {
        return $this->suffixRequirements;
    }

    /**
     * Set whether you want to write the JS to the body of the page rather than at the end of the
     * head tag.
     *
     * @param bool $var
     * @return $this
     */
    public function setWriteJavascriptToBody($var)
    {
        $this->writeJavascriptToBody = $var;
        return $this;
    }

    /**
     * Check whether you want to write the JS to the body of the page rather than at the end of the
     * head tag.
     *
     * @return bool
     */
    public function getWriteJavascriptToBody()
    {
        return $this->writeJavascriptToBody;
    }

    /**
     * Forces the JavaScript requirements to the end of the body, right before the closing tag
     *
     * @param bool $var
     * @return $this
     */
    public function setForceJSToBottom($var)
    {
        $this->forceJSToBottom = $var;
        return $this;
    }

    /**
     * Check if the JavaScript requirements are written to the end of the body, right before the closing tag
     *
     * @return bool
     */
    public function getForceJSToBottom()
    {
        return $this->forceJSToBottom;
    }

    /**
     * Check if minify files should be combined
     *
     * @return bool
     */
    public function getMinifyCombinedFiles()
    {
        return $this->minifyCombinedFiles;
    }

    /**
     * Set if combined files should be minified
     *
     * @param bool $minify
     * @return $this
     */
    public function setMinifyCombinedFiles($minify)
    {
        $this->minifyCombinedFiles = $minify;
        return $this;
    }

    /**
     * Register the given JavaScript file as required.
     *
     * @param string $file Either relative to docroot or in the form "vendor/package:resource"
     * @param array $options List of options. Available options include:
     * - 'provides' : List of scripts files included in this file
     * - 'async' : Boolean value to set async attribute to script tag
     * - 'defer' : Boolean value to set defer attribute to script tag
     * - 'type' : Override script type= value.
     * - 'integrity' : SubResource Integrity hash
     * - 'crossorigin' : Cross-origin policy for the resource
     */
    public function javascript($file, $options = [])
    {
        $file = ModuleResourceLoader::singleton()->resolvePath($file);

        // Get type
        $type = null;
        if (isset($this->javascript[$file]['type'])) {
            $type = $this->javascript[$file]['type'];
        }
        if (isset($options['type'])) {
            $type = $options['type'];
        }

        // make sure that async/defer is set if it is set once even if file is included multiple times
        $async = (
            isset($options['async']) && $options['async']
            || (
                isset($this->javascript[$file])
                && isset($this->javascript[$file]['async'])
                && $this->javascript[$file]['async']
            )
        );
        $defer = (
            isset($options['defer']) && $options['defer']
            || (
                isset($this->javascript[$file])
                && isset($this->javascript[$file]['defer'])
                && $this->javascript[$file]['defer']
            )
        );
        $integrity = $options['integrity'] ?? null;
        $crossorigin = $options['crossorigin'] ?? null;

        $this->javascript[$file] = [
            'async' => $async,
            'defer' => $defer,
            'type' => $type,
            'integrity' => $integrity,
            'crossorigin' => $crossorigin,
        ];

        // Record scripts included in this file
        if (isset($options['provides'])) {
            $this->providedJavascript[$file] = array_values($options['provides']);
        }
    }

    /**
     * Remove a javascript requirement
     *
     * @param string $file
     */
    protected function unsetJavascript($file)
    {
        unset($this->javascript[$file]);
    }

    /**
     * Gets all scripts that are already provided by prior scripts.
     * This follows these rules:
     *  - Files will not be considered provided if they are separately
     *    included prior to the providing file.
     *  - Providing files can be blocked, and don't provide anything
     *  - Provided files can't be blocked (you need to block the provider)
     *  - If a combined file includes files that are provided by prior
     *    scripts, then these should be excluded from the combined file.
     *  - If a combined file includes files that are provided by later
     *    scripts, then these files should be included in the combined
     *    file, but we can't block the later script either (possible double
     *    up of file).
     *
     * @return array Array of provided files (map of $path => $path)
     */
    public function getProvidedScripts()
    {
        $providedScripts = [];
        $includedScripts = [];
        foreach ($this->javascript as $script => $options) {
            // Ignore scripts that are explicitly blocked
            if (isset($this->blocked[$script])) {
                continue;
            }
            // At this point, the file is included.
            // This might also be combined at this point, potentially.
            $includedScripts[$script] = true;

            // Record any files this provides, EXCEPT those already included by now
            if (isset($this->providedJavascript[$script])) {
                foreach ($this->providedJavascript[$script] as $provided) {
                    if (!isset($includedScripts[$provided])) {
                        $providedScripts[$provided] = $provided;
                    }
                }
            }
        }
        return $providedScripts;
    }

    /**
     * Returns an array of required JavaScript, excluding blocked
     * and duplicates of provided files.
     *
     * @return array
     */
    public function getJavascript()
    {
        return array_diff_key(
            $this->javascript,
            $this->getBlocked(),
            $this->getProvidedScripts()
        );
    }

    /**
     * Gets all javascript, including blocked files. Unwraps the array into a non-associative list
     *
     * @return array Indexed array of javascript files
     */
    protected function getAllJavascript()
    {
        return $this->javascript;
    }

    /**
     * Register the given JavaScript code into the list of requirements
     *
     * @param string $script The script content as a string (without enclosing `<script>` tag)
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
     */
    public function customScript($script, $uniquenessID = null)
    {
        if ($uniquenessID) {
            $this->customScript[$uniquenessID] = $script;
        } else {
            $this->customScript[] = $script;
        }
    }

    /**
     * Return all registered custom scripts
     *
     * @return array
     */
    public function getCustomScripts()
    {
        return array_diff_key($this->customScript, $this->blocked);
    }

    /**
     * Register the given CSS styles into the list of requirements
     *
     * @param string $script CSS selectors as a string (without enclosing `<style>` tag)
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
     */
    public function customCSS($script, $uniquenessID = null)
    {
        if ($uniquenessID) {
            $this->customCSS[$uniquenessID] = $script;
        } else {
            $this->customCSS[] = $script;
        }
    }

    /**
     * Return all registered custom CSS
     *
     * @return array
     */
    public function getCustomCSS()
    {
        return array_diff_key($this->customCSS, $this->blocked);
    }

    /**
     * Add the following custom HTML code to the `<head>` section of the page
     *
     * @param string $html Custom HTML code
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
     */
    public function insertHeadTags($html, $uniquenessID = null)
    {
        if ($uniquenessID) {
            $this->customHeadTags[$uniquenessID] = $html;
        } else {
            $this->customHeadTags[] = $html;
        }
    }

    /**
     * Return all custom head tags
     *
     * @return array
     */
    public function getCustomHeadTags()
    {
        return array_diff_key($this->customHeadTags, $this->blocked);
    }

    /**
     * Include the content of the given JavaScript file in the list of requirements. Dollar-sign
     * variables will be interpolated with values from $vars similar to a .ss template.
     *
     * @param string $file The template file to load, relative to docroot
     * @param string[] $vars The array of variables to interpolate.
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
     */
    public function javascriptTemplate($file, $vars, $uniquenessID = null)
    {
        $file = ModuleResourceLoader::singleton()->resolvePath($file);
        $absolutePath = Director::getAbsFile($file);
        if (!file_exists($absolutePath)) {
            throw new InvalidArgumentException("Javascript template file {$file} does not exist");
        }

        $script = file_get_contents($absolutePath);
        $search = [];
        $replace = [];

        if ($vars) {
            foreach ($vars as $k => $v) {
                $search[] = '$' . $k;
                $replace[] = str_replace("\\'", "'", Convert::raw2js($v));
            }
        }

        $script = str_replace($search, $replace, $script);
        $this->customScript($script, $uniquenessID);
    }

    /**
     * Register the given stylesheet into the list of requirements.
     *
     * @param string $file The CSS file to load, relative to site root
     * @param string $media Comma-separated list of media types to use in the link tag
     *                      (e.g. 'screen,projector')
     * @param array $options List of options. Available options include:
     * - 'integrity' : SubResource Integrity hash
     * - 'crossorigin' : Cross-origin policy for the resource
     */
    public function css($file, $media = null, $options = [])
    {
        $file = ModuleResourceLoader::singleton()->resolvePath($file);

        $integrity = $options['integrity'] ?? null;
        $crossorigin = $options['crossorigin'] ?? null;

        $this->css[$file] = [
            "media" => $media,
            "integrity" => $integrity,
            "crossorigin" => $crossorigin,
        ];
    }

    /**
     * Remove a css requirement
     *
     * @param string $file
     */
    protected function unsetCSS($file)
    {
        unset($this->css[$file]);
    }

    /**
     * Get the list of registered CSS file requirements, excluding blocked files
     *
     * @return array Associative array of file to spec
     */
    public function getCSS()
    {
        return array_diff_key($this->css, $this->blocked);
    }

    /**
     * Gets all CSS files requirements, including blocked
     *
     * @return array Associative array of file to spec
     */
    protected function getAllCSS()
    {
        return $this->css;
    }

    /**
     * Gets the list of all blocked files
     *
     * @return array
     */
    public function getBlocked()
    {
        return $this->blocked;
    }

    /**
     * Clear either a single or all requirements
     *
     * Caution: Clearing single rules added via customCSS and customScript only works if you
     * originally specified a $uniquenessID.
     *
     * @param string|int $fileOrID
     */
    public function clear($fileOrID = null)
    {
        $types = [
            'javascript',
            'css',
            'customScript',
            'customCSS',
            'customHeadTags',
            'combinedFiles',
        ];
        foreach ($types as $type) {
            if ($fileOrID) {
                if (isset($this->{$type}[$fileOrID])) {
                    $this->disabled[$type][$fileOrID] = $this->{$type}[$fileOrID];
                    unset($this->{$type}[$fileOrID]);
                }
            } else {
                $this->disabled[$type] = $this->{$type};
                $this->{$type} = [];
            }
        }
    }

    /**
     * Restore requirements cleared by call to Requirements::clear
     */
    public function restore()
    {
        $types = [
            'javascript',
            'css',
            'customScript',
            'customCSS',
            'customHeadTags',
            'combinedFiles',
        ];
        foreach ($types as $type) {
            $this->{$type} = $this->disabled[$type];
        }
    }

    /**
     * Block inclusion of a specific file
     *
     * The difference between this and {@link clear} is that the calling order does not matter;
     * {@link clear} must be called after the initial registration, whereas {@link block} can be
     * used in advance. This is useful, for example, to block scripts included by a superclass
     * without having to override entire functions and duplicate a lot of code.
     *
     * Note that blocking should be used sparingly because it's hard to trace where an file is
     * being blocked from.
     *
     * @param string|int $fileOrID Relative path from webroot, module resource reference or
     *                             requirement API ID
     */
    public function block($fileOrID)
    {
        if (is_string($fileOrID)) {
            $fileOrID = ModuleResourceLoader::singleton()->resolvePath($fileOrID);
        }
        $this->blocked[$fileOrID] = $fileOrID;
    }

    /**
     * Remove an item from the block list
     *
     * @param string|int $fileOrID
     */
    public function unblock($fileOrID)
    {
        unset($this->blocked[$fileOrID]);
    }

    /**
     * Removes all items from the block list
     */
    public function unblockAll()
    {
        $this->blocked = [];
    }

    /**
     * Update the given HTML content with the appropriate include tags for the registered
     * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
     * including a head and body tag.
     *
     * @param string $content HTML content that has already been parsed from the $templateFile
     *                             through {@link SSViewer}
     * @return string HTML content augmented with the requirements tags
     */
    public function includeInHTML($content)
    {
        if (func_num_args() > 1) {
            Deprecation::notice(
                '5.0',
                '$templateFile argument is deprecated. includeInHTML takes a sole $content parameter now.'
            );
            $content = func_get_arg(1);
        }

        // Skip if content isn't injectable, or there is nothing to inject
        $tagsAvailable = preg_match('#</head\b#', $content);
        $hasFiles = $this->css || $this->javascript || $this->customCSS || $this->customScript || $this->customHeadTags;
        if (!$tagsAvailable || !$hasFiles) {
            return $content;
        }
        $requirements = '';
        $jsRequirements = '';

        // Combine files - updates $this->javascript and $this->css
        $this->processCombinedFiles();

        // Script tags for js links
        foreach ($this->getJavascript() as $file => $attributes) {
            // Build html attributes
            $htmlAttributes = [
                'type' => isset($attributes['type']) ? $attributes['type'] : "application/javascript",
                'src' => $this->pathForFile($file),
            ];
            if (!empty($attributes['async'])) {
                $htmlAttributes['async'] = 'async';
            }
            if (!empty($attributes['defer'])) {
                $htmlAttributes['defer'] = 'defer';
            }
            if (!empty($attributes['integrity'])) {
                $htmlAttributes['integrity'] = $attributes['integrity'];
            }
            if (!empty($attributes['crossorigin'])) {
                $htmlAttributes['crossorigin'] = $attributes['crossorigin'];
            }
            $jsRequirements .= HTML::createTag('script', $htmlAttributes);
            $jsRequirements .= "\n";
        }

        // Add all inline JavaScript *after* including external files they might rely on
        foreach ($this->getCustomScripts() as $script) {
            $jsRequirements .= HTML::createTag(
                'script',
                [ 'type' => 'application/javascript' ],
                "//<![CDATA[\n{$script}\n//]]>"
            );
            $jsRequirements .= "\n";
        }

        // CSS file links
        foreach ($this->getCSS() as $file => $params) {
            $htmlAttributes = [
                'rel' => 'stylesheet',
                'type' => 'text/css',
                'href' => $this->pathForFile($file),
            ];
            if (!empty($params['media'])) {
                $htmlAttributes['media'] = $params['media'];
            }
            if (!empty($params['integrity'])) {
                $htmlAttributes['integrity'] = $params['integrity'];
            }
            if (!empty($params['crossorigin'])) {
                $htmlAttributes['crossorigin'] = $params['crossorigin'];
            }
            $requirements .= HTML::createTag('link', $htmlAttributes);
            $requirements .= "\n";
        }

        // Literal custom CSS content
        foreach ($this->getCustomCSS() as $css) {
            $requirements .= HTML::createTag('style', ['type' => 'text/css'], "\n{$css}\n");
            $requirements .= "\n";
        }

        foreach ($this->getCustomHeadTags() as $customHeadTag) {
            $requirements .= "{$customHeadTag}\n";
        }

        // Inject CSS  into body
        $content = $this->insertTagsIntoHead($requirements, $content);

        // Inject scripts
        if ($this->getForceJSToBottom()) {
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
        } elseif ($this->getWriteJavascriptToBody()) {
            $content = $this->insertScriptsIntoBody($jsRequirements, $content);
        } else {
            $content = $this->insertTagsIntoHead($jsRequirements, $content);
        }
        return $content;
    }

    /**
     * Given a block of HTML, insert the given scripts at the bottom before
     * the closing `</body>` tag
     *
     * @param string $jsRequirements String containing one or more javascript `<script />` tags
     * @param string $content HTML body
     * @return string Merged HTML
     */
    protected function insertScriptsAtBottom($jsRequirements, $content)
    {
        // Forcefully put the scripts at the bottom of the body instead of before the first
        // script tag.
        $content = preg_replace(
            '/(<\/body[^>]*>)/i',
            $this->escapeReplacement($jsRequirements) . '\\1',
            $content
        );
        return $content;
    }

    /**
     * Given a block of HTML, insert the given scripts inside the `<body></body>`
     *
     * @param string $jsRequirements String containing one or more javascript `<script />` tags
     * @param string $content HTML body
     * @return string Merged HTML
     */
    protected function insertScriptsIntoBody($jsRequirements, $content)
    {
        // If your template already has script tags in the body, then we try to put our script
        // tags just before those. Otherwise, we put it at the bottom.
        $bodyTagPosition = stripos($content, '<body');
        $scriptTagPosition = stripos($content, '<script', $bodyTagPosition);

        $commentTags = [];
        $canWriteToBody = ($scriptTagPosition !== false)
            &&
            // Check that the script tag is not inside a html comment tag
            !(
                preg_match('/.*(?|(<!--)|(-->))/U', $content, $commentTags, 0, $scriptTagPosition)
                &&
                $commentTags[1] == '-->'
            );

        if ($canWriteToBody) {
            // Insert content before existing script tags
            $content = substr($content, 0, $scriptTagPosition)
                . $jsRequirements
                . substr($content, $scriptTagPosition);
        } else {
            // Insert content at bottom of page otherwise
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
        }

        return $content;
    }

    /**
     * Given a block of HTML, insert the given code inside the `<head></head>` block
     *
     * @param string $jsRequirements String containing one or more html tags
     * @param string $content HTML body
     * @return string Merged HTML
     */
    protected function insertTagsIntoHead($jsRequirements, $content)
    {
        $content = preg_replace(
            '/(<\/head>)/i',
            $this->escapeReplacement($jsRequirements) . '\\1',
            $content
        );
        return $content;
    }

    /**
     * Safely escape a literal string for use in preg_replace replacement
     *
     * @param string $replacement
     * @return string
     */
    protected function escapeReplacement($replacement)
    {
        return addcslashes($replacement, '\\$');
    }

    /**
     * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given
     * HTTP Response
     *
     * @param HTTPResponse $response
     */
    public function includeInResponse(HTTPResponse $response)
    {
        $this->processCombinedFiles();
        $jsRequirements = [];
        $cssRequirements = [];

        foreach ($this->getJavascript() as $file => $attributes) {
            $path = $this->pathForFile($file);
            if ($path) {
                $jsRequirements[] = str_replace(',', '%2C', $path);
            }
        }

        if (count($jsRequirements)) {
            $response->addHeader('X-Include-JS', implode(',', $jsRequirements));
        }

        foreach ($this->getCSS() as $file => $params) {
            $path = $this->pathForFile($file);
            if ($path) {
                $path = str_replace(',', '%2C', $path);
                $cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path;
            }
        }

        if (count($cssRequirements)) {
            $response->addHeader('X-Include-CSS', implode(',', $cssRequirements));
        }
    }

    /**
     * Add i18n files from the given javascript directory. SilverStripe expects that the given
     * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js,
     * etc.
     *
     * @param string $langDir The JavaScript lang directory, relative to the site root, e.g.,
     *                         'framework/javascript/lang'
     * @param bool $return Return all relative file paths rather than including them in
     *                         requirements
     *
     * @return array|null All relative files if $return is true, or null otherwise
     */
    public function add_i18n_javascript($langDir, $return = false)
    {
        $langDir = ModuleResourceLoader::singleton()->resolvePath($langDir);

        $files = [];
        $candidates = [
            'en',
            'en_US',
            i18n::getData()->langFromLocale(i18n::config()->get('default_locale')),
            i18n::config()->get('default_locale'),
            i18n::getData()->langFromLocale(i18n::get_locale()),
            i18n::get_locale(),
            strtolower(DBField::create_field('Locale', i18n::get_locale())->RFC1766()),
            strtolower(DBField::create_field('Locale', i18n::config()->get('default_locale'))->RFC1766())
        ];

        $candidates = array_map(
            function ($candidate) {
                return $candidate . '.js';
            },
            $candidates
        );

        foreach ($candidates as $candidate) {
            $relativePath = Path::join($langDir, $candidate);
            $absolutePath = Director::getAbsFile($relativePath);
            if (file_exists($absolutePath)) {
                $files[] = $relativePath;
            }
        }

        if ($return) {
            return $files;
        }

        foreach ($files as $file) {
            $this->javascript($file);
        }
        return null;
    }

    /**
     * Finds the path for specified file
     *
     * @param string $fileOrUrl
     * @return string|bool
     */
    protected function pathForFile($fileOrUrl)
    {
        // Since combined urls could be root relative, treat them as urls here.
        if (preg_match('{^(//)|(http[s]?:)}', $fileOrUrl) || Director::is_root_relative_url($fileOrUrl)) {
            return $fileOrUrl;
        } else {
            return Injector::inst()->get(ResourceURLGenerator::class)->urlForResource($fileOrUrl);
        }
    }

    /**
     * Concatenate several css or javascript files into a single dynamically generated file. This
     * increases performance by fewer HTTP requests.
     *
     * The combined file is regenerated based on every file modification time. Optionally a
     * rebuild can be triggered by appending ?flush=1 to the URL.
     *
     * All combined files will have a comment on the start of each concatenated file denoting their
     * original position.
     *
     * CAUTION: You're responsible for ensuring that the load order for combined files is
     * retained - otherwise combining JavaScript files can lead to functional errors in the
     * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also
     * only include each file once across all includes and combinations in a single page load.
     *
     * CAUTION: Combining CSS Files discards any "media" information.
     *
     * Example for combined JavaScript:
     * <code>
     * Requirements::combine_files(
     *    'foobar.js',
     *    array(
     *        'mysite/javascript/foo.js',
     *        'mysite/javascript/bar.js',
     *    ),
     *    array(
     *        'async' => true,
     *        'defer' => true,
     *    )
     * );
     * </code>
     *
     * Example for combined CSS:
     * <code>
     * Requirements::combine_files(
     *    'foobar.css',
     *    array(
     *        'mysite/javascript/foo.css',
     *        'mysite/javascript/bar.css',
     *    ),
     *    array(
     *        'media' => 'print',
     *    )
     * );
     * </code>
     *
     * @param string $combinedFileName Filename of the combined file relative to docroot
     * @param array $files Array of filenames relative to docroot
     * @param array $options Array of options for combining files. Available options are:
     * - 'media' : If including CSS Files, you can specify a media type
     * - 'async' : If including JavaScript Files, boolean value to set async attribute to script tag
     * - 'defer' : If including JavaScript Files, boolean value to set defer attribute to script tag
     */
    public function combineFiles($combinedFileName, $files, $options = [])
    {
        if (is_string($options)) {
            Deprecation::notice('4.0', 'Parameter media is deprecated. Use options array instead.');
            $options = ['media' => $options];
        }
        // Skip this combined files if already included
        if (isset($this->combinedFiles[$combinedFileName])) {
            return;
        }

        // Add all files to necessary type list
        $paths = [];
        $combinedType = null;
        foreach ($files as $file) {
            // Get file details
            list($path, $type) = $this->parseCombinedFile($file);
            if ($type === 'javascript') {
                $type = 'js';
            }
            if ($combinedType && $type && $combinedType !== $type) {
                throw new InvalidArgumentException(
                    "Cannot mix js and css files in same combined file {$combinedFileName}"
                );
            }
            switch ($type) {
                case 'css':
                    $this->css($path, (isset($options['media']) ? $options['media'] : null), $options);
                    break;
                case 'js':
                    $this->javascript($path, $options);
                    break;
                default:
                    throw new InvalidArgumentException("Invalid combined file type: {$type}");
            }
            $combinedType = $type;
            $paths[] = $path;
        }

        // Duplicate check
        foreach ($this->combinedFiles as $existingCombinedFilename => $combinedItem) {
            $existingFiles = $combinedItem['files'];
            $duplicates = array_intersect($existingFiles, $paths);
            if ($duplicates) {
                throw new InvalidArgumentException(sprintf(
                    "Requirements_Backend::combine_files(): Already included file(s) %s in combined file '%s'",
                    implode(',', $duplicates),
                    $existingCombinedFilename
                ));
            }
        }

        $this->combinedFiles[$combinedFileName] = [
            'files' => $paths,
            'type' => $combinedType,
            'options' => $options,
        ];
    }

    /**
     * Return path and type of given combined file
     *
     * @param string|array $file Either a file path, or an array spec
     * @return array array with two elements, path and type of file
     */
    protected function parseCombinedFile($file)
    {
        // Array with path and type keys
        if (is_array($file) && isset($file['path']) && isset($file['type'])) {
            return [$file['path'], $file['type']];
        }

        // Extract value from indexed array
        if (is_array($file)) {
            $path = array_shift($file);

            // See if there's a type specifier
            if ($file) {
                $type = array_shift($file);
                return [$path, $type];
            }

            // Otherwise convent to string
            $file = $path;
        }

        $type = File::get_file_extension($file);
        return [$file, $type];
    }

    /**
     * Return all combined files; keys are the combined file names, values are lists of
     * associative arrays with 'files', 'type', and 'media' keys for details about this
     * combined file.
     *
     * @return array
     */
    public function getCombinedFiles()
    {
        return array_diff_key($this->combinedFiles, $this->blocked);
    }

    /**
     * Includes all combined files, including blocked ones
     *
     * @return array
     */
    protected function getAllCombinedFiles()
    {
        return $this->combinedFiles;
    }

    /**
     * Clears all combined files
     */
    public function deleteAllCombinedFiles()
    {
        $combinedFolder = $this->getCombinedFilesFolder();
        if ($combinedFolder) {
            $this->getAssetHandler()->removeContent($combinedFolder);
        }
    }

    /**
     * Clear all registered CSS and JavaScript file combinations
     */
    public function clearCombinedFiles()
    {
        $this->combinedFiles = [];
    }

    /**
     * Do the heavy lifting involved in combining the combined files.
     */
    public function processCombinedFiles()
    {
        // Check if combining is enabled
        if (!$this->getCombinedFilesEnabled()) {
            return;
        }

        // Before scripts are modified, detect files that are provided by preceding ones
        $providedScripts = $this->getProvidedScripts();

        // Process each combined files
        foreach ($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
            $fileList = $combinedItem['files'];
            $type = $combinedItem['type'];
            $options = $combinedItem['options'];

            // Generate this file, unless blocked
            $combinedURL = null;
            if (!isset($this->blocked[$combinedFile])) {
                // Filter files for blocked / provided
                $filteredFileList = array_diff(
                    $fileList,
                    $this->getBlocked(),
                    $providedScripts
                );
                $combinedURL = $this->getCombinedFileURL($combinedFile, $filteredFileList, $type);
            }

            // Replace all existing files, injecting the combined file at the position of the first item
            // in order to preserve inclusion order.
            // Note that we iterate across blocked files in order to get the correct order, and validate
            // that the file is included in the correct location (regardless of which files are blocked).
            $included = false;
            switch ($type) {
                case 'css': {
                    $newCSS = []; // Assoc array of css file => spec
                    foreach ($this->getAllCSS() as $css => $spec) {
                        if (!in_array($css, $fileList)) {
                            $newCSS[$css] = $spec;
                        } elseif (!$included && $combinedURL) {
                            $newCSS[$combinedURL] = [
                                'media' => $options['media'] ?? null,
                                'integrity' => $options['integrity'] ?? null,
                                'crossorigin' => $options['crossorigin'] ?? null,
                            ];
                            $included = true;
                        }
                        // If already included, or otherwise blocked, then don't add into CSS
                    }
                    $this->css = $newCSS;
                    break;
                }
                case 'js': {
                    // Assoc array of file => attributes
                    $newJS = [];
                    foreach ($this->getAllJavascript() as $script => $attributes) {
                        if (!in_array($script, $fileList)) {
                            $newJS[$script] = $attributes;
                        } elseif (!$included && $combinedURL) {
                            $newJS[$combinedURL] = $options;
                            $included = true;
                        }
                        // If already included, or otherwise blocked, then don't add into scripts
                    }
                    $this->javascript = $newJS;
                    break;
                }
            }
        }
    }

    /**
     * Given a set of files, combine them (as necessary) and return the url
     *
     * @param string $combinedFile Filename for this combined file
     * @param array $fileList List of files to combine
     * @param string $type Either 'js' or 'css'
     * @return string|null URL to this resource, if there are files to combine
     * @throws Exception
     */
    protected function getCombinedFileURL($combinedFile, $fileList, $type)
    {
        // Skip empty lists
        if (empty($fileList)) {
            return null;
        }

        // Generate path (Filename)
        $hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
        if (!$hashQuerystring) {
            $combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
        }
        $combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);

        // Send file combination request to the backend, with an optional callback to perform regeneration
        $minify = $this->getMinifyCombinedFiles();
        if ($minify && !$this->minifier) {
            throw new Exception(
                sprintf(
                    <<<MESSAGE
Cannot minify files without a minification service defined.
Set %s::minifyCombinedFiles to false, or inject a %s service on
%s.properties.minifier
MESSAGE
                    ,
                    __CLASS__,
                    Requirements_Minifier::class,
                    __CLASS__
                )
            );
        }

        $combinedURL = $this
            ->getAssetHandler()
            ->getContentURL(
                $combinedFileID,
                function () use ($fileList, $minify, $type) {
                    // Physically combine all file content
                    $combinedData = '';
                    foreach ($fileList as $file) {
                        $filePath = Director::getAbsFile($file);
                        if (!file_exists($filePath)) {
                            throw new InvalidArgumentException("Combined file {$file} does not exist");
                        }
                        $fileContent = file_get_contents($filePath);
                        // Use configured minifier
                        if ($minify) {
                            $fileContent = $this->minifier->minify($fileContent, $type, $file);
                        }

                        if ($this->writeHeaderComment) {
                            // Write a header comment for each file for easier identification and debugging.
                            $combinedData .= "/****** FILE: $file *****/\n";
                        }
                        $combinedData .= $fileContent . "\n";
                    }
                    return $combinedData;
                }
            );

        // If the name isn't hashed, we will need to append the querystring m= parameter instead
        // Since url won't be automatically suffixed, add it in here
        if ($hashQuerystring && $this->getSuffixRequirements()) {
            $hash = $this->hashOfFiles($fileList);
            $q = stripos($combinedURL, '?') === false ? '?' : '&';
            $combinedURL .= "{$q}m={$hash}";
        }

        return $combinedURL;
    }

    /**
     * Given a filename and list of files, generate a new filename unique to these files
     *
     * @param string $combinedFile
     * @param array $fileList
     * @return string
     */
    protected function hashedCombinedFilename($combinedFile, $fileList)
    {
        $name = pathinfo($combinedFile, PATHINFO_FILENAME);
        $hash = $this->hashOfFiles($fileList);
        $extension = File::get_file_extension($combinedFile);
        return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
    }

    /**
     * Check if combined files are enabled
     *
     * @return bool
     */
    public function getCombinedFilesEnabled()
    {
        if (isset($this->combinedFilesEnabled)) {
            return $this->combinedFilesEnabled;
        }

        // Non-dev sites are always combined
        if (!Director::isDev()) {
            return true;
        }

        // Fallback to default
        return Config::inst()->get(__CLASS__, 'combine_in_dev');
    }

    /**
     * For a given filelist, determine some discriminating value to determine if
     * any of these files have changed.
     *
     * @param array $fileList List of files
     * @return string SHA1 bashed file hash
     */
    protected function hashOfFiles($fileList)
    {
        // Get hash based on hash of each file
        $hash = '';
        foreach ($fileList as $file) {
            $absolutePath = Director::getAbsFile($file);
            if (file_exists($absolutePath)) {
                $hash .= sha1_file($absolutePath);
            } else {
                throw new InvalidArgumentException("Combined file {$file} does not exist");
            }
        }
        return sha1($hash);
    }

    /**
     * Registers the given themeable stylesheet as required.
     *
     * A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
     * and it that doesn't exist and the module parameter is set then a CSS file with that name in
     * the module is used.
     *
     * @param string $name The name of the file - eg '/css/File.css' would have the name 'File'
     * @param string $media Comma-separated list of media types to use in the link tag
     *                       (e.g. 'screen,projector')
     */
    public function themedCSS($name, $media = null)
    {
        $path = ThemeResourceLoader::inst()->findThemedCSS($name, SSViewer::get_themes());
        if ($path) {
            $this->css($path, $media);
        } else {
            throw new InvalidArgumentException(
                "The css file doesn't exist. Please check if the file $name.css exists in any context or search for "
                . "themedCSS references calling this file in your templates."
            );
        }
    }

    /**
     * Registers the given themeable javascript as required.
     *
     * A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
     * and it that doesn't exist and the module parameter is set then a javascript file with that name in
     * the module is used.
     *
     * @param string $name The name of the file - eg '/js/File.js' would have the name 'File'
     * @param string $type Comma-separated list of types to use in the script tag
     *                       (e.g. 'text/javascript,text/ecmascript')
     */
    public function themedJavascript($name, $type = null)
    {
        $path = ThemeResourceLoader::inst()->findThemedJavascript($name, SSViewer::get_themes());
        if ($path) {
            $opts = [];
            if ($type) {
                $opts['type'] = $type;
            }
            $this->javascript($path, $opts);
        } else {
            throw new InvalidArgumentException(
                "The javascript file doesn't exist. Please check if the file $name.js exists in any "
                . "context or search for themedJavascript references calling this file in your templates."
            );
        }
    }

    /**
     * Output debugging information.
     */
    public function debug()
    {
        Debug::show($this->javascript);
        Debug::show($this->css);
        Debug::show($this->customCSS);
        Debug::show($this->customScript);
        Debug::show($this->customHeadTags);
        Debug::show($this->combinedFiles);
    }
}
