<?php

/**
 * @package     Joomla.Administrator
 * @subpackage  com_config
 *
 * @copyright   (C) 2013 Open Source Matters, Inc. <https://www.joomla.org>
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\Component\Config\Administrator\Model;

use Joomla\CMS\Access\Access;
use Joomla\CMS\Access\Rules;
use Joomla\CMS\Cache\Exception\CacheConnectingException;
use Joomla\CMS\Cache\Exception\UnsupportedCacheException;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Event\Application\AfterSaveConfigurationEvent;
use Joomla\CMS\Event\Application\BeforeSaveConfigurationEvent;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\LanguageFactoryInterface;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Mail\Exception\MailDisabledException;
use Joomla\CMS\Mail\MailerFactoryAwareInterface;
use Joomla\CMS\Mail\MailerFactoryAwareTrait;
use Joomla\CMS\Mail\MailTemplate;
use Joomla\CMS\MVC\Model\FormModel;
use Joomla\CMS\Table\Asset;
use Joomla\CMS\Table\Extension;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\UserHelper;
use Joomla\Database\DatabaseFactory;
use Joomla\Database\ParameterType;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Folder;
use Joomla\Filesystem\Path;
use Joomla\Filter\OutputFilter;
use Joomla\Http\HttpFactory;
use Joomla\Registry\Registry;
use Joomla\Utilities\ArrayHelper;
use PHPMailer\PHPMailer\Exception as phpMailerException;

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

/**
 * Model for the global configuration
 *
 * @since  3.2
 */
class ApplicationModel extends FormModel implements MailerFactoryAwareInterface
{
    use MailerFactoryAwareTrait;

    /**
     * Array of protected password fields from the configuration.php
     *
     * @var    array
     * @since  3.9.23
     */
    private $protectedConfigurationFields = ['password', 'secret', 'smtppass', 'oauth2_client_secret', 'oauth2_refresh_token', 'redis_server_auth', 'session_redis_server_auth'];

    /**
     * Method to get a form object.
     *
     * @param   array    $data      Data for the form.
     * @param   boolean  $loadData  True if the form is to load its own data (default case), false if not.
     *
     * @return  mixed  A Form object
     *
     * @since   1.6
     * @throws  \Exception on failure
     */
    public function getForm($data = [], $loadData = true)
    {
        return $this->loadForm('com_config.application', 'application', ['control' => 'jform', 'load_data' => $loadData]);
    }

    /**
     * Method to get the configuration data.
     *
     * This method will load the global configuration data straight from
     * JConfig. If configuration data has been saved in the session, that
     * data will be merged into the original data, overwriting it.
     *
     * @return  array  An array containing all global config data.
     *
     * @since   1.6
     */
    public function getData()
    {
        // Get the config data.
        $config = new \JConfig();
        $data   = ArrayHelper::fromObject($config);

        // Get the correct driver at runtime
        $data['dbtype'] = $this->getDatabase()->getName();

        // Prime the asset_id for the rules.
        $data['asset_id'] = 1;

        // Get the text filter data
        $params          = ComponentHelper::getParams('com_config');
        $data['filters'] = ArrayHelper::fromObject($params->get('filters'));

        // If no filter data found, get from com_content (update of 1.6/1.7 site)
        if (empty($data['filters'])) {
            $contentParams   = ComponentHelper::getParams('com_content');
            $data['filters'] = ArrayHelper::fromObject($contentParams->get('filters'));
        }

        // Check for data in the session.
        $temp = Factory::getApplication()->getUserState('com_config.config.global.data');

        // Merge in the session data.
        if (!empty($temp)) {
            // $temp can sometimes be an object, and we need it to be an array
            if (\is_object($temp)) {
                $temp = ArrayHelper::fromObject($temp);
            }

            $data = array_merge($temp, $data);
        }

        $allowedMailers = ['mail', 'sendmail', 'smtp', 'smtpoauth2'];

        if (!\in_array((string) ($data['mailer'] ?? ''), $allowedMailers, true)) {
            $data['mailer'] = 'mail';
        }

        if (empty($data['oauth2_tenant_mode'])) {
            $tenantId                   = (string) ($data['oauth2_tenant_id'] ?? '');
            $data['oauth2_tenant_mode'] = strtolower($tenantId) !== 'common' && $tenantId !== '' ? 'tenant' : 'common';
        }

        return $data;
    }

