<?php
/*
 * Copyright (C) 2025 Xibo Signage Ltd
 *
 * Xibo - Digital Signage - https://xibosignage.com
 *
 * This file is part of Xibo.
 *
 * Xibo is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * any later version.
 *
 * Xibo 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 Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Xibo.  If not, see <http://www.gnu.org/licenses/>.
 */

namespace Xibo\Widget\Render;

use Carbon\Carbon;
use FilesystemIterator;
use Illuminate\Support\Str;
use Psr\Http\Message\RequestInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Slim\Views\Twig;
use Twig\Extension\SandboxExtension;
use Twig\Sandbox\SecurityPolicy;
use Twig\TwigFilter;
use Xibo\Entity\Display;
use Xibo\Entity\Module;
use Xibo\Entity\ModuleTemplate;
use Xibo\Entity\Region;
use Xibo\Entity\Widget;
use Xibo\Factory\ModuleFactory;
use Xibo\Helper\DateFormatHelper;
use Xibo\Helper\LinkSigner;
use Xibo\Helper\Translate;
use Xibo\Service\ConfigServiceInterface;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Support\Sanitizer\SanitizerInterface;

/**
 * Class responsible for rendering out a widgets HTML, caching it if necessary
 */
class WidgetHtmlRenderer
{
    /** @var string Cache Path */
    private $cachePath;

    /** @var LoggerInterface */
    private $logger;

    /** @var \Slim\Views\Twig */
    private $twig;

    /** @var \Xibo\Service\ConfigServiceInterface */
    private $config;

    /** @var ModuleFactory */
    private $moduleFactory;

    /**
     * @param string $cachePath
     * @param Twig $twig
     * @param ConfigServiceInterface $config
     * @param ModuleFactory $moduleFactory
     */
    public function __construct(
        string $cachePath,
        Twig $twig,
        ConfigServiceInterface $config,
        ModuleFactory $moduleFactory
    ) {
        $this->cachePath = $cachePath;
        $this->twig = $twig;
        $this->config = $config;
        $this->moduleFactory = $moduleFactory;
    }

    /**
     * @param \Psr\Log\LoggerInterface $logger
     * @return $this
     */
    public function useLogger(LoggerInterface $logger): WidgetHtmlRenderer
    {
        $this->logger = $logger;
        return $this;
    }

    private function getLog(): LoggerInterface
    {
        if ($this->logger === null) {
            $this->logger = new NullLogger();
        }

        return $this->logger;
    }

    /**
     * @param \Xibo\Entity\Module $module
     * @param \Xibo\Entity\Region $region
     * @param \Xibo\Entity\Widget $widget
     * @param \Xibo\Support\Sanitizer\SanitizerInterface $params
     * @param string $downloadUrl
     * @param array $additionalContexts An array of additional key/value contexts for the templates
     * @return string
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     */
    public function preview(
        Module $module,
        Region $region,
        Widget $widget,
        SanitizerInterface $params,
        string $downloadUrl,
        array $additionalContexts = []
    ): string {
        if ($module->previewEnabled == 1) {
            $twigSandbox = $this->getTwigSandbox();
            $width = $params->getDouble('width', ['default' => 0]);
            $height = $params->getDouble('height', ['default' => 0]);

            if ($module->preview !== null) {
                // Parse out our preview (which is always a stencil)
                $module->decorateProperties($widget, true);
                return $twigSandbox->fetchFromString(
                    $module->preview->twig,
                    array_merge(
                        [
                            'width' => $width,
                            'height' => $height,
                            'params' => $params,
                            'options' => $module->getPropertyValues(),
                            'downloadUrl' => $downloadUrl,
                            'calculatedDuration' => $widget->calculatedDuration,
                        ],
                        $module->getPropertyValues(),
                        $additionalContexts
                    )
                );
            } else if ($module->renderAs === 'html') {
                // Modules without a preview should render out as HTML
                return $this->twig->fetch(
                    'module-html-preview.twig',
                    array_merge(
                        [
                            'width' => $width,
                            'height' => $height,
                            'regionId' => $region->regionId,
                            'widgetId' => $widget->widgetId,
                            'calculatedDuration' => $widget->calculatedDuration,
                        ],
                        $module->getPropertyValues(),
                        $additionalContexts
                    )
                );
            }
        }

        // Render an icon.
        return $this->twig->fetch('module-icon-preview.twig', [
            'moduleName' => $module->name,
            'moduleType' => $module->type,
            'moduleIcon' => $module->icon,
        ]);
    }

