<?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\Helper;

use Joomla\CMS\Cache\CacheControllerFactoryInterface;
use Joomla\CMS\Cache\Controller\CallbackController;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Event\Module;
use Joomla\CMS\Factory;
use Joomla\CMS\Filter\InputFilter;
use Joomla\CMS\Language\LanguageHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Profiler\Profiler;
use Joomla\Database\ParameterType;
use Joomla\Filesystem\Path;
use Joomla\Registry\Registry;

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

/**
 * Module helper class
 *
 * @since  1.5
 */
abstract class ModuleHelper
{
    /**
     * Get module by name (real, eg 'Breadcrumbs' or folder, eg 'mod_breadcrumbs')
     *
     * @param   string  $name   The name of the module
     * @param   string  $title  The title of the module, optional
     *
     * @return  \stdClass|null  The Module object
     *
     * @since   1.5
     */
    public static function &getModule($name, $title = null)
    {
        $result  = null;
        $modules =& static::load();

        foreach ($modules as $module) {
            // Match the name of the module
            if ($module->name === $name || $module->module === $name) {
                // Match the title if we're looking for a specific instance of the module
                if (!$title || $module->title === $title) {
                    // Found it
                    $result = $module;
                    break;
                }
            }
        }

        // If we didn't find it, and the name is mod_something, create a dummy object
        if ($result === null && strpos($name, 'mod_') === 0) {
            $result         = static::createDummyModule();
            $result->module = $name;
        }

        return $result;
    }

    /**
     * Get modules by position
     *
     * @param   string  $position  The position of the module
     *
     * @return  array  An array of module objects
     *
     * @since   1.5
     */
    public static function &getModules($position)
    {
        $position = strtolower($position);
        $result   = [];
        $input    = Factory::getApplication()->getInput();
        $modules  = &static::load();

        foreach ($modules as $module) {
            if ($module->position === $position) {
                $result[] = $module;
            }
        }

        // Prepend a dummy module for template preview if no module is published in the position
        if (empty($result) && $input->getBool('tp') && ComponentHelper::getParams('com_templates')->get('template_positions_display')) {
            $dummy                  = static::createDummyModule();
            $dummy->title           = $position;
            $dummy->position        = $position;
            $dummy->content         = $position;
            $dummy->contentRendered = true;

            array_unshift($result, $dummy);
        }

        return $result;
    }

    /**
     * Checks if a module is enabled. A given module will only be returned
     * if it meets the following criteria: it is enabled, it is assigned to
     * the current menu item or all items, and the user meets the access level
     * requirements.
     *
     * @param   string  $module  The module name
     *
     * @return  boolean See description for conditions.
     *
     * @since   1.5
     */
    public static function isEnabled($module)
    {
        $result = static::getModule($module);

        return $result !== null && $result->id !== 0;
    }