    /**
     * Method to validate the db connection properties.
     *
     * @param   array  $data  An array containing all global config data.
     *
     * @return  array|boolean  Array with the validated global config data or boolean false on a validation failure.
     *
     * @since   4.0.0
     */
    public function validateDbConnection($data)
    {
        // Validate database connection encryption options
        if ((int) $data['dbencryption'] === 0) {
            // Reset unused options
            if (!empty($data['dbsslkey'])) {
                $data['dbsslkey'] = '';
            }

            if (!empty($data['dbsslcert'])) {
                $data['dbsslcert'] = '';
            }

            if ((bool) $data['dbsslverifyservercert'] === true) {
                $data['dbsslverifyservercert'] = false;
            }

            if (!empty($data['dbsslca'])) {
                $data['dbsslca'] = '';
            }

            if (!empty($data['dbsslcipher'])) {
                $data['dbsslcipher'] = '';
            }
        } else {
            // Check localhost
            if (strtolower($data['host']) === 'localhost') {
                Factory::getApplication()->enqueueMessage(Text::_('COM_CONFIG_ERROR_DATABASE_ENCRYPTION_LOCALHOST'), 'error');

                return false;
            }

            // Check CA file and folder depending on database type if server certificate verification
            if ((bool) $data['dbsslverifyservercert'] === true) {
                if (empty($data['dbsslca'])) {
                    Factory::getApplication()->enqueueMessage(
                        Text::sprintf(
                            'COM_CONFIG_ERROR_DATABASE_ENCRYPTION_FILE_FIELD_EMPTY',
                            Text::_('COM_CONFIG_FIELD_DATABASE_ENCRYPTION_CA_LABEL')
                        ),
                        'error'
                    );

                    return false;
                }

                if (!is_file(Path::clean($data['dbsslca']))) {
                    Factory::getApplication()->enqueueMessage(
                        Text::sprintf(
                            'COM_CONFIG_ERROR_DATABASE_ENCRYPTION_FILE_FIELD_BAD',
                            Text::_('COM_CONFIG_FIELD_DATABASE_ENCRYPTION_CA_LABEL')
                        ),
                        'error'
                    );

                    return false;
                }
            } else {
                // Reset unused option
                if (!empty($data['dbsslca'])) {
                    $data['dbsslca'] = '';
                }
            }

            // Check key and certificate if two-way encryption
            if ((int) $data['dbencryption'] === 2) {
                if (empty($data['dbsslkey'])) {
                    Factory::getApplication()->enqueueMessage(
                        Text::sprintf(
                            'COM_CONFIG_ERROR_DATABASE_ENCRYPTION_FILE_FIELD_EMPTY',
                            Text::_('COM_CONFIG_FIELD_DATABASE_ENCRYPTION_KEY_LABEL')
                        ),
                        'error'
                    );

                    return false;
                }

                if (!is_file(Path::clean($data['dbsslkey']))) {
                    Factory::getApplication()->enqueueMessage(
                        Text::sprintf(
                            'COM_CONFIG_ERROR_DATABASE_ENCRYPTION_FILE_FIELD_BAD',
                            Text::_('COM_CONFIG_FIELD_DATABASE_ENCRYPTION_KEY_LABEL')
                        ),
                        'error'
                    );

                    return false;
                }

                if (empty($data['dbsslcert'])) {
                    Factory::getApplication()->enqueueMessage(
                        Text::sprintf(
                            'COM_CONFIG_ERROR_DATABASE_ENCRYPTION_FILE_FIELD_EMPTY',
                            Text::_('COM_CONFIG_FIELD_DATABASE_ENCRYPTION_CERT_LABEL')
                        ),
                        'error'
                    );

                    return false;
                }

                if (!is_file(Path::clean($data['dbsslcert']))) {
                    Factory::getApplication()->enqueueMessage(
                        Text::sprintf(
                            'COM_CONFIG_ERROR_DATABASE_ENCRYPTION_FILE_FIELD_BAD',
                            Text::_('COM_CONFIG_FIELD_DATABASE_ENCRYPTION_CERT_LABEL')
                        ),
                        'error'
                    );

                    return false;
                }
            } else {
                // Reset unused options
                if (!empty($data['dbsslkey'])) {
                    $data['dbsslkey'] = '';
                }

                if (!empty($data['dbsslcert'])) {
                    $data['dbsslcert'] = '';
                }
            }
        }

        return $data;
    }

