<?php

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

namespace Joomla\CMS\Document\Renderer\Html;

use Joomla\CMS\Document\DocumentRenderer;
use Joomla\CMS\WebAsset\WebAssetItemInterface;

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

/**
 * JDocument head renderer
 *
 * @since  4.0.0
 */
class ScriptsRenderer extends DocumentRenderer
{
    /**
     * List of already rendered src
     *
     * @var array
     *
     * @since   4.0.0
     */
    private $renderedSrc = [];

    /**
     * Renders the document script tags and returns the results as a string
     *
     * @param   string  $head     (unused)
     * @param   array   $params   Associative array of values
     * @param   string  $content  The script
     *
     * @return  string  The output of the script
     *
     * @since   4.0.0
     */
    public function render($head, $params = [], $content = null)
    {
        // Get line endings
        $lnEnd        = $this->_doc->_getLineEnd();
        $tab          = $this->_doc->_getTab();
        $buffer       = '';
        $wam          = $this->_doc->getWebAssetManager();
        $assets       = $wam->getAssets('script', true);

        // Get a list of inline assets and their relation with regular assets
        $inlineAssets   = $wam->filterOutInlineAssets($assets);
        $inlineRelation = $wam->getInlineRelation($inlineAssets);

        // Generate importmap first
        $buffer .= $this->renderImportmap($assets);

        // Merge with existing scripts, for rendering
        $assets = array_merge(array_values($assets), $this->_doc->_scripts);

        // Generate script file links
        foreach ($assets as $key => $item) {
            // Check whether we have an Asset instance, or old array with attributes
            $asset = $item instanceof WebAssetItemInterface ? $item : null;

            // Add src attribute for non Asset item
            if (!$asset) {
                $item['src'] = $key;
            }

            // Check for inline content "before"
            if ($asset && !empty($inlineRelation[$asset->getName()]['before'])) {
                foreach ($inlineRelation[$asset->getName()]['before'] as $itemBefore) {
                    $buffer .= $this->renderInlineElement($itemBefore);

                    // Remove this item from inline queue
                    unset($inlineAssets[$itemBefore->getName()]);
                }
            }

            $buffer .= $this->renderElement($item);

            // Check for inline content "after"
            if ($asset && !empty($inlineRelation[$asset->getName()]['after'])) {
                foreach ($inlineRelation[$asset->getName()]['after'] as $itemBefore) {
                    $buffer .= $this->renderInlineElement($itemBefore);

                    // Remove this item from inline queue
                    unset($inlineAssets[$itemBefore->getName()]);
                }
            }
        }

        // Generate script declarations for assets
        foreach ($inlineAssets as $item) {
            $buffer .= $this->renderInlineElement($item);
        }

        // Generate script declarations for old scripts
        foreach ($this->_doc->_script as $type => $contents) {
            // Test for B.C. in case someone still store script declarations as single string
            if (\is_string($contents)) {
                $contents = [$contents];
            }

            foreach ($contents as $content) {
                $buffer .= $this->renderInlineElement(
                    [
                        'type'    => $type,
                        'content' => $content,
                    ]
                );
            }
        }

        // Output the custom tags - array_unique makes sure that we don't output the same tags twice
        foreach (array_unique($this->_doc->_custom) as $custom) {
            $buffer .= $tab . $custom . $lnEnd;
        }

        return ltrim($buffer, $tab);
    }