    /**
     * Render or cache.
     * ----------------
     * @param ModuleTemplate[] $moduleTemplates
     * @param \Xibo\Entity\Widget[] $widgets
     * @throws \Twig\Error\SyntaxError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\LoaderError
     * @throws \Xibo\Support\Exception\NotFoundException
     */
    public function renderOrCache(
        Region $region,
        array $widgets,
        array $moduleTemplates
    ): string {
        // HTML is cached per widget for regions of type zone/frame and playlist.
        // HTML is cached per region for regions of type canvas.
        $widgetModifiedDt = 0;

        if ($region->type === 'canvas') {
            foreach ($widgets as $item) {
                $widgetModifiedDt = max($widgetModifiedDt, $item->modifiedDt);
                if ($item->type === 'global') {
                    $widget = $item;
                }
            }

            // If we don't have a global widget, just grab the first one.
            $widget = $widget ?? $widgets[0];
        } else {
            $widget = $widgets[0];
            $widgetModifiedDt = $widget->modifiedDt;
        }

        if (!file_exists($this->cachePath)) {
            mkdir($this->cachePath, 0777, true);
        }

        // Cache File
        // ----------
        // Widgets may or may not appear in the same Region each time they are previewed due to them potentially
        // being contained in a Playlist.
        // Region width/height only changes in Draft state, so the FE is responsible for asserting the correct
        // width/height relating scaling params when the preview first loads.
        $cachePath = $this->cachePath . DIRECTORY_SEPARATOR
            . $widget->widgetId
            . '_'
            . $region->regionId
            . '.html';

        // Changes to the Playlist should also invalidate Widget HTML caches
        try {
            $playlistModifiedDt = Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $region->getPlaylist([
                'loadPermissions' => false,
                'loadWidgets' => false,
                'loadTags' => false,
                'loadActions' => false,
            ])->modifiedDt);
        } catch (\Exception) {
            $this->getLog()->error('renderOrCache: cannot find playlist modifiedDt, using now');
            $playlistModifiedDt = Carbon::now();
        }

        // Have we changed since we last cached this widget
        $modifiedDt = max(Carbon::createFromTimestamp($widgetModifiedDt), $playlistModifiedDt);
        $cachedDt = Carbon::createFromTimestamp(file_exists($cachePath) ? filemtime($cachePath) : 0);

        $this->getLog()->debug('renderOrCache: Cache details - modifiedDt: '
            . $modifiedDt->format(DateFormatHelper::getSystemFormat())
            . ', cachedDt: ' . $cachedDt->format(DateFormatHelper::getSystemFormat())
            . ', cachePath: ' . $cachePath);