    /**
     * Method to save the configuration data.
     *
     * @param   array  $data  An array containing all global config data.
     *
     * @return  boolean  True on success, false on failure.
     *
     * @since   1.6
     */
    public function save($data)
    {
        $app = Factory::getApplication();

        // Try to load the values from the configuration file
        foreach ($this->protectedConfigurationFields as $fieldKey) {
            if (!isset($data[$fieldKey])) {
                $data[$fieldKey] = $app->get($fieldKey, '');
            }
        }

        // Never overwrite OAuth credentials with empty values.
        if (\array_key_exists('oauth2_client_secret', $data) && trim((string) $data['oauth2_client_secret']) === '') {
            $data['oauth2_client_secret'] = $app->get('oauth2_client_secret', '');
        }

        if (\array_key_exists('oauth2_refresh_token', $data) && trim((string) $data['oauth2_refresh_token']) === '') {
            $data['oauth2_refresh_token'] = $app->get('oauth2_refresh_token', '');
        }

        // Check that we aren't setting wrong database configuration
        $options = [
            'driver'   => $data['dbtype'],
            'host'     => $data['host'],
            'user'     => $data['user'],
            'password' => $data['password'],
            'database' => $data['db'],
            'prefix'   => $data['dbprefix'],
        ];

        // Validate database name
        if (\in_array($options['driver'], ['pgsql', 'postgresql']) && !preg_match('#^[a-zA-Z_][0-9a-zA-Z_$]*$#', $options['database'])) {
            $app->enqueueMessage(Text::_('COM_CONFIG_FIELD_DATABASE_NAME_INVALID_MSG_POSTGRES'), 'warning');

            return false;
        }

        if (\in_array($options['driver'], ['mysql', 'mysqli']) && preg_match('#[\\\\\/]#', $options['database'])) {
            $app->enqueueMessage(Text::_('COM_CONFIG_FIELD_DATABASE_NAME_INVALID_MSG_MYSQL'), 'warning');

            return false;
        }

        if ((int) $data['dbencryption'] !== 0) {
            $options['ssl'] = [
                'enable'             => true,
                'verify_server_cert' => (bool) $data['dbsslverifyservercert'],
            ];

            foreach (['cipher', 'ca', 'key', 'cert'] as $value) {
                $confVal = trim($data['dbssl' . $value]);

                if ($confVal !== '') {
                    $options['ssl'][$value] = $confVal;
                }
            }
        }

        try {
            $revisedDbo = (new DatabaseFactory())->getDriver($options['driver'], $options);
            $revisedDbo->getVersion();
        } catch (\Exception $e) {
            $app->enqueueMessage(Text::sprintf('COM_CONFIG_ERROR_DATABASE_NOT_AVAILABLE', $e->getCode(), $e->getMessage()), 'error');

            return false;
        }

        if ((int) $data['dbencryption'] !== 0 && empty($revisedDbo->getConnectionEncryption())) {
            if ($revisedDbo->isConnectionEncryptionSupported()) {
                Factory::getApplication()->enqueueMessage(Text::_('COM_CONFIG_ERROR_DATABASE_ENCRYPTION_CONN_NOT_ENCRYPT'), 'error');
            } else {
                Factory::getApplication()->enqueueMessage(Text::_('COM_CONFIG_ERROR_DATABASE_ENCRYPTION_SRV_NOT_SUPPORTS'), 'error');
            }

            return false;
        }

        // Check if we can set the Force SSL option
        if ((int) $data['force_ssl'] !== 0 && (int) $data['force_ssl'] !== (int) $app->get('force_ssl', '0')) {
            try {
                // Make an HTTPS request to check if the site is available in HTTPS.
                $host    = Uri::getInstance()->getHost();
                $options = new Registry();
                $options->set('userAgent', 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:41.0) Gecko/20100101 Firefox/41.0');

                // Do not check for valid server certificate here, leave this to the user, moreover disable using a proxy if any is configured.
                $options->set(
                    'transport.curl',
                    [
                        CURLOPT_SSL_VERIFYPEER => false,
                        CURLOPT_SSL_VERIFYHOST => false,
                        CURLOPT_PROXY          => null,
                        CURLOPT_PROXYUSERPWD   => null,
                    ]
                );
                $response = (new HttpFactory())->getHttp($options)->get('https://' . $host . Uri::root(true) . '/', ['Host' => $host], 10);

                // If available in HTTPS check also the status code.
                if (!\in_array($response->getStatusCode(), [200, 503, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 401], true)) {
                    throw new \RuntimeException(Text::_('COM_CONFIG_ERROR_SSL_NOT_AVAILABLE_HTTP_CODE'));
                }
            } catch (\RuntimeException $e) {
                $data['force_ssl'] = 0;

                // Also update the user state
                $app->setUserState('com_config.config.global.data.force_ssl', 0);

                // Inform the user
                $app->enqueueMessage(Text::sprintf('COM_CONFIG_ERROR_SSL_NOT_AVAILABLE', $e->getMessage()), 'warning');
            }
        }

        // Save the rules
        if (isset($data['rules'])) {
            $rules = new Rules($data['rules']);

            // Check that we aren't removing our Super User permission
            // Need to get groups from database, since they might have changed
            $myGroups      = Access::getGroupsByUser($this->getCurrentUser()->id);
            $myRules       = $rules->getData();
            $hasSuperAdmin = $myRules['core.admin']->allow($myGroups);

            if (!$hasSuperAdmin) {
                $app->enqueueMessage(Text::_('COM_CONFIG_ERROR_REMOVING_SUPER_ADMIN'), 'error');

                return false;
            }

            $asset = new Asset($this->getDatabase());

            if ($asset->loadByName('root.1')) {
                $asset->rules = (string) $rules;

                if (!$asset->check() || !$asset->store()) {
                    $app->enqueueMessage($asset->getError(), 'error');

                    return false;
                }
            } else {
                $app->enqueueMessage(Text::_('COM_CONFIG_ERROR_ROOT_ASSET_NOT_FOUND'), 'error');

                return false;
            }

            unset($data['rules']);
        }

        // Save the text filters
        if (isset($data['filters'])) {
            $registry = new Registry(['filters' => $data['filters']]);

            $extension = new Extension($this->getDatabase());

            // Get extension_id
            $extensionId = $extension->find(['name' => 'com_config']);

            if ($extension->load((int) $extensionId)) {
                $extension->params = (string) $registry;

                if (!$extension->check() || !$extension->store()) {
                    $app->enqueueMessage($extension->getError(), 'error');

                    return false;
                }
            } else {
                $app->enqueueMessage(Text::_('COM_CONFIG_ERROR_CONFIG_EXTENSION_NOT_FOUND'), 'error');

                return false;
            }

            unset($data['filters']);
        }

        // Get the previous configuration.
        $prev = new \JConfig();
        $prev = ArrayHelper::fromObject($prev);

        // Merge the new data in. We do this to preserve values that were not in the form.
        $data = array_merge($prev, $data);

        /*
         * Perform miscellaneous options based on configuration settings/changes.
         */

        // Escape the offline message if present.
        if (isset($data['offline_message'])) {
            $data['offline_message'] = OutputFilter::ampReplace($data['offline_message']);
        }

        // Purge the database session table if we are changing to the database handler.
        if ($prev['session_handler'] != 'database' && $data['session_handler'] == 'database') {
            $db    = $this->getDatabase();
            $query = $db->createQuery()
                ->delete($db->quoteName('#__session'))
                ->where($db->quoteName('time') . ' < ' . (time() - 1));
            $db->setQuery($query);
            $db->execute();
        }

        // Purge the database session table if we are disabling session metadata
        if ($prev['session_metadata'] == 1 && $data['session_metadata'] == 0) {
            try {
                // If we are are using the session handler, purge the extra columns, otherwise truncate the whole session table
                if ($data['session_handler'] === 'database') {
                    $revisedDbo->setQuery(
                        $revisedDbo->createQuery()
                            ->update('#__session')
                            ->set(
                                [
                                    $revisedDbo->quoteName('client_id') . ' = 0',
                                    $revisedDbo->quoteName('guest') . ' = NULL',
                                    $revisedDbo->quoteName('userid') . ' = NULL',
                                    $revisedDbo->quoteName('username') . ' = NULL',
                                ]
                            )
                    )->execute();
                } else {
                    $revisedDbo->truncateTable('#__session');
                }
            } catch (\RuntimeException) {
                /*
                 * The database API logs errors on failures so we don't need to add any error handling mechanisms here.
                 * Also, this data won't be added or checked anymore once the configuration is saved, so it'll purge itself
                 * through normal garbage collection anyway or if not using the database handler someone can purge the
                 * table on their own.  Either way, carry on Soldier!
                 */
            }
        }

        // Ensure custom session file path exists or try to create it if changed
        if (!empty($data['session_filesystem_path'])) {
            $currentPath = $prev['session_filesystem_path'] ?? null;

            if ($currentPath) {
                $currentPath = Path::clean($currentPath);
            }

            $data['session_filesystem_path'] = Path::clean($data['session_filesystem_path']);

            if ($currentPath !== $data['session_filesystem_path']) {
                if (!is_dir(Path::clean($data['session_filesystem_path'])) && !Folder::create($data['session_filesystem_path'])) {
                    try {
                        Log::add(
                            Text::sprintf(
                                'COM_CONFIG_ERROR_CUSTOM_SESSION_FILESYSTEM_PATH_NOTWRITABLE_USING_DEFAULT',
                                $data['session_filesystem_path']
                            ),
                            Log::WARNING,
                            'jerror'
                        );
                    } catch (\RuntimeException) {
                        $app->enqueueMessage(
                            Text::sprintf(
                                'COM_CONFIG_ERROR_CUSTOM_SESSION_FILESYSTEM_PATH_NOTWRITABLE_USING_DEFAULT',
                                $data['session_filesystem_path']
                            ),
                            'warning'
                        );
                    }

                    $data['session_filesystem_path'] = $currentPath;
                }
            }
        }

        // Set the shared session configuration
        if (isset($data['shared_session'])) {
            $currentShared = $prev['shared_session'] ?? '0';

            // Has the user enabled shared sessions?
            if ($data['shared_session'] == 1 && $currentShared == 0) {
                // Generate a random shared session name
                $data['session_name'] = UserHelper::genRandomPassword(16);
            }

            // Has the user disabled shared sessions?
            if ($data['shared_session'] == 0 && $currentShared == 1) {
                // Remove the session name value
                unset($data['session_name']);
            }
        }

        // Set the shared session configuration
        if (isset($data['shared_session'])) {
            $currentShared = $prev['shared_session'] ?? '0';

            // Has the user enabled shared sessions?
            if ($data['shared_session'] == 1 && $currentShared == 0) {
                // Generate a random shared session name
                $data['session_name'] = UserHelper::genRandomPassword(16);
            }

            // Has the user disabled shared sessions?
            if ($data['shared_session'] == 0 && $currentShared == 1) {
                // Remove the session name value
                unset($data['session_name']);
            }
        }

        if (empty($data['cache_handler'])) {
            $data['caching'] = 0;
        }

        /*
         * Look for a custom cache_path
         * First check if a path is given in the submitted data, then check if a path exists in the previous data, otherwise use the default
         */
        if (!empty($data['cache_path'])) {
            $path = $data['cache_path'];
        } elseif (!empty($prev['cache_path'])) {
            $path = $prev['cache_path'];
        } else {
            $path = JPATH_CACHE;
        }

        // Give a warning if the cache-folder can not be opened
        if ($data['caching'] > 0 && $data['cache_handler'] == 'file' && @opendir($path) === false) {
            $error = true;

            // If a custom path is in use, try using the system default instead of disabling cache
            if ($path !== JPATH_CACHE && @opendir(JPATH_CACHE) !== false) {
                try {
                    Log::add(
                        Text::sprintf('COM_CONFIG_ERROR_CUSTOM_CACHE_PATH_NOTWRITABLE_USING_DEFAULT', $path, JPATH_CACHE),
                        Log::WARNING,
                        'jerror'
                    );
                } catch (\RuntimeException) {
                    $app->enqueueMessage(
                        Text::sprintf('COM_CONFIG_ERROR_CUSTOM_CACHE_PATH_NOTWRITABLE_USING_DEFAULT', $path, JPATH_CACHE),
                        'warning'
                    );
                }

                $path  = JPATH_CACHE;
                $error = false;

                $data['cache_path'] = '';
            }

            if ($error) {
                try {
                    Log::add(Text::sprintf('COM_CONFIG_ERROR_CACHE_PATH_NOTWRITABLE', $path), Log::WARNING, 'jerror');
                } catch (\RuntimeException) {
                    $app->enqueueMessage(Text::sprintf('COM_CONFIG_ERROR_CACHE_PATH_NOTWRITABLE', $path), 'warning');
                }

                $data['caching'] = 0;
            }
        }

        // Did the user remove their custom cache path?  Don't save the variable to the config
        if (empty($data['cache_path'])) {
            unset($data['cache_path']);
        }

        // Clean the cache if disabled but previously enabled or changing cache handlers; these operations use the `$prev` data already in memory
        if ((!$data['caching'] && $prev['caching']) || $data['cache_handler'] !== $prev['cache_handler']) {
            try {
                $this->getCacheControllerFactory()
                    ->createCacheController('callback', ['defaultgroup' => ''])
                    ->clean();
            } catch (CacheConnectingException) {
                try {
                    Log::add(Text::_('COM_CONFIG_ERROR_CACHE_CONNECTION_FAILED'), Log::WARNING, 'jerror');
                } catch (\RuntimeException) {
                    $app->enqueueMessage(Text::_('COM_CONFIG_ERROR_CACHE_CONNECTION_FAILED'), 'warning');
                }
            } catch (UnsupportedCacheException) {
                try {
                    Log::add(Text::_('COM_CONFIG_ERROR_CACHE_DRIVER_UNSUPPORTED'), Log::WARNING, 'jerror');
                } catch (\RuntimeException) {
                    $app->enqueueMessage(Text::_('COM_CONFIG_ERROR_CACHE_DRIVER_UNSUPPORTED'), 'warning');
                }
            }
        }

        /*
         * Look for a custom tmp_path
         * First check if a path is given in the submitted data, then check if a path exists in the previous data, otherwise use the default
         */
        $defaultTmpPath = JPATH_ROOT . '/tmp';

        if (!empty($data['tmp_path'])) {
            $path = $data['tmp_path'];
        } elseif (!empty($prev['tmp_path'])) {
            $path = $prev['tmp_path'];
        } else {
            $path = $defaultTmpPath;
        }

        $path = Path::clean($path);

        // Give a warning if the tmp-folder is not valid or not writable
        if (!is_dir($path) || !is_writable($path)) {
            $error = true;

            // If a custom path is in use, try using the system default tmp path
            if ($path !== $defaultTmpPath && is_dir($defaultTmpPath) && is_writable($defaultTmpPath)) {
                try {
                    Log::add(
                        Text::sprintf('COM_CONFIG_ERROR_CUSTOM_TEMP_PATH_NOTWRITABLE_USING_DEFAULT', $path, $defaultTmpPath),
                        Log::WARNING,
                        'jerror'
                    );
                } catch (\RuntimeException) {
                    $app->enqueueMessage(
                        Text::sprintf('COM_CONFIG_ERROR_CUSTOM_TEMP_PATH_NOTWRITABLE_USING_DEFAULT', $path, $defaultTmpPath),
                        'warning'
                    );
                }

                $error = false;

                $data['tmp_path'] = $defaultTmpPath;
            }

            if ($error) {
                try {
                    Log::add(Text::sprintf('COM_CONFIG_ERROR_TMP_PATH_NOTWRITABLE', $path), Log::WARNING, 'jerror');
                } catch (\RuntimeException) {
                    $app->enqueueMessage(Text::sprintf('COM_CONFIG_ERROR_TMP_PATH_NOTWRITABLE', $path), 'warning');
                }
            }
        }

        /*
         * Look for a custom log_path
         * First check if a path is given in the submitted data, then check if a path exists in the previous data, otherwise use the default
         */
        $defaultLogPath = JPATH_ADMINISTRATOR . '/logs';

        if (!empty($data['log_path'])) {
            $path = $data['log_path'];
        } elseif (!empty($prev['log_path'])) {
            $path = $prev['log_path'];
        } else {
            $path = $defaultLogPath;
        }

        $path = Path::clean($path);

        // Give a warning if the log-folder is not valid or not writable
        if (!is_dir($path) || !is_writable($path)) {
            $error = true;

            // If a custom path is in use, try using the system default log path
            if ($path !== $defaultLogPath && is_dir($defaultLogPath) && is_writable($defaultLogPath)) {
                try {
                    Log::add(
                        Text::sprintf('COM_CONFIG_ERROR_CUSTOM_LOG_PATH_NOTWRITABLE_USING_DEFAULT', $path, $defaultLogPath),
                        Log::WARNING,
                        'jerror'
                    );
                } catch (\RuntimeException) {
                    $app->enqueueMessage(
                        Text::sprintf('COM_CONFIG_ERROR_CUSTOM_LOG_PATH_NOTWRITABLE_USING_DEFAULT', $path, $defaultLogPath),
                        'warning'
                    );
                }

                $error            = false;
                $data['log_path'] = $defaultLogPath;
            }

            if ($error) {
                try {
                    Log::add(Text::sprintf('COM_CONFIG_ERROR_LOG_PATH_NOTWRITABLE', $path), Log::WARNING, 'jerror');
                } catch (\RuntimeException) {
                    $app->enqueueMessage(Text::sprintf('COM_CONFIG_ERROR_LOG_PATH_NOTWRITABLE', $path), 'warning');
                }
            }
        }

        // Create the new configuration object.
        $config = new Registry($data);

        // Overwrite webservices cors settings
        $app->set('cors', $data['cors'] ?? 0);
        $app->set('cors_allow_origin', $data['cors_allow_origin'] ?? '*');
        $app->set('cors_allow_headers', $data['cors_allow_headers'] ?? 'Content-Type,X-Joomla-Token');
        $app->set('cors_allow_methods', $data['cors_allow_methods'] ?? '');

        // Clear cache of com_config component.
        $this->cleanCache('_system');

        $dispatcher  = $this->getDispatcher();
        $eventBefore = new BeforeSaveConfigurationEvent('onApplicationBeforeSave', ['subject' => $config]);
        $result      = $dispatcher->dispatch('onApplicationBeforeSave', $eventBefore)->getArgument('result', []);

        // Store the data.
        if (\in_array(false, $result, true)) {
            throw new \RuntimeException(Text::_('COM_CONFIG_ERROR_UNKNOWN_BEFORE_SAVING'));
        }

        // Write the configuration file.
        $result = $this->writeConfigFile($config);

        // Trigger the after save event.
        $this->getDispatcher()->dispatch('onApplicationAfterSave', new AfterSaveConfigurationEvent(
            'onApplicationAfterSave',
            ['subject' => $config]
        ));

        return $result;
    }