    /**
     * Renders the element
     *
     * @param   WebAssetItemInterface|array  $item  The element
     *
     * @return  string  The resulting string
     *
     * @since   4.0.0
     */
    private function renderElement($item): string
    {
        $buffer = '';
        $asset  = $item instanceof WebAssetItemInterface ? $item : null;
        $src    = $asset ? $asset->getUri() : ($item['src'] ?? '');

        // Make sure we have a src, and it not already rendered
        if (!$src || !empty($this->renderedSrc[$src])) {
            return '';
        }

        $lnEnd        = $this->_doc->_getLineEnd();
        $tab          = $this->_doc->_getTab();
        $mediaVersion = $this->_doc->getMediaVersion();

        // Get the attributes and other options
        if ($asset) {
            $attribs     = $asset->getAttributes();
            $version     = $asset->getVersion();
            $conditional = $asset->getOption('conditional');

            // Add an asset info for debugging
            if (JDEBUG) {
                $attribs['data-asset-name'] = $asset->getName();

                if ($asset->getDependencies()) {
                    $attribs['data-asset-dependencies'] = implode(',', $asset->getDependencies());
                }

                if ($asset->getOption('deprecated')) {
                    @trigger_error(
                        \sprintf('Web Asset script [%s] is deprecated. %s', $asset->getName(), $asset->getOption('deprecatedMsg', '')),
                        E_USER_DEPRECATED
                    );
                }
            }
        } else {
            $attribs     = $item;
            $version     = $attribs['options']['version'] ?? '';
            $conditional = !empty($attribs['options']['conditional']) ? $attribs['options']['conditional'] : null;
        }

        // Add "nonce" attribute if exist
        if ($this->_doc->cspNonce && !\is_null($this->_doc->cspNonce)) {
            $attribs['nonce'] = $this->_doc->cspNonce;
        }

        // To prevent double rendering
        $this->renderedSrc[$src] = true;

        // Check if script uses media version.
        if ($version && !str_contains($src, '?') && ($mediaVersion || $version !== 'auto')) {
            $src .= '?' . ($version === 'auto' ? $mediaVersion : $version);
        }

        $buffer .= $tab;

        // This is for IE conditional statements support.
        if (!\is_null($conditional)) {
            $buffer .= '<!--[if ' . $conditional . ']>';
        }

        // Render the element with attributes
        $buffer .= '<script src="' . htmlspecialchars($src) . '"';
        $buffer .= $this->renderAttributes($attribs);
        $buffer .= '></script>';

        // This is for IE conditional statements support.
        if (!\is_null($conditional)) {
            $buffer .= '<![endif]-->';
        }

        $buffer .= $lnEnd;

        return $buffer;
    }

    /**
     * Renders the inline element
     *
     * @param   WebAssetItemInterface|array  $item  The element
     *
     * @return  string  The resulting string
     *
     * @since   4.0.0
     */
    private function renderInlineElement($item): string
    {
        $buffer = '';
        $lnEnd  = $this->_doc->_getLineEnd();
        $tab    = $this->_doc->_getTab();

        if ($item instanceof WebAssetItemInterface) {
            $attribs = $item->getAttributes();
            $content = $item->getOption('content');
        } else {
            $attribs = $item;
            $content = $item['content'] ?? '';

            unset($attribs['content']);
        }

        // Do not produce empty elements
        if (!$content) {
            return '';
        }

        // Add "nonce" attribute if exist
        if ($this->_doc->cspNonce && !\is_null($this->_doc->cspNonce)) {
            $attribs['nonce'] = $this->_doc->cspNonce;
        }

        $buffer .= $tab . '<script';
        $buffer .= $this->renderAttributes($attribs);
        $buffer .= '>';

        // This is for full XHTML support.
        if ($this->_doc->_mime !== 'text/html') {
            $buffer .= $tab . $tab . '//<![CDATA[' . $lnEnd;
        }

        $buffer .= $content;

        // See above note
        if ($this->_doc->_mime !== 'text/html') {
            $buffer .= $tab . $tab . '//]]>' . $lnEnd;
        }

        $buffer .= '</script>' . $lnEnd;

        return $buffer;
    }