    /**
     * Render the module.
     *
     * @param   object  $module   A module object.
     * @param   array   $attribs  An array of attributes for the module (probably from the XML).
     *
     * @return  string  The HTML content of the module output.
     *
     * @since   1.5
     */
    public static function renderModule($module, $attribs = [])
    {
        $app = Factory::getApplication();

        // Check that $module is a valid module object
        if (!\is_object($module) || !isset($module->module) || !isset($module->params)) {
            if (JDEBUG) {
                Log::addLogger(['text_file' => 'jmodulehelper.log.php'], Log::ALL, ['modulehelper']);
                $app->getLogger()->debug(
                    __METHOD__ . '() - The $module parameter should be a module object.',
                    ['category' => 'modulehelper']
                );
            }

            return '';
        }

        // Get module parameters
        $params = new Registry($module->params);

        // Render the module content
        static::renderRawModule($module, $params, $attribs);

        // Return early if only the content is required
        if (!empty($attribs['contentOnly'])) {
            return $module->content;
        }

        if (JDEBUG) {
            Profiler::getInstance('Application')->mark('beforeRenderModule ' . $module->module . ' (' . $module->title . ')');
        }

        // Record the scope.
        $scope = $app->scope;

        // Set scope to component name
        $app->scope = $module->module;
        $dispatcher = $app->getDispatcher();

        // Get the template
        $template = $app->getTemplate();

        // Check if the current module has a style param to override template module style
        $paramsChromeStyle = $params->get('style');
        $basePath          = '';

        if ($paramsChromeStyle) {
            $paramsChromeStyle   = explode('-', $paramsChromeStyle, 2);
            $ChromeStyleTemplate = strtolower($paramsChromeStyle[0]);
            $attribs['style']    = $paramsChromeStyle[1];

            // Only set $basePath if the specified template isn't the current or system one.
            if ($ChromeStyleTemplate !== $template && $ChromeStyleTemplate !== 'system') {
                $basePath = JPATH_THEMES . '/' . $ChromeStyleTemplate . '/html/layouts';
            }
        }

        // Make sure a style is set
        if (!isset($attribs['style'])) {
            $attribs['style'] = 'none';
        }

        // Dynamically add outline style
        if ($app->getInput()->getBool('tp') && ComponentHelper::getParams('com_templates')->get('template_positions_display')) {
            $attribs['style'] .= ' outline';
        }

        $module->style = $attribs['style'];

        // If the $module is nulled it will return an empty content, otherwise it will render the module normally.
        $brEvent = $dispatcher->dispatch('onRenderModule', new Module\BeforeRenderModuleEvent('onRenderModule', [
            'subject'    => $module,
            'attributes' => &$attribs, // @todo: Remove reference in Joomla 6, see BeforeRenderModuleEvent::__constructor()
        ]));
        // Get final attributes
        $attribs = $brEvent->getArgument('attributes', $attribs);

        if (!isset($module->content)) {
            return '';
        }

        // Prevent double modification of the module content by chrome style
        $module = clone $module;

        $displayData = [
            'module'  => $module,
            'params'  => $params,
            'attribs' => $attribs,
        ];

        foreach (explode(' ', $attribs['style']) as $style) {
            $moduleContent = LayoutHelper::render('chromes.' . $style, $displayData, $basePath);

            if ($moduleContent) {
                $module->content = $moduleContent;
            }
        }

        // Revert the scope
        $app->scope = $scope;

        $dispatcher->dispatch('onAfterRenderModule', new Module\AfterRenderModuleEvent('onAfterRenderModule', [
            'subject'    => $module,
            'attributes' => $attribs,
        ]));

        if (JDEBUG) {
            Profiler::getInstance('Application')->mark('afterRenderModule ' . $module->module . ' (' . $module->title . ')');
        }

        return $module->content;
    }

    /**
     * Render the module content.
     *
     * @param   object    $module   A module object
     * @param   Registry  $params   A module parameters
     * @param   array     $attribs  An array of attributes for the module (probably from the XML).
     *
     * @return  string
     *
     * @since   4.0.0
     */
    public static function renderRawModule($module, Registry $params, $attribs = [])
    {
        if (!empty($module->contentRendered)) {
            return $module->content;
        }

        if (JDEBUG) {
            Profiler::getInstance('Application')->mark('beforeRenderRawModule ' . $module->module . ' (' . $module->title . ')');
        }

        $app = Factory::getApplication();

        // Record the scope.
        $scope = $app->scope;

        // Set scope to component name
        $app->scope = $module->module;

        // Get module path
        $module->module = preg_replace('/[^A-Z0-9_\.-]/i', '', $module->module);

        $dispatcher = $app->bootModule($module->module, $app->getName())->getDispatcher($module, $app);

        // Check if we have a dispatcher
        if ($dispatcher) {
            ob_start();
            $dispatcher->dispatch();
            $module->content = ob_get_clean();
        }

        // Add the flag that the module content has been rendered
        $module->contentRendered = true;

        // Revert the scope
        $app->scope = $scope;

        if (JDEBUG) {
            Profiler::getInstance('Application')->mark('afterRenderRawModule ' . $module->module . ' (' . $module->title . ')');
        }

        return $module->content;
    }