    /**
     * Method to unset the root_user value from configuration data.
     *
     * This method will load the global configuration data straight from
     * JConfig and remove the root_user value for security, then save the configuration.
     *
     * @return  boolean  True on success, false on failure.
     *
     * @since   1.6
     */
    public function removeroot()
    {
        // Get the previous configuration.
        $prev = new \JConfig();
        $prev = ArrayHelper::fromObject($prev);

        // Create the new configuration object, and unset the root_user property
        unset($prev['root_user']);
        $config = new Registry($prev);

        $dispatcher  = $this->getDispatcher();
        $eventBefore = new BeforeSaveConfigurationEvent('onApplicationBeforeSave', ['subject' => $config]);
        $result      = $dispatcher->dispatch('onApplicationBeforeSave', $eventBefore)->getArgument('result', []);

        // Store the data.
        if (\in_array(false, $result, true)) {
            throw new \RuntimeException(Text::_('COM_CONFIG_ERROR_UNKNOWN_BEFORE_SAVING'));
        }

        // Write the configuration file.
        $result = $this->writeConfigFile($config);

        // Trigger the after save event.
        $this->getDispatcher()->dispatch('onApplicationAfterSave', new AfterSaveConfigurationEvent(
            'onApplicationAfterSave',
            ['subject' => $config]
        ));

        return $result;
    }

