<?php

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

namespace Joomla\CMS\Updater\Adapter;

use Joomla\CMS\Application\ApplicationHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\Filter\InputFilter;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Table\Update;
use Joomla\CMS\Updater\UpdateAdapter;
use Joomla\CMS\Updater\Updater;
use Joomla\CMS\Version;

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

/**
 * Extension class for updater
 *
 * @since  1.7.0
 */
class ExtensionAdapter extends UpdateAdapter
{
    protected $currentUpdate;
    protected $latest;

    /**
     * Start element parser callback.
     *
     * @param   object  $parser  The parser object.
     * @param   string  $name    The name of the element.
     * @param   array   $attrs   The attributes of the element.
     *
     * @return  void
     *
     * @since   1.7.0
     */
    protected function _startElement($parser, $name, $attrs = [])
    {
        $this->stack[] = $name;
        $tag           = $this->_getStackLocation();

        // Reset the data
        if (isset($this->$tag)) {
            $this->$tag->_data = '';
        }

        switch ($name) {
            case 'UPDATE':
                $this->currentUpdate                 = new Update($this->db);
                $this->currentUpdate->update_site_id = $this->updateSiteId;
                $this->currentUpdate->detailsurl     = $this->_url;
                $this->currentUpdate->folder         = '';
                $this->currentUpdate->client_id      = 1;
                $this->currentUpdate->infourl        = '';
                break;

            case 'UPDATES':
                // Don't do anything
                break;

            default:
                if (\in_array($name, $this->updatecols)) {
                    $name                       = strtolower($name);
                    $this->currentUpdate->$name = '';
                }

                if ($name === 'TARGETPLATFORM') {
                    $this->currentUpdate->targetplatform = $attrs;
                }

                if ($name === 'PHP_MINIMUM') {
                    $this->currentUpdate->php_minimum = '';
                }

                if ($name === 'SUPPORTED_DATABASES') {
                    $this->currentUpdate->supported_databases = $attrs;
                }
                break;
        }
    }

    /**
     * Character Parser Function
     *
     * @param   object  $parser  Parser object.
     * @param   object  $name    The name of the element.
     *
     * @return  void
     *
     * @since   1.7.0
     */
    protected function _endElement($parser, $name)
    {
        array_pop($this->stack);

        switch ($name) {
            case 'UPDATE':
                // Lower case and remove the exclamation mark
                $product = strtolower(InputFilter::getInstance()->clean(Version::PRODUCT, 'cmd'));

                // Check that the product matches and that the version matches (optionally a regexp)
                if (
                    $product == $this->currentUpdate->targetplatform['NAME']
                    && preg_match('/^' . $this->currentUpdate->targetplatform['VERSION'] . '/', JVERSION)
                ) {
                    // Check if PHP version supported via <php_minimum> tag, assume true if tag isn't present
                    if (!isset($this->currentUpdate->php_minimum) || version_compare(PHP_VERSION, $this->currentUpdate->php_minimum, '>=')) {
                        $phpMatch = true;
                    } else {
                        // Notify the user of the potential update
                        $msg = Text::sprintf(
                            'JLIB_INSTALLER_AVAILABLE_UPDATE_PHP_VERSION',
                            $this->currentUpdate->name,
                            $this->currentUpdate->version,
                            $this->currentUpdate->php_minimum,
                            PHP_VERSION
                        );

                        Factory::getApplication()->enqueueMessage($msg, 'warning');

                        $phpMatch = false;
                    }

                    $dbMatch = false;

                    // Check if DB & version is supported via <supported_databases> tag, assume supported if tag isn't present
                    if (isset($this->currentUpdate->supported_databases)) {
                        $db           = $this->db;
                        $dbType       = strtolower($db->getServerType());
                        $dbVersion    = $db->getVersion();
                        $supportedDbs = $this->currentUpdate->supported_databases;

                        // MySQL and MariaDB use the same database driver but not the same version numbers
                        if ($dbType === 'mysql') {
                            // Check whether we have a MariaDB version string and extract the proper version from it
                            if (stripos($dbVersion, 'mariadb') !== false) {
                                // MariaDB: Strip off any leading '5.5.5-', if present
                                $dbVersion = preg_replace('/^5\.5\.5-/', '', $dbVersion);
                                $dbType    = 'mariadb';
                            }
                        }

                        // $supportedDbs has uppercase keys because they are XML attribute names
                        $dbTypeUcase = strtoupper($dbType);

                        // Do we have an entry for the database?
                        if (\array_key_exists($dbTypeUcase, $supportedDbs)) {
                            $minimumVersion = $supportedDbs[$dbTypeUcase];
                            $dbMatch        = version_compare($dbVersion, $minimumVersion, '>=');

                            if (!$dbMatch) {
                                // Notify the user of the potential update
                                $dbMsg = Text::sprintf(
                                    'JLIB_INSTALLER_AVAILABLE_UPDATE_DB_MINIMUM',
                                    $this->currentUpdate->name,
                                    $this->currentUpdate->version,
                                    Text::_('JLIB_DB_SERVER_TYPE_' . $dbTypeUcase),
                                    $dbVersion,
                                    $minimumVersion
                                );

                                Factory::getApplication()->enqueueMessage($dbMsg, 'warning');
                            }
                        } else {
                            // Notify the user of the potential update
                            $dbMsg = Text::sprintf(
                                'JLIB_INSTALLER_AVAILABLE_UPDATE_DB_TYPE',
                                $this->currentUpdate->name,
                                $this->currentUpdate->version,
                                Text::_('JLIB_DB_SERVER_TYPE_' . $dbTypeUcase)
                            );

                            Factory::getApplication()->enqueueMessage($dbMsg, 'warning');
                        }
                    } else {
                        // Set to true if the <supported_databases> tag is not set
                        $dbMatch = true;
                    }

                    // Check minimum stability
                    $stabilityMatch = true;

                    if (isset($this->currentUpdate->stability) && ($this->currentUpdate->stability < $this->minimum_stability)) {
                        $stabilityMatch = false;
                    }

                    // Some properties aren't valid fields in the update table so unset them to prevent J! from trying to store them
                    unset($this->currentUpdate->targetplatform);

                    if (isset($this->currentUpdate->php_minimum)) {
                        unset($this->currentUpdate->php_minimum);
                    }

                    if (isset($this->currentUpdate->supported_databases)) {
                        unset($this->currentUpdate->supported_databases);
                    }

                    if (isset($this->currentUpdate->stability)) {
                        unset($this->currentUpdate->stability);
                    }

                    // If the PHP version and minimum stability checks pass, consider this version as a possible update
                    if ($phpMatch && $stabilityMatch && $dbMatch) {
                        if (isset($this->latest)) {
                            // We already have a possible update. Check the version.
                            if (version_compare($this->currentUpdate->version, $this->latest->version, '>') == 1) {
                                $this->latest = $this->currentUpdate;
                            }
                        } else {
                            // We don't have any possible updates yet, assume this is an available update.
                            $this->latest = $this->currentUpdate;
                        }
                    }
                }
                break;

            case 'UPDATES':
                // :D
                break;
        }
    }