    /**
     * Get the path to a layout for a module
     *
     * @param   string  $module  The name of the module
     * @param   string  $layout  The name of the module layout. If alternative layout, in the form template:filename.
     *
     * @return  string  The path to the module layout
     *
     * @since   1.5
     */
    public static function getLayoutPath($module, $layout = 'default')
    {
        $templateObj   = Factory::getApplication()->getTemplate(true);
        $defaultLayout = $layout;
        $template      = $templateObj->template;

        if (strpos($layout, ':') !== false) {
            // Get the template and file name from the string
            $temp          = explode(':', $layout);
            $template      = $temp[0] === '_' ? $template : $temp[0];
            $layout        = $temp[1];
            $defaultLayout = $temp[1] ?: 'default';
        }

        $dPath = JPATH_BASE . '/modules/' . $module . '/tmpl/default.php';

        try {
            // Build the template and base path for the layout
            $tPath = Path::check(JPATH_THEMES . '/' . $template . '/html/' . $module . '/' . $layout . '.php');
            $iPath = Path::check(JPATH_THEMES . '/' . $templateObj->parent . '/html/' . $module . '/' . $layout . '.php');
            $bPath = Path::check(JPATH_BASE . '/modules/' . $module . '/tmpl/' . $defaultLayout . '.php');
        } catch (\Exception $e) {
            // On error fallback to the default path
            return $dPath;
        }

        // If the template has a layout override use it
        if (is_file($tPath)) {
            return $tPath;
        }

        if (!empty($templateObj->parent) && is_file($iPath)) {
            return $iPath;
        }

        if (is_file($bPath)) {
            return $bPath;
        }

        return $dPath;
    }

    /**
     * Load published modules.
     *
     * @return  array
     *
     * @since   3.2
     */
    protected static function &load()
    {
        static $modules;

        if (isset($modules)) {
            return $modules;
        }

        $dispatcher = Factory::getApplication()->getDispatcher();
        $modules    = [];

        $modules = $dispatcher->dispatch('onPrepareModuleList', new Module\PrepareModuleListEvent('onPrepareModuleList', [
            'modules' => &$modules, // @todo: Remove reference in Joomla 6, see PrepareModuleListEvent::__constructor()
        ]))->getArgument('modules', $modules);

        // If the onPrepareModuleList event returns an array of modules, then ignore the default module list creation
        if (!$modules) {
            $modules = static::getModuleList();
        }

        $modules = $dispatcher->dispatch('onAfterModuleList', new Module\AfterModuleListEvent('onAfterModuleList', [
            'modules' => &$modules, // @todo: Remove reference in Joomla 6, see AfterModuleListEvent::__constructor()
        ]))->getArgument('modules', $modules);

        $modules = static::cleanModuleList($modules);

        $modules = $dispatcher->dispatch('onAfterCleanModuleList', new Module\AfterCleanModuleListEvent('onAfterCleanModuleList', [
            'modules' => &$modules, // @todo: Remove reference in Joomla 6, see AfterCleanModuleListEvent::__constructor()
        ]))->getArgument('modules', $modules);

        return $modules;
    }