    /**
     * Renders the element attributes
     *
     * @param   array  $attributes  The element attributes
     *
     * @return  string  The attributes string
     *
     * @since   4.0.0
     */
    private function renderAttributes(array $attributes): string
    {
        $buffer = '';

        $defaultJsMimes         = ['text/javascript', 'application/javascript', 'text/x-javascript', 'application/x-javascript'];
        $html5NoValueAttributes = ['defer', 'async', 'nomodule'];

        foreach ($attributes as $attrib => $value) {
            // Don't add the 'options' attribute. This attribute is for internal use (version, conditional, etc).
            if ($attrib === 'options' || $attrib === 'src') {
                continue;
            }

            // Don't add type attribute if document is HTML5 and it's a default mime type. 'mime' is for B/C.
            if (\in_array($attrib, ['type', 'mime']) && $this->_doc->isHtml5() && \in_array($value, $defaultJsMimes)) {
                continue;
            }

            // B/C: If defer and async is false or empty don't render the attribute. Also skip if value is bool:false.
            if (\in_array($attrib, ['defer', 'async']) && !$value || $value === false) {
                continue;
            }

            // NoValue attribute, if it have bool:true
            $isNoValueAttrib = $value === true || \in_array($attrib, $html5NoValueAttributes);

            // Don't add type attribute if document is HTML5 and it's a default mime type. 'mime' is for B/C.
            if ($attrib === 'mime') {
                $attrib = 'type';
            } elseif ($isNoValueAttrib) {
                // NoValue attribute in non HTML5 should contain a value, set it equal to attribute name.
                $value = $attrib;
            }

            // Add attribute to script tag output.
            $buffer .= ' ' . htmlspecialchars($attrib, ENT_COMPAT, 'UTF-8');

            if (!($this->_doc->isHtml5() && $isNoValueAttrib)) {
                // Json encode value if it's an array.
                $value = !\is_scalar($value) ? json_encode($value) : $value;

                $buffer .= '="' . htmlspecialchars($value, ENT_COMPAT, 'UTF-8') . '"';
            }
        }

        return $buffer;
    }

    /**
     * Renders ESM importmap element
     *
     * @param   WebAssetItemInterface[]  $assets  The assets list
     *
     * @return  string  The attributes string
     *
     * @since   5.0.0
     */
    private function renderImportmap(array &$assets)
    {
        $buffer       = '';
        $importmap    = ['imports' => []];
        $tab          = $this->_doc->_getTab();
        $mediaVersion = $this->_doc->getMediaVersion();

        // Collect a modules for the map
        foreach ($assets as $k => $item) {
            // Only importmap:true can be mapped
            if (!$item->getOption('importmap')) {
                continue;
            }

            $esmName  = $item->getOption('importmapName') ?: $item->getName();
            $esmScope = $item->getOption('importmapScope');
            $version  = $item->getVersion();
            $src      = $item->getUri();

            if (!$src) {
                continue;
            }

            // Check if script uses media version.
            if ($version && !str_contains($src, '?') && !str_ends_with($src, '/') && ($mediaVersion || $version !== 'auto')) {
                $src .= '?' . ($version === 'auto' ? $mediaVersion : $version);
            }

            if (!$esmScope) {
                $importmap['imports'][$esmName] = $src;
            } else {
                $importmap['scopes'][$esmScope][$esmName] = $src;
            }

            // Remove the item from list of assets after it were added to the map.
            unset($assets[$k]);
        }

        if (!empty($importmap['imports'])) {
            // Add polyfill when exists
            if (!empty($assets['es-module-shims'])) {
                $buffer .= $this->renderElement($assets['es-module-shims']);
            }

            // Render importmap
            $jsonImports = json_encode($importmap, JDEBUG ? JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES : JSON_UNESCAPED_SLASHES);
            $attribs     = ['type' => 'importmap'];

            // Add "nonce" attribute if exist
            if ($this->_doc->cspNonce && !\is_null($this->_doc->cspNonce)) {
                $attribs['nonce'] = $this->_doc->cspNonce;
            }

            $buffer .= $tab . '<script';
            $buffer .= $this->renderAttributes($attribs);
            $buffer .= '>' . $jsonImports . '</script>';
        }

        // Remove polyfill for "importmap" from assets list
        unset($assets['es-module-shims']);

        return $buffer;
    }
}