    /**
     * Method to write the configuration to a file.
     *
     * @param   Registry  $config  A Registry object containing all global config data.
     *
     * @return  boolean  True on success, false on failure.
     *
     * @since   2.5.4
     * @throws  \RuntimeException
     */
    private function writeConfigFile(Registry $config)
    {
        // Set the configuration file path.
        $file = JPATH_CONFIGURATION . '/configuration.php';

        $app = Factory::getApplication();

        // Attempt to make the file writable.
        if (Path::isOwner($file) && !Path::setPermissions($file, '0644')) {
            $app->enqueueMessage(Text::_('COM_CONFIG_ERROR_CONFIGURATION_PHP_NOTWRITABLE'), 'notice');
        }

        // Attempt to write the configuration file as a PHP class named JConfig.
        $configuration = $config->toString('PHP', ['class' => 'JConfig', 'closingtag' => false]);

        if (!File::write($file, $configuration)) {
            throw new \RuntimeException(Text::_('COM_CONFIG_ERROR_WRITE_FAILED'));
        }

        // Attempt to make the file unwritable.
        if (Path::isOwner($file) && !Path::setPermissions($file, '0444')) {
            $app->enqueueMessage(Text::_('COM_CONFIG_ERROR_CONFIGURATION_PHP_NOTUNWRITABLE'), 'notice');
        }

        return true;
    }