    /**
     * Module list
     *
     * @return  array
     */
    public static function getModuleList()
    {
        $app      = Factory::getApplication();
        $itemId   = $app->getInput()->getInt('Itemid', 0);
        $groups   = $app->getIdentity()->getAuthorisedViewLevels();
        $clientId = (int) $app->getClientId();

        // Build a cache ID for the resulting data object
        $cacheId = implode(',', $groups) . '.' . $clientId . '.' . $itemId;

        $db      = Factory::getDbo();
        $query   = $db->getQuery(true);
        $nowDate = Factory::getDate()->toSql();

        $query->select($db->quoteName(['m.id', 'm.title', 'm.module', 'm.position', 'm.content', 'm.showtitle', 'm.params', 'mm.menuid']))
            ->from($db->quoteName('#__modules', 'm'))
            ->join(
                'LEFT',
                $db->quoteName('#__modules_menu', 'mm'),
                $db->quoteName('mm.moduleid') . ' = ' . $db->quoteName('m.id')
            )
            ->join(
                'LEFT',
                $db->quoteName('#__extensions', 'e'),
                $db->quoteName('e.element') . ' = ' . $db->quoteName('m.module')
                . ' AND ' . $db->quoteName('e.client_id') . ' = ' . $db->quoteName('m.client_id')
            )
            ->where(
                [
                    $db->quoteName('m.published') . ' = 1',
                    $db->quoteName('e.enabled') . ' = 1',
                    $db->quoteName('m.client_id') . ' = :clientId',
                ]
            )
            ->bind(':clientId', $clientId, ParameterType::INTEGER)
            ->whereIn($db->quoteName('m.access'), $groups)
            ->extendWhere(
                'AND',
                [
                    $db->quoteName('m.publish_up') . ' IS NULL',
                    $db->quoteName('m.publish_up') . ' <= :publishUp',
                ],
                'OR'
            )
            ->bind(':publishUp', $nowDate)
            ->extendWhere(
                'AND',
                [
                    $db->quoteName('m.publish_down') . ' IS NULL',
                    $db->quoteName('m.publish_down') . ' >= :publishDown',
                ],
                'OR'
            )
            ->bind(':publishDown', $nowDate)
            ->extendWhere(
                'AND',
                [
                    $db->quoteName('mm.menuid') . ' = :itemId',
                    $db->quoteName('mm.menuid') . ' <= 0',
                ],
                'OR'
            )
            ->bind(':itemId', $itemId, ParameterType::INTEGER);

        // Filter by language
        if ($app->isClient('site') && $app->getLanguageFilter() || $app->isClient('administrator') && static::isAdminMultilang()) {
            $language = $app->getLanguage()->getTag();

            $query->whereIn($db->quoteName('m.language'), [$language, '*'], ParameterType::STRING);
            $cacheId .= $language . '*';
        }

        $query->order($db->quoteName(['m.position', 'm.ordering']));

        // Set the query
        $db->setQuery($query);

        try {
            /** @var CallbackController $cache */
            $cache = Factory::getContainer()->get(CacheControllerFactoryInterface::class)
                ->createCacheController('callback', ['defaultgroup' => 'com_modules']);

            $modules = $cache->get([$db, 'loadObjectList'], [], md5($cacheId), false);
        } catch (\RuntimeException $e) {
            $app->getLogger()->warning(
                Text::sprintf('JLIB_APPLICATION_ERROR_MODULE_LOAD', $e->getMessage()),
                ['category' => 'jerror']
            );

            return [];
        }

        return $modules;
    }

    /**
     * Clean the module list
     *
     * @param   array  $modules  Array with module objects
     *
     * @return  array
     */
    public static function cleanModuleList($modules)
    {
        // Apply negative selections and eliminate duplicates
        $Itemid = Factory::getApplication()->getInput()->getInt('Itemid');
        $negId  = $Itemid ? -(int) $Itemid : false;
        $clean  = [];
        $dupes  = [];

        foreach ($modules as $i => $module) {
            // The module is excluded if there is an explicit prohibition
            $negHit = ($negId === (int) $module->menuid);

            if (isset($dupes[$module->id])) {
                // If this item has been excluded, keep the duplicate flag set,
                // but remove any item from the modules array.
                if ($negHit) {
                    unset($clean[$module->id]);
                }

                continue;
            }

            $dupes[$module->id] = true;

            // Only accept modules without explicit exclusions.
            if ($negHit) {
                continue;
            }

            $module->name     = substr($module->module, 4);
            $module->style    = null;
            $module->position = strtolower($module->position);

            $clean[$module->id] = $module;
        }

        unset($dupes);

        // Return to simple indexing that matches the query order.
        return array_values($clean);
    }