    /**
     * Character Parser Function
     *
     * @param   object  $parser  Parser object.
     * @param   object  $data    The data.
     *
     * @return  void
     *
     * @note    This is public because its called externally.
     * @since   1.7.0
     */
    protected function _characterData($parser, $data)
    {
        $tag = $this->_getLastTag();

        if (\in_array($tag, $this->updatecols)) {
            $tag = strtolower($tag);
            $this->currentUpdate->$tag .= $data;
        }

        if ($tag === 'PHP_MINIMUM') {
            $this->currentUpdate->php_minimum = $data;
        }

        if ($tag === 'TAG') {
            $this->currentUpdate->stability = $this->stabilityTagToInteger((string) $data);
        }
    }

    /**
     * Finds an update.
     *
     * @param   array  $options  Update options.
     *
     * @return  array|boolean  Array containing the array of update sites and array of updates. False on failure
     *
     * @since   1.7.0
     */
    public function findUpdate($options)
    {
        $response = $this->getUpdateSiteResponse($options);

        if ($response === false) {
            return false;
        }

        /**
         * Unset the latest update which might have been found for a previous update site, avoid
         * strange issue reported at https://github.com/joomla/joomla-cms/issues/46066
         */
        unset($this->latest);

        if (\array_key_exists('minimum_stability', $options)) {
            $this->minimum_stability = $options['minimum_stability'];
        }

        $this->xmlParser = xml_parser_create('');
        xml_set_element_handler($this->xmlParser, [$this, '_startElement'], [$this, '_endElement']);
        xml_set_character_data_handler($this->xmlParser, [$this, '_characterData']);

        if (!xml_parse($this->xmlParser, (string) $response->getBody())) {
            // If the URL is missing the .xml extension, try appending it and retry loading the update
            if (!$this->appendExtension && (!str_ends_with($this->_url, '.xml'))) {
                $options['append_extension'] = true;

                return $this->findUpdate($options);
            }

            $app = Factory::getApplication();
            $app->getLogger()->warning("Error parsing url: {$this->_url}", ['category' => 'updater']);
            $app->enqueueMessage(Text::sprintf('JLIB_UPDATER_ERROR_EXTENSION_PARSE_URL', $this->_url), 'warning');

            return false;
        }

        if (isset($this->latest)) {
            if (isset($this->latest->client) && \strlen($this->latest->client)) {
                /**
                 * The client_id in the update XML manifest can be either an integer (backwards
                 * compatible with Joomla 1.6–3.10) or a string. Backwards compatibility with the
                 * integer key is provided as update servers with the legacy, numeric IDs cause PHP notices
                 * during update retrieval. The proper string key is one of 'site' or 'administrator'.
                 */
                $this->latest->client_id = is_numeric($this->latest->client) ? $this->latest->client
                    : ApplicationHelper::getClientInfo($this->latest->client, true)->id;

                unset($this->latest->client);
            }

            $updates = [$this->latest];
        } else {
            $updates = [];
        }

        return ['update_sites' => [], 'updates' => $updates];
    }

    /**
     * Converts a tag to numeric stability representation. If the tag doesn't represent a known stability level (one of
     * dev, alpha, beta, rc, stable) it is ignored.
     *
     * @param   string  $tag  The tag string, e.g. dev, alpha, beta, rc, stable
     *
     * @return  integer
     *
     * @since   3.4
     */
    protected function stabilityTagToInteger($tag)
    {
        $constant = '\\Joomla\\CMS\\Updater\\Updater::STABILITY_' . strtoupper($tag);

        if (\defined($constant)) {
            return \constant($constant);
        }

        return Updater::STABILITY_STABLE;
    }
}