    /**
     * Method to store the permission values in the asset table.
     *
     * This method will get an array with permission key value pairs and transform it
     * into json and update the asset table in the database.
     *
     * @param   string  $permission  Need an array with Permissions (component, rule, value and title)
     *
     * @return  array|bool  A list of result data or false on failure.
     *
     * @since   3.5
     */
    public function storePermissions($permission = null)
    {
        $app   = Factory::getApplication();
        $input = $app->getInput();
        $user  = $this->getCurrentUser();

        if (\is_null($permission)) {
            // Get data from input.
            $permission = [
                'component' => $input->json->get('comp'),
                'action'    => $input->json->get('action'),
                'rule'      => $input->json->get('rule'),
                'value'     => $input->json->get('value'),
                'title'     => $input->json->get('title', '', 'RAW'),
            ];
        }

        // We are creating a new item so we don't have an item id so don't allow.
        if (str_ends_with($permission['component'], '.false')) {
            $app->enqueueMessage(Text::_('JLIB_RULES_SAVE_BEFORE_CHANGE_PERMISSIONS'), 'error');

            return false;
        }

        // Check if the user is authorized to do this.
        if (!$user->authorise('core.admin', $permission['component'])) {
            $app->enqueueMessage(Text::_('JERROR_ALERTNOAUTHOR'), 'error');

            return false;
        }

        $permission['component'] = empty($permission['component']) ? 'root.1' : $permission['component'];

        // Current view is global config?
        $isGlobalConfig = $permission['component'] === 'root.1';

        // Check if changed group has Super User permissions.
        $isSuperUserGroupBefore = Access::checkGroup($permission['rule'], 'core.admin');

        // Check if current user belongs to changed group.
        $currentUserBelongsToGroup = \in_array((int) $permission['rule'], $user->groups);

        // Get current user groups tree.
        $currentUserGroupsTree = Access::getGroupsByUser($user->id, true);

        // Check if current user belongs to changed group.
        $currentUserSuperUser = $user->authorise('core.admin');

        // If user is not Super User cannot change the permissions of a group it belongs to.
        if (!$currentUserSuperUser && $currentUserBelongsToGroup) {
            $app->enqueueMessage(Text::_('JLIB_USER_ERROR_CANNOT_CHANGE_OWN_GROUPS'), 'error');

            return false;
        }

        // If user is not Super User cannot change the permissions of a group it belongs to.
        if (!$currentUserSuperUser && \in_array((int) $permission['rule'], $currentUserGroupsTree)) {
            $app->enqueueMessage(Text::_('JLIB_USER_ERROR_CANNOT_CHANGE_OWN_PARENT_GROUPS'), 'error');

            return false;
        }

        // If user is not Super User cannot change the permissions of a Super User Group.
        if (!$currentUserSuperUser && $isSuperUserGroupBefore && !$currentUserBelongsToGroup) {
            $app->enqueueMessage(Text::_('JLIB_USER_ERROR_CANNOT_CHANGE_SUPER_USER'), 'error');

            return false;
        }

        // If user is not Super User cannot change the Super User permissions in any group it belongs to.
        if ($isSuperUserGroupBefore && $currentUserBelongsToGroup && $permission['action'] === 'core.admin') {
            $app->enqueueMessage(Text::_('JLIB_USER_ERROR_CANNOT_DEMOTE_SELF'), 'error');

            return false;
        }

        try {
            $asset  = new Asset($this->getDatabase());
            $result = $asset->loadByName($permission['component']);

            if ($result === false) {
                $data = [$permission['action'] => [$permission['rule'] => $permission['value']]];

                $rules        = new Rules($data);
                $asset->rules = (string) $rules;
                $asset->name  = (string) $permission['component'];
                $asset->title = (string) $permission['title'];

                // Get the parent asset id so we have a correct tree.
                $parentAsset = new Asset($this->getDatabase());

                if (str_contains($asset->name, '.')) {
                    $assetParts = explode('.', $asset->name);
                    $parentAsset->loadByName($assetParts[0]);
                    $parentAssetId = $parentAsset->id;
                } else {
                    $parentAssetId = $parentAsset->getRootId();
                }

                /**
                 * @todo: incorrect ACL stored
                 * When changing a permission of an item that doesn't have a row in the asset table the row a new row is created.
                 * This works fine for item <-> component <-> global config scenario and component <-> global config scenario.
                 * But doesn't work properly for item <-> section(s) <-> component <-> global config scenario,
                 * because a wrong parent asset id (the component) is stored.
                 * Happens when there is no row in the asset table (ex: deleted or not created on update).
                 */

                $asset->setLocation($parentAssetId, 'last-child');
            } else {
                // Decode the rule settings.
                $temp = json_decode($asset->rules, true);

                // Check if a new value is to be set.
                if (isset($permission['value'])) {
                    // Check if we already have an action entry.
                    if (!isset($temp[$permission['action']])) {
                        $temp[$permission['action']] = [];
                    }

                    // Check if we already have a rule entry.
                    if (!isset($temp[$permission['action']][$permission['rule']])) {
                        $temp[$permission['action']][$permission['rule']] = [];
                    }

                    // Set the new permission.
                    $temp[$permission['action']][$permission['rule']] = (int) $permission['value'];

                    // Check if we have an inherited setting.
                    if ($permission['value'] === '') {
                        unset($temp[$permission['action']][$permission['rule']]);
                    }

                    // Check if we have any rules.
                    if (!$temp[$permission['action']]) {
                        unset($temp[$permission['action']]);
                    }
                } else {
                    // There is no value so remove the action as it's not needed.
                    unset($temp[$permission['action']]);
                }

                $asset->rules = json_encode($temp, JSON_FORCE_OBJECT);
            }

            if (!$asset->check() || !$asset->store()) {
                $app->enqueueMessage(Text::_('JLIB_UNKNOWN'), 'error');

                return false;
            }
        } catch (\Exception $e) {
            $app->enqueueMessage($e->getMessage(), 'error');

            return false;
        }

        // All checks done.
        $result = [
            'text'   => '',
            'class'  => '',
            'result' => true,
        ];

        // Show the current effective calculated permission considering current group, path and cascade.

        try {
            // The database instance
            $db = $this->getDatabase();

            // Get the asset id by the name of the component.
            $query = $db->createQuery()
                ->select($db->quoteName('id'))
                ->from($db->quoteName('#__assets'))
                ->where($db->quoteName('name') . ' = :component')
                ->bind(':component', $permission['component']);

            $db->setQuery($query);

            $assetId = (int) $db->loadResult();

            // Fetch the parent asset id.
            $parentAssetId = null;

            /**
             * @todo: incorrect info
             * When creating a new item (not saving) it uses the calculated permissions from the component (item <-> component <-> global config).
             * But if we have a section too (item <-> section(s) <-> component <-> global config) this is not correct.
             * Also, currently it uses the component permission, but should use the calculated permissions for a child of the component/section.
             */

            // If not in global config we need the parent_id asset to calculate permissions.
            if (!$isGlobalConfig) {
                // In this case we need to get the component rules too.
                $query->clear()
                    ->select($db->quoteName('parent_id'))
                    ->from($db->quoteName('#__assets'))
                    ->where($db->quoteName('id') . ' = :assetid')
                    ->bind(':assetid', $assetId, ParameterType::INTEGER);

                $db->setQuery($query);

                $parentAssetId = (int) $db->loadResult();
            }

            // Get the group parent id of the current group.
            $rule = (int) $permission['rule'];
            $query->clear()
                ->select($db->quoteName('parent_id'))
                ->from($db->quoteName('#__usergroups'))
                ->where($db->quoteName('id') . ' = :rule')
                ->bind(':rule', $rule, ParameterType::INTEGER);

            $db->setQuery($query);

            $parentGroupId = (int) $db->loadResult();

            // Count the number of child groups of the current group.
            $query->clear()
                ->select('COUNT(' . $db->quoteName('id') . ')')
                ->from($db->quoteName('#__usergroups'))
                ->where($db->quoteName('parent_id') . ' = :rule')
                ->bind(':rule', $rule, ParameterType::INTEGER);

            $db->setQuery($query);

            $totalChildGroups = (int) $db->loadResult();
        } catch (\Exception $e) {
            $app->enqueueMessage($e->getMessage(), 'error');

            return false;
        }

        // Clear access statistics.
        Access::clearStatics();

        // After current group permission is changed we need to check again if the group has Super User permissions.
        $isSuperUserGroupAfter = Access::checkGroup($permission['rule'], 'core.admin');

        // Get the rule for just this asset (non-recursive) and get the actual setting for the action for this group.
        $assetRule = Access::getAssetRules($assetId, false, false)->allow($permission['action'], $permission['rule']);

        // Get the group, group parent id, and group global config recursive calculated permission for the chosen action.
        $inheritedGroupRule = Access::checkGroup($permission['rule'], $permission['action'], $assetId);

        if (!empty($parentAssetId)) {
            $inheritedGroupParentAssetRule = Access::checkGroup($permission['rule'], $permission['action'], $parentAssetId);
        } else {
            $inheritedGroupParentAssetRule = null;
        }

        $inheritedParentGroupRule = !empty($parentGroupId) ? Access::checkGroup($parentGroupId, $permission['action'], $assetId) : null;

        // Current group is a Super User group, so calculated setting is "Allowed (Super User)".
        if ($isSuperUserGroupAfter) {
            $result['class'] = 'badge bg-success';
            $result['text']  = '<span class="icon-lock icon-white" aria-hidden="true"></span>' . Text::_('JLIB_RULES_ALLOWED_ADMIN');
        } else {
            // Not super user.
            // First get the real recursive calculated setting and add (Inherited) to it.

            // If recursive calculated setting is "Denied" or null. Calculated permission is "Not Allowed (Inherited)".
            if ($inheritedGroupRule === null || $inheritedGroupRule === false) {
                $result['class'] = 'badge bg-danger';
                $result['text']  = Text::_('JLIB_RULES_NOT_ALLOWED_INHERITED');
            } else {
                // If recursive calculated setting is "Allowed". Calculated permission is "Allowed (Inherited)".
                $result['class'] = 'badge bg-success';
                $result['text']  = Text::_('JLIB_RULES_ALLOWED_INHERITED');
            }

            // Second part: Overwrite the calculated permissions labels if there is an explicit permission in the current group.

            /**
             * @todo: incorrect info
             * If a component has a permission that doesn't exists in global config (ex: frontend editing in com_modules) by default
             * we get "Not Allowed (Inherited)" when we should get "Not Allowed (Default)".
             */

            // If there is an explicit permission "Not Allowed". Calculated permission is "Not Allowed".
            if ($assetRule === false) {
                $result['class'] = 'badge bg-danger';
                $result['text']  = Text::_('JLIB_RULES_NOT_ALLOWED');
            } elseif ($assetRule === true) {
                // If there is an explicit permission is "Allowed". Calculated permission is "Allowed".
                $result['class'] = 'badge bg-success';
                $result['text']  = Text::_('JLIB_RULES_ALLOWED');
            }

            // Third part: Overwrite the calculated permissions labels for special cases.

            // Global configuration with "Not Set" permission. Calculated permission is "Not Allowed (Default)".
            if (empty($parentGroupId) && $isGlobalConfig === true && $assetRule === null) {
                $result['class'] = 'badge bg-danger';
                $result['text']  = Text::_('JLIB_RULES_NOT_ALLOWED_DEFAULT');
            } elseif ($inheritedGroupParentAssetRule === false || $inheritedParentGroupRule === false) {
                /**
                 * Component/Item with explicit "Denied" permission at parent Asset (Category, Component or Global config) configuration.
                 * Or some parent group has an explicit "Denied".
                 * Calculated permission is "Not Allowed (Locked)".
                 */
                $result['class'] = 'badge bg-danger';
                $result['text']  = '<span class="icon-lock icon-white" aria-hidden="true"></span>' . Text::_('JLIB_RULES_NOT_ALLOWED_LOCKED');
            }
        }

        // If removed or added super user from group, we need to refresh the page to recalculate all settings.
        if ($isSuperUserGroupBefore != $isSuperUserGroupAfter) {
            $app->enqueueMessage(Text::_('JLIB_RULES_NOTICE_RECALCULATE_GROUP_PERMISSIONS'), 'notice');
        }

        // If this group has child groups, we need to refresh the page to recalculate the child settings.
        if ($totalChildGroups > 0) {
            $app->enqueueMessage(Text::_('JLIB_RULES_NOTICE_RECALCULATE_GROUP_CHILDS_PERMISSIONS'), 'notice');
        }

        return $result;
    }