    /**
     * Module cache helper
     *
     * Caching modes:
     * To be set in XML:
     * 'static'      One cache file for all pages with the same module parameters
     * 'itemid'      Changes on itemid change, to be called from inside the module:
     * 'safeuri'     Id created from $cacheparams->modeparams array,
     * 'id'          Module sets own cache id's
     *
     * @param   object  $module        Module object
     * @param   object  $moduleparams  Module parameters
     * @param   object  $cacheparams   Module cache parameters - id or URL parameters, depending on the module cache mode
     *
     * @return  string
     *
     * @see     InputFilter::clean()
     * @since   1.6
     */
    public static function moduleCache($module, $moduleparams, $cacheparams)
    {
        if (!isset($cacheparams->modeparams)) {
            $cacheparams->modeparams = null;
        }

        if (!isset($cacheparams->cachegroup)) {
            $cacheparams->cachegroup = $module->module;
        }

        if (!isset($cacheparams->cachesuffix)) {
            $cacheparams->cachesuffix = '';
        }

        $user = Factory::getUser();
        $app  = Factory::getApplication();

        /** @var CallbackController $cache */
        $cache = Factory::getContainer()->get(CacheControllerFactoryInterface::class)
            ->createCacheController('callback', ['defaultgroup' => $cacheparams->cachegroup]);

        // Turn cache off for internal callers if parameters are set to off and for all logged in users
        $ownCacheDisabled = $moduleparams->get('owncache') === 0 || $moduleparams->get('owncache') === '0';
        $cacheDisabled    = $moduleparams->get('cache') === 0 || $moduleparams->get('cache') === '0';

        if ($ownCacheDisabled || $cacheDisabled || $app->get('caching') == 0 || $user->id) {
            $cache->setCaching(false);
        }

        // Module cache is set in seconds, global cache in minutes, setLifeTime works in minutes
        $cache->setLifeTime($moduleparams->get('cache_time', $app->get('cachetime') * 60) / 60);

        $wrkaroundoptions = ['nopathway' => 1, 'nohead' => 0, 'nomodules' => 1, 'modulemode' => 1, 'mergehead' => 1];

        $wrkarounds  = true;
        $view_levels = md5(serialize($user->getAuthorisedViewLevels()));

        switch ($cacheparams->cachemode) {
            case 'id':
                $ret = $cache->get(
                    [$cacheparams->class, $cacheparams->method],
                    $cacheparams->methodparams,
                    $cacheparams->modeparams . $cacheparams->cachesuffix,
                    $wrkarounds,
                    $wrkaroundoptions
                );
                break;

            case 'safeuri':
                $safeuri = new \stdClass();

                if (\is_array($cacheparams->modeparams)) {
                    $input        = $app->getInput();
                    $uri          = $input->getArray();
                    $noHtmlFilter = InputFilter::getInstance();

                    foreach ($cacheparams->modeparams as $key => $value) {
                        // Use int filter for id/catid to clean out spamy slugs
                        if (isset($uri[$key])) {
                            $safeuri->$key = $noHtmlFilter->clean($uri[$key], $value);
                        }
                    }
                }

                $secureid = md5(serialize([$safeuri, $cacheparams->method, $moduleparams]));
                $ret      = $cache->get(
                    [$cacheparams->class, $cacheparams->method],
                    $cacheparams->methodparams,
                    $module->id . $view_levels . $secureid . $cacheparams->cachesuffix,
                    $wrkarounds,
                    $wrkaroundoptions
                );
                break;

            case 'static':
                $ret = $cache->get(
                    [$cacheparams->class, $cacheparams->method],
                    $cacheparams->methodparams,
                    $module->module . md5(serialize($cacheparams->methodparams)) . $cacheparams->cachesuffix,
                    $wrkarounds,
                    $wrkaroundoptions
                );
                break;

            case 'itemid':
            default:
                $ret = $cache->get(
                    [$cacheparams->class, $cacheparams->method],
                    $cacheparams->methodparams,
                    $module->id . $view_levels . $app->getInput()->getInt('Itemid', null) . $cacheparams->cachesuffix,
                    $wrkarounds,
                    $wrkaroundoptions
                );
                break;
        }

        return $ret;
    }

    /**
     * Method to determine if filtering by language is enabled in back-end for modules.
     *
     * @return  boolean  True if enabled; false otherwise.
     *
     * @since   3.8.0
     */
    public static function isAdminMultilang()
    {
        static $enabled = false;

        if (\count(LanguageHelper::getInstalledLanguages(1)) > 1) {
            $enabled = (bool) ComponentHelper::getParams('com_modules')->get('adminlangfilter', 0);
        }

        return $enabled;
    }

    /**
     * Get module by id
     *
     * @param   string  $id  The id of the module
     *
     * @return  \stdClass  The Module object
     *
     * @since   3.9.0
     */
    public static function &getModuleById($id)
    {
        $modules =& static::load();

        foreach ($modules as $module) {
            // Match the id of the module
            if ((string) $module->id === $id) {
                // Found it
                return $module;
            }
        }

        // If we didn't find it, create a dummy object
        $result = static::createDummyModule();

        return $result;
    }

    /**
     * Method to create a dummy module.
     *
     * @return  \stdClass  The Module object
     *
     * @since   4.0.0
     */
    protected static function createDummyModule(): \stdClass
    {
        $module            = new \stdClass();
        $module->id        = 0;
        $module->title     = '';
        $module->module    = '';
        $module->position  = '';
        $module->content   = '';
        $module->showtitle = 0;
        $module->control   = '';
        $module->params    = '';

        return $module;
    }
}