        if ($modifiedDt->greaterThan($cachedDt) || !file_get_contents($cachePath)) {
            $this->getLog()->debug('renderOrCache: We will need to regenerate');

            // Are we worried about concurrent requests here?
            // these aren't providing any data anymore, so in theory it shouldn't be possible to
            // get locked up here
            // We don't clear cached media here, as that comes along with data.
            if (file_exists($cachePath)) {
                $this->getLog()->debug('renderOrCache: Deleting cache file ' . $cachePath . ' which already existed');
                unlink($cachePath);
            }

            // Render
            $output = $this->render($widget->widgetId, $region, $widgets, $moduleTemplates);

            // Cache to the library
            file_put_contents($cachePath, $output);

            $this->getLog()->debug('renderOrCache: Generate complete');

            return $output;
        } else {
            $this->getLog()->debug('renderOrCache: Serving from cache');
            return file_get_contents($cachePath);
        }
    }

    /**
     * Decorate the HTML output for a preview
     * @param \Xibo\Entity\Region $region
     * @param string $output
     * @param callable $urlFor
     * @param \Psr\Http\Message\ServerRequestInterface $request
     * @return string
     */
    public function decorateForPreview(
        Region $region,
        string $output,
        callable $urlFor,
        RequestInterface $request
    ): string {
        $matches = [];
        preg_match_all('/\[\[(.*?)\]\]/', $output, $matches);
        foreach ($matches[1] as $match) {
            if ($match === 'PlayerBundle') {
                $output = str_replace('[[PlayerBundle]]', $urlFor('layout.preview.bundle', []), $output);
            } else if ($match === 'FontBundle') {
                $output = str_replace('[[FontBundle]]', $urlFor('library.font.css', []), $output);
            } else if ($match === 'ViewPortWidth') {
                $output = str_replace('[[ViewPortWidth]]', $region->width, $output);
            } else if (Str::startsWith($match, 'dataUrl')) {
                $value = explode('=', $match);
                $output = str_replace(
                    '[[' . $match . ']]',
                    $urlFor('module.getData', ['regionId' => $region->regionId, 'id' => $value[1]]),
                    $output
                );
            } else if (Str::startsWith($match, 'data=')) {
                // Not needed as this CMS is always capable of providing separate data.
                $output = str_replace('"[[' . $match . ']]"', '[]', $output);
            } else if (Str::startsWith($match, 'mediaId') || Str::startsWith($match, 'libraryId')) {
                $value = explode('=', $match);
                $params = ['id' => $value[1]];
                if (Str::startsWith($match, 'mediaId')) {
                    $params['type'] = 'image';
                }
                $output = str_replace(
                    '[[' . $match . ']]',
                    $urlFor('library.download', $params) . '?preview=1',
                    $output
                );
            } else if (Str::startsWith($match, 'assetId')) {
                $value = explode('=', $match);
                $output = str_replace(
                    '[[' . $match . ']]',
                    $urlFor('module.asset.download', ['assetId' => $value[1]]) . '?preview=1',
                    $output
                );
            } else if (Str::startsWith($match, 'assetAlias')) {
                $value = explode('=', $match);
                $output = str_replace(
                    '[[' . $match . ']]',
                    $urlFor('module.asset.download', ['assetId' => $value[1]]) . '?preview=1&isAlias=1',
                    $output
                );
            }
        }

        // Handle CSP in preview
        $html = new \DOMDocument();
        $html->loadHTML($output, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_SCHEMA_CREATE);
        foreach ($html->getElementsByTagName('script') as $node) {
            // We add this requests cspNonce to every script tag
            if ($node instanceof \DOMElement) {
                $node->setAttribute('nonce', $request->getAttribute('cspNonce'));
            }
        }

        return $html->saveHTML();
    }

    /**
     * Decorate the HTML output for a player
     * @param \Xibo\Entity\Display $display
     * @param string $output
     * @param array $storedAs A keyed array of library media this widget has access to
     * @param bool $isSupportsDataUrl
     * @param array $data A keyed array of data this widget has access to
     * @param \Xibo\Widget\Definition\Asset[] $assets A keyed array of assets this widget has access to
     * @return string
     * @throws \Xibo\Support\Exception\NotFoundException
     */
    public function decorateForPlayer(
        Display $display,
        string $output,
        array $storedAs,
        bool $isSupportsDataUrl = true,
        array $data = [],
        array $assets = []
    ): string {
        // Do we need to add a URL prefix to the requests?
        $auth = $display->isPwa()
            ? '&v=7&serverKey=' . $this->config->getSetting('SERVER_KEY') . '&hardwareKey=' . $display->license
            : null;
        $encryptionKey = $this->config->getApiKeyDetails()['encryptionKey'];
        $cdnUrl = $this->config->getSetting('CDN_URL');

        $matches = [];
        preg_match_all('/\[\[(.*?)\]\]/', $output, $matches);
        foreach ($matches[1] as $match) {
            if ($match === 'PlayerBundle') {
                if ($display->isPwa()) {
                    $url = LinkSigner::generateSignedLink(
                        $display,
                        $encryptionKey,
                        $cdnUrl,
                        'P',
                        1,
                        'bundle.min.js',
                        'bundle',
                        true,
                    );
                } else {
                    $url = 'bundle.min.js';
                }
                $output = str_replace(
                    '[[PlayerBundle]]',
                    $url,
                    $output,
                );
            } else if ($match === 'FontBundle') {
                if ($display->isPwa()) {
                    $url = LinkSigner::generateSignedLink(
                        $display,
                        $encryptionKey,
                        $cdnUrl,
                        'P',
                        1,
                        'fonts.css',
                        'fontCss',
                        true,
                    );
                } else {
                    $url = 'fonts.css';
                }
                $output = str_replace(
                    '[[FontBundle]]',
                    $url,
                    $output,
                );
            } else if ($match === 'ViewPortWidth') {
                if ($display->isPwa()) {
                    $output = str_replace(
                        '[[ViewPortWidth]]',
                        explode('x', ($display->resolution ?: 'x'))[0],
                        $output,
                    );
                }
            } else if (Str::startsWith($match, 'dataUrl')) {
                $value = explode('=', $match);
                $output = str_replace(
                    '[[' . $match . ']]',
                    $isSupportsDataUrl
                        ? ($display->isPwa()
                            ? '/pwa/getData?widgetId=' . $value[1] . $auth
                            : $value[1] . '.json')
                        : 'null',
                    $output,
                );
            } else if (Str::startsWith($match, 'data=')) {
                $value = explode('=', $match);
                $output = str_replace(
                    '"[[' . $match . ']]"',
                    isset($data[$value[1]])
                        ? json_encode($data[$value[1]])
                        : 'null',
                    $output,
                );
            } else if (Str::startsWith($match, 'mediaId') || Str::startsWith($match, 'libraryId')) {
                $value = explode('=', $match);
                if (array_key_exists($value[1], $storedAs)) {
                    if ($display->isPwa()) {
                        $url = LinkSigner::generateSignedLink(
                            $display,
                            $encryptionKey,
                            $cdnUrl,
                            'M',
                            $value[1],
                            $storedAs[$value[1]],
                            null,
                            true,
                        );
                    } else {
                        $url = $storedAs[$value[1]];
                    }
                    $output = str_replace(
                        '[[' . $match . ']]',
                        $url,
                        $output,
                    );
                } else {
                    $output = str_replace(
                        '[[' . $match . ']]',
                        '',
                        $output,
                    );
                }
            } else if (Str::startsWith($match, 'assetId')) {
                $value = explode('=', $match);
                if (array_key_exists($value[1], $assets)) {
                    $asset = $assets[$value[1]];
                    if ($display->isPwa()) {
                        $url = LinkSigner::generateSignedLink(
                            $display,
                            $encryptionKey,
                            $cdnUrl,
                            'P',
                            $asset->id,
                            $asset->getFilename(),
                            'asset',
                            true,
                        );
                    } else {
                        $url = $asset->getFilename();
                    }
                    $output = str_replace(
                        '[[' . $match . ']]',
                        $url,
                        $output,
                    );
                } else {
                    $output = str_replace(
                        '[[' . $match . ']]',
                        '',
                        $output,
                    );
                }
            } else if (Str::startsWith($match, 'assetAlias')) {
                $value = explode('=', $match);
                foreach ($assets as $asset) {
                    if ($asset->alias === $value[1]) {
                        if ($display->isPwa()) {
                            $url = LinkSigner::generateSignedLink(
                                $display,
                                $encryptionKey,
                                $cdnUrl,
                                'P',
                                $asset->id,
                                $asset->getFilename(),
                                'asset',
                                true,
                            );
                        } else {
                            $url = $asset->getFilename();
                        }
                        $output = str_replace(
                            '[[' . $match . ']]',
                            $url,
                            $output,
                        );
                        continue 2;
                    }
                }
                $output = str_replace('[[' . $match . ']]', '', $output);
            }
        }
        return $output;
    }

    /**
     * Render out the widgets HTML
     * @param \Xibo\Entity\Widget[] $widgets
     * @param ModuleTemplate[] $moduleTemplates
     * @throws \Twig\Error\SyntaxError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\LoaderError
     * @throws \Xibo\Support\Exception\NotFoundException
     */
    private function render(
        int $widgetId,
        Region $region,
        array $widgets,
        array $moduleTemplates
    ): string {
        // Build a Twig Sandbox
        $twigSandbox = $this->getTwigSandbox();

        // Build up some data for twig
        $twig = [];
        $twig['widgetId'] = $widgetId;
        $twig['hbs'] = [];
        $twig['twig'] = [];
        $twig['style'] = [];
        $twig['assets'] = [];
        $twig['onRender'] = [];
        $twig['onParseData'] = [];
        $twig['onDataLoad'] = [];
        $twig['onElementParseData'] = [];
        $twig['onTemplateRender'] = [];
        $twig['onTemplateVisible'] = [];
        $twig['onInitialize'] = [];
        $twig['templateProperties'] = [];
        $twig['elements'] = [];
        $twig['width'] = $region->width;
        $twig['height'] = $region->height;
        $twig['cmsDateFormat'] = $this->config->getSetting('DATE_FORMAT');
        $twig['locale'] = Translate::GetJSLocale();

        // Output some data for each widget.
        $twig['data'] = [];

        // Max duration
        $duration = 0;
        $numItems = 0;

        // Grab any global elements in our templates
        $globalElements = [];
        foreach ($moduleTemplates as $moduleTemplate) {
            if ($moduleTemplate->type === 'element' && $moduleTemplate->dataType === 'global') {
                // Add global elements to an array of extendable elements
                $globalElements[$moduleTemplate->templateId] = $moduleTemplate;
            }
        }

        $this->getLog()->debug('render: there are ' . count($globalElements) . ' global elements');

        // Extend any elements which need to be extended.
        foreach ($moduleTemplates as $moduleTemplate) {
            if ($moduleTemplate->type === 'element'
                && !empty($moduleTemplate->extends)
                && array_key_exists($moduleTemplate->extends->template, $globalElements)
            ) {
                $extends = $globalElements[$moduleTemplate->extends->template];

                $this->getLog()->debug('render: extending template ' . $moduleTemplate->templateId);

                // Merge properties
                $moduleTemplate->properties = array_merge($extends->properties, $moduleTemplate->properties);

                // Store on the object to use when we output the stencil
                $moduleTemplate->setUnmatchedProperty('extends', $extends);
            }
        }

        // Render each widget out into the html
        foreach ($widgets as $widget) {
            $this->getLog()->debug('render: widget to process is widgetId: ' . $widget->widgetId);
            $this->getLog()->debug('render: ' . count($widgets) . ' widgets, '
                . count($moduleTemplates) . ' templates');

            // Get the module.
            $module = $this->moduleFactory->getByType($widget->type);

            // Decorate our module with the saved widget properties
            // we include the defaults.
            $module->decorateProperties($widget, true);

            // templateId or "elements"
            $templateId = $widget->getOptionValue('templateId', null);

            // Validate this modules properties.
            try {
                $module->validateProperties('status');
                $widget->isValid = 1;
            } catch (InvalidArgumentException $invalidArgumentException) {
                $widget->isValid = 0;
            }

            // Parse out some common properties.
            $moduleLanguage = null;

            foreach ($module->properties as $property) {
                if ($property->type === 'languageSelector' && !empty($property->value)) {
                    $moduleLanguage = $property->value;
                    break;
                }
            }

            // Get an array of the modules property values.
            $modulePropertyValues = $module->getPropertyValues();

            // Configure a translator for the module
            // Note: We are using the language defined against the module and not from the module template
            $translator = null;
            if ($moduleLanguage !== null) {
                $translator = Translate::getTranslationsFromLocale($moduleLanguage);
            }

            // Output some sample data and a data url.
            $widgetData = [
                'widgetId' => $widget->widgetId,
                'templateId' => $templateId,
                'sample' => $module->sampleData,
                'properties' => $modulePropertyValues,
                'isValid' => $widget->isValid === 1,
                'isRepeatData' => $widget->getOptionValue('isRepeatData', 1) === 1,
                'duration' => $widget->useDuration ? $widget->duration : $module->defaultDuration,
                'calculatedDuration' => $widget->calculatedDuration,
                'isDataExpected' => $module->isDataProviderExpected(),
            ];

            // Should we expect data?
            if ($module->isDataProviderExpected()) {
                $widgetData['url'] = '[[dataUrl=' . $widget->widgetId . ']]';
                $widgetData['data'] = '[[data=' . $widget->widgetId . ']]';
            } else {
                $widgetData['url'] = null;
                $widgetData['data'] = null;
            }

            // Do we have a library file with this module?
            if ($module->regionSpecific == 0) {
                $widgetData['libraryId'] = '[[libraryId=' . $widget->getPrimaryMediaId() . ']]';
            }

            // Output event functions for this widget
            if (!empty($module->onInitialize)) {
                $twig['onInitialize'][$widget->widgetId] = $module->onInitialize;
            }
            if (!empty($module->onParseData)) {
                $twig['onParseData'][$widget->widgetId] = $module->onParseData;
            }
            if (!empty($module->onDataLoad)) {
                $twig['onDataLoad'][$widget->widgetId] = $module->onDataLoad;
            }
            if (!empty($module->onRender)) {
                $twig['onRender'][$widget->widgetId] = $module->onRender;
            }
            if (!empty($module->onVisible)) {
                $twig['onVisible'][$widget->widgetId] = $module->onVisible;
            }

            // Include any module assets.
            foreach ($module->assets as $asset) {
                if ($asset->isSendToPlayer()
                    && $asset->mimeType === 'text/css' || $asset->mimeType === 'text/javascript'
                ) {
                    $twig['assets'][] = $asset;
                }
            }

            // Find my template
            if ($templateId !== 'elements') {
                // Render out the `twig` from our specific static template
                foreach ($moduleTemplates as $moduleTemplate) {
                    if ($moduleTemplate->templateId === $templateId) {
                        $moduleTemplate->decorateProperties($widget, true);
                        $widgetData['templateProperties'] = $moduleTemplate->getPropertyValues();

                        $this->getLog()->debug('render: Static template to include: ' . $moduleTemplate->templateId);
                        if ($moduleTemplate->stencil !== null) {
                            if ($moduleTemplate->stencil->twig !== null) {
                                $twig['twig'][] = $twigSandbox->fetchFromString(
                                    $this->decorateTranslations($moduleTemplate->stencil->twig, $translator),
                                    $widgetData['templateProperties'],
                                );
                            }
                            if ($moduleTemplate->stencil->style !== null) {
                                $twig['style'][] = [
                                    'content' => $twigSandbox->fetchFromString(
                                        $moduleTemplate->stencil->style,
                                        $widgetData['templateProperties'],
                                    ),
                                    'type' => $moduleTemplate->type,
                                    'dataType' => $moduleTemplate->dataType,
                                    'templateId' => $moduleTemplate->templateId,
                                ];
                            }
                        }
                        break;
                    }
                }
            }

            // Add to widgetData
            $twig['data'][] = $widgetData;

            // Watermark duration
            $duration = max($duration, $widget->calculatedDuration);
            // TODO: this won't always be right? can we make it right
            $numItems = max($numItems, $widgetData['properties']['numItems'] ?? 0);

            // What does our module have
            if ($module->stencil !== null) {
                // Stencils have access to any module properties
                if ($module->stencil->twig !== null) {
                    $twig['twig'][] = $twigSandbox->fetchFromString(
                        $this->decorateTranslations($module->stencil->twig, null),
                        array_merge($modulePropertyValues, ['settings' => $module->getSettingsForOutput()]),
                    );
                }
                if ($module->stencil->hbs !== null) {
                    $twig['hbs']['module'] = [
                        'content' => $this->decorateTranslations($module->stencil->hbs, null),
                        'width' => $module->stencil->width,
                        'height' => $module->stencil->height,
                        'gapBetweenHbs' => $module->stencil->gapBetweenHbs,
                    ];
                }
                if ($module->stencil->head !== null) {
                    $twig['head'][] = $twigSandbox->fetchFromString(
                        $this->decorateTranslations($module->stencil->head, null),
                        $modulePropertyValues,
                    );
                }
                if ($module->stencil->style !== null) {
                    $twig['style'][] = [
                        'content' => $twigSandbox->fetchFromString(
                            $module->stencil->style,
                            $modulePropertyValues,
                        ),
                        'type' => $module->type,
                        'dataType' => $module->dataType,
                    ];
                }
            }

            // Include elements/element groups - they will already be JSON encoded.
            $widgetElements = $widget->getOptionValue('elements', null);
            if (!empty($widgetElements)) {
                $this->getLog()->debug('render: there are elements to include');

                // Elements will be JSON
                $widgetElements = json_decode($widgetElements, true);

                // Are any of the module properties marked for sending to elements?
                $modulePropertiesToSend = [];
                if (count($widgetElements) > 0) {
                    foreach ($module->properties as $property) {
                        if ($property->sendToElements) {
                            $modulePropertiesToSend[$property->id] = $modulePropertyValues[$property->id] ?? null;
                        }
                    }
                }

                // Join together the template properties for this element, and the element properties
                foreach ($widgetElements as $widgetIndex => $widgetElement) {
                    // Assert the widgetId
                    $widgetElements[$widgetIndex]['widgetId'] = $widget->widgetId;

                    foreach (($widgetElement['elements'] ?? []) as $elementIndex => $element) {
                        $this->getLog()->debug('render: elements: processing widget index ' . $widgetIndex
                            . ', element index ' . $elementIndex . ' with id ' . $element['id']);

                        foreach ($moduleTemplates as $moduleTemplate) {
                            if ($moduleTemplate->templateId === $element['id']) {
                                $this->getLog()->debug('render: elements: found template for element '
                                    . $element['id']);

                                // Merge the properties on the element with the properties on the template.
                                $widgetElements[$widgetIndex]['elements'][$elementIndex]['properties'] =
                                    $moduleTemplate->getPropertyValues(
                                        true,
                                        $moduleTemplate->decoratePropertiesByArray(
                                            $element['properties'] ?? [],
                                            true
                                        )
                                    );

                                // Update any properties which match on the element
                                foreach ($modulePropertiesToSend as $propertyToSend => $valueToSend) {
                                    $widgetElements[$widgetIndex]['elements']
                                        [$elementIndex]['properties'][$propertyToSend] = $valueToSend;
                                }
                            }
                        }

                        // Check the element for a mediaId property and set it to
                        // [[mediaId=the_id_from_the_mediaId_property]]
                        if (!empty($element['mediaId'])) {
                            // Update the element so we output the mediaId replacement
                            $widgetElements[$widgetIndex]['elements'][$elementIndex]['properties']['mediaId']
                                = '[[mediaId=' . $element['mediaId'] . ']]';
                        }
                    }
                }

                $twig['elements'][] = json_encode($widgetElements);
            }
        }

        // Render out HBS/style from templates
        // we do not render Twig here
        foreach ($moduleTemplates as $moduleTemplate) {
            $this->getLog()->debug('render: outputting module template ' . $moduleTemplate->templateId);

            // Handle extends.
            $extension = $moduleTemplate->getUnmatchedProperty('extends');
            $isExtensionHasHead = false;
            $isExtensionHasStyle = false;

            // Render out any hbs
            if ($moduleTemplate->stencil !== null && $moduleTemplate->stencil->hbs !== null) {
                // If we have an extension then look for %parent% and insert it.
                if ($extension !== null && Str::contains('%parent%', $moduleTemplate->stencil->hbs)) {
                    $moduleTemplate->stencil->hbs = str_replace(
                        '%parent%',
                        $extension->stencil->hbs,
                        $moduleTemplate->stencil->hbs
                    );
                }

                // Output the hbs
                $twig['hbs'][$moduleTemplate->templateId] = [
                    'content' => $this->decorateTranslations($moduleTemplate->stencil->hbs, null),
                    'width' => $moduleTemplate->stencil->width,
                    'height' => $moduleTemplate->stencil->height,
                    'gapBetweenHbs' => $moduleTemplate->stencil->gapBetweenHbs,
                    'extends' => [
                        'override' => $moduleTemplate->extends?->override,
                        'with' => $moduleTemplate->extends?->with,
                        'escapeHtml' => $moduleTemplate->extends?->escapeHtml ?? true,
                    ],
                ];
            } else if ($extension !== null) {
                // Output the extension HBS instead
                $twig['hbs'][$moduleTemplate->templateId] = [
                    'content' => $this->decorateTranslations($extension->stencil->hbs, null),
                    'width' => $extension->stencil->width,
                    'height' => $extension->stencil->height,
                    'gapBetweenHbs' => $extension->stencil->gapBetweenHbs,
                    'extends' => [
                        'override' => $moduleTemplate->extends?->override,
                        'with' => $moduleTemplate->extends?->with,
                        'escapeHtml' => $moduleTemplate->extends?->escapeHtml ?? true,
                    ],
                ];

                if ($extension->stencil->head !== null) {
                    $twig['head'][] = $extension->stencil->head;
                    $isExtensionHasHead = true;
                }

                if ($extension->stencil->style !== null) {
                    $twig['style'][] = [
                        'content' => $extension->stencil->style,
                        'type' => $moduleTemplate->type,
                        'dataType' => $moduleTemplate->dataType,
                        'templateId' => $moduleTemplate->templateId,
                    ];
                    $isExtensionHasStyle = true;
                }
            }

            // Render the module template's head, if present and not already output by the extension
            if ($moduleTemplate->stencil !== null
                && $moduleTemplate->stencil->head !== null
                && !$isExtensionHasHead
            ) {
                $twig['head'][] = $moduleTemplate->stencil->head;
            }

            // Render the module template's style, if present and not already output by the extension
            if ($moduleTemplate->stencil !== null
                && $moduleTemplate->stencil->style !== null
                && !$isExtensionHasStyle
                && $moduleTemplate->type === 'element'
            ) {
                // Add more info to the element style
                // so we can use it to create CSS scope
                $twig['style'][] = [
                    'content' => $moduleTemplate->stencil->style,
                    'type' => $moduleTemplate->type,
                    'dataType' => $moduleTemplate->dataType,
                    'templateId' => $moduleTemplate->templateId,
                ];
            }

            if ($moduleTemplate->onTemplateRender !== null) {
                $twig['onTemplateRender'][$moduleTemplate->templateId] = $moduleTemplate->onTemplateRender;
            }

            if ($moduleTemplate->onTemplateVisible !== null) {
                $twig['onTemplateVisible'][$moduleTemplate->templateId] = $moduleTemplate->onTemplateVisible;
            }

            if ($moduleTemplate->onElementParseData !== null) {
                $twig['onElementParseData'][$moduleTemplate->templateId] = $moduleTemplate->onElementParseData;
            }

            // Include any module template assets.
            foreach ($moduleTemplate->assets as $asset) {
                if ($asset->isSendToPlayer()
                    && $asset->mimeType === 'text/css' || $asset->mimeType === 'text/javascript'
                ) {
                    $twig['assets'][] = $asset;
                }
            }
        }

        // Duration
        $twig['duration'] = $duration;
        $twig['numItems'] = $numItems;

        // We use the default get resource template.
        return $this->twig->fetch('widget-html-render.twig', $twig);
    }

    /**
     * Decorate translations in template files.
     * @param string $content
     * @param \GetText\Translator $translator
     * @return string
     */
    private function decorateTranslations(string $content, ?\Gettext\Translator $translator): string
    {
        $matches = [];
        preg_match_all('/\|\|.*?\|\|/', $content, $matches);
        foreach ($matches[0] as $sub) {
            // Parse out the translateTag
            $translateTag = str_replace('||', '', $sub);

            // We have a valid translateTag to substitute
            if ($translator !== null) {
                $replace = $translator->gettext($translateTag);
            } else {
                $replace = __($translateTag);
            }

            // Substitute the replacement we have found (it might be '')
            $content = str_replace($sub, $replace, $content);
        }

        return $content;
    }

    /**
     * @param \Xibo\Entity\Widget $widget
     * @return void
     */
    public function clearWidgetCache(Widget $widget)
    {
        $cachePath = $this->cachePath
            . DIRECTORY_SEPARATOR
            . $widget->widgetId
            . DIRECTORY_SEPARATOR;

        // Drop the cache
        // there is a chance this may not yet exist
        try {
            $it = new \RecursiveDirectoryIterator($cachePath, FilesystemIterator::SKIP_DOTS);
            $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
            foreach ($files as $file) {
                if ($file->isDir()) {
                    rmdir($file->getRealPath());
                } else {
                    unlink($file->getRealPath());
                }
            }
            rmdir($cachePath);
        } catch (\UnexpectedValueException $unexpectedValueException) {
            $this->logger->debug('HTML cache doesn\'t exist yet or cannot be deleted. '
                . $unexpectedValueException->getMessage());
        }
    }

    /**
     * Get a Twig Sandbox
     * @return \Slim\Views\Twig
     * @throws \Twig\Error\LoaderError
     */
    private function getTwigSandbox(): Twig
    {
        // Create a Twig Environment with a Sandbox
        $sandbox = Twig::create([
            PROJECT_ROOT . '/modules',
            PROJECT_ROOT . '/custom',
        ], [
            'cache' => false,
        ]);

        // Add missing filter
        $sandbox->getEnvironment()->addFilter(new TwigFilter('url_decode', 'urldecode'));

        // Configure a security policy
        // Create a new security policy
        $policy = new SecurityPolicy();

        // Allowed tags
        // import is allowed for weather static templates which import a macro
        $policy->setAllowedTags(['if', 'for', 'set', 'macro', 'import']);

        // Allowed filters
        $policy->setAllowedFilters(['escape', 'raw', 'url_decode']);

        // Create a Sandbox
        $sandbox->addExtension(new SandboxExtension($policy, true));

        return $sandbox;
    }
}