    /**
     * Method to send a test mail which is called via an AJAX request
     *
     * @return   boolean
     *
     * @since  3.5
     */
    public function sendTestMail()
    {
        // Set the new values to test with the current settings
        $app      = Factory::getApplication();
        $user     = $this->getCurrentUser();
        $input    = $app->getInput()->json;
        $smtppass = $input->get('smtppass', null, 'RAW');

        $config = new Registry();
        $config->set('smtpauth', $input->get('smtpauth'));
        $config->set('smtpuser', $input->get('smtpuser', '', 'STRING'));
        $config->set('smtphost', $input->get('smtphost'));
        $config->set('smtpsecure', $input->get('smtpsecure'));
        $config->set('smtpport', $input->get('smtpport'));
        $config->set('mailfrom', $input->get('mailfrom', '', 'STRING'));
        $config->set('fromname', $input->get('fromname', '', 'STRING'));
        $config->set('mailer', $input->get('mailer'));
        $config->set('mailonline', $input->get('mailonline'));
        $config->set('oauth2_provider', $input->get('oauth2_provider', $app->get('oauth2_provider', 'microsoft'), 'STRING'));
        $config->set('oauth2_email', $input->get('oauth2_email', $app->get('oauth2_email', ''), 'STRING'));
        $config->set('oauth2_client_id', $input->get('oauth2_client_id', $app->get('oauth2_client_id', ''), 'STRING'));
        $config->set('oauth2_client_secret', $input->get('oauth2_client_secret', $app->get('oauth2_client_secret', ''), 'RAW'));
        $config->set('oauth2_scope', $input->get('oauth2_scope', $app->get('oauth2_scope', ''), 'STRING'));
        $config->set('oauth2_tenant_mode', $input->get('oauth2_tenant_mode', $app->get('oauth2_tenant_mode', ''), 'CMD'));
        $config->set('oauth2_tenant_id', $input->get('oauth2_tenant_id', $app->get('oauth2_tenant_id', 'common'), 'STRING'));
        $config->set('oauth2_authorize_url', $input->get('oauth2_authorize_url', $app->get('oauth2_authorize_url', ''), 'STRING'));
        $config->set('oauth2_token_url', $input->get('oauth2_token_url', $app->get('oauth2_token_url', ''), 'STRING'));
        $config->set('oauth2_smtp_host', $input->get('oauth2_smtp_host', $app->get('oauth2_smtp_host', ''), 'STRING'));
        $config->set('oauth2_smtp_port', $input->get('oauth2_smtp_port', $app->get('oauth2_smtp_port', 587), 'INT'));
        $config->set('oauth2_smtp_secure', $input->get('oauth2_smtp_secure', $app->get('oauth2_smtp_secure', 'tls'), 'STRING'));
        $config->set('oauth2_refresh_token', $input->get('oauth2_refresh_token', $app->get('oauth2_refresh_token', ''), 'RAW'));
        $config->set('oauth2_token_issued_at', $input->get('oauth2_token_issued_at', $app->get('oauth2_token_issued_at', ''), 'STRING'));
        // Use smtppass only if it was submitted
        if ($smtppass !== null) {
            $config->set('smtppass', $smtppass);
        }

        $mail = $this->getMailerFactory()->createMailer($config);

        // Prepare email and try to send it
        $mailer = new MailTemplate(
            'com_config.test_mail',
            $user->getParam('language', $app->get('language')),
            $mail,
            Factory::getContainer()->get(LanguageFactoryInterface::class),
            $this->getDatabase()
        );
        $mailer->addTemplateData(
            [
                // Replace the occurrences of "@" and "|" in the site name
                'sitename' => str_replace(['@', '|'], '', $app->get('sitename')),
                'method'   => Text::_('COM_CONFIG_SENDMAIL_METHOD_' . strtoupper($mail->Mailer ?? $input->get('mailer'))),
            ]
        );
        $mailer->addRecipient($user->email, $user->name);

        try {
            $mailSent = $mailer->send();
        } catch (MailDisabledException | phpMailerException $e) {
            $app->enqueueMessage($e->getMessage(), 'error');

            return false;
        }

        if ($mailSent === true) {
            $methodName       = Text::_('COM_CONFIG_SENDMAIL_METHOD_' . strtoupper($mail->Mailer));
            $configuredMailer = (string) $app->get('mailer');

            if (\in_array($configuredMailer, ['smtpoauth2'], true)) {
                $configuredMailer = 'smtp';
            }

            // If JMail send the mail using PHP Mail as fallback.
            if (($mail->Mailer ?? $app->get('mailer')) !== $configuredMailer) {
                $app->enqueueMessage(Text::sprintf('COM_CONFIG_SENDMAIL_SUCCESS_FALLBACK', $user->email, $methodName), 'warning');
            } else {
                $app->enqueueMessage(Text::sprintf('COM_CONFIG_SENDMAIL_SUCCESS', $user->email, $methodName), 'message');
            }

            return true;
        }

        $errorInfo = trim((string) $mail->ErrorInfo);
        $app->enqueueMessage($errorInfo !== '' ? $errorInfo : Text::_('COM_CONFIG_SENDMAIL_ERROR'), 'error');

        return false;
    }
}
