<?php

/**
 * @file classes/plugins/PluginGalleryDAO.php
 *
 * Copyright (c) 2014-2021 Simon Fraser University
 * Copyright (c) 2003-2021 John Willinsky
 * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
 *
 * @class PluginGalleryDAO
 *
 * @ingroup plugins
 *
 * @see DAO
 *
 * @brief Operations for retrieving content from the PKP plugin gallery.
 */

namespace PKP\plugins;

use APP\core\Application;
use DOMDocument;
use DOMElement;
use PKP\cache\CacheManager;
use PKP\cache\FileCache;
use PKP\controllers\grid\plugins\PluginGalleryGridHandler;
use PKP\core\PKPApplication;
use PKP\core\PKPString;
use PKP\db\DAORegistry;
use PKP\site\VersionDAO;
use Throwable;

class PluginGalleryDAO extends \PKP\db\DAO
{
    public const PLUGIN_GALLERY_XML_URL = 'https://pkp.sfu.ca/ojs/xml/plugins.xml';

    /**
     * The default timeout (in seconds) to wait plugins.xml request
     *
     * @see https://docs.guzzlephp.org/en/6.5/request-options.html#timeout
     */
    public const DEFAULT_TIMEOUT = 10;

    /**
     * TTL's Cache in seconds
     */
    public const TTL_CACHE_SECONDS = 86400;

    /**
     * Get a set of GalleryPlugin objects describing the available
     * compatible plugins in their newest versions.
     *
     * @param PKPApplication $application
     * @param string $category Optional category name to use as filter
     * @param string $search Optional text to use as filter
     *
     * @return array GalleryPlugin objects
     */
    public function getNewestCompatible($application, $category = null, $search = null)
    {
        $doc = $this->_getDocument();
        $plugins = [];

        foreach ($doc->getElementsByTagName('plugin') as $index => $element) {
            $plugin = $this->_compatibleFromElement($element, $application);
            // May be null if no compatible version exists; also
            // apply search filters if any supplied.
            if (
                $plugin &&
                ($category == '' || $category == PluginGalleryGridHandler::PLUGIN_GALLERY_ALL_CATEGORY_SEARCH_VALUE || $plugin->getCategory() == $category) &&
                ($search == '' || PKPString::strpos(PKPString::strtolower(serialize($plugin)), PKPString::strtolower($search)) !== false)
            ) {
                $plugins[$index] = $plugin;
            }
        }

        return $plugins;
    }

    /**
     * Get the external Plugin XML document
     *
     * @return ?string
     */
    protected function getExternalDocument(): ?string
    {
        $application = Application::get();
        $client = $application->getHttpClient();
        /** @var VersionDAO */
        $versionDao = DAORegistry::getDAO('VersionDAO');
        $currentVersion = $versionDao->getCurrentVersion();
        try {
            $response = $client->request(
                'GET',
                static::PLUGIN_GALLERY_XML_URL,
                [
                    'query' => [
                        'application' => $application->getName(),
                        'version' => $currentVersion->getVersionString()
                    ],
                    'timeout' => self::DEFAULT_TIMEOUT,
                ]
            );

            return $response->getBody();
        } catch (Throwable $e) {
            error_log($e->getMessage());

            return null;
        }
    }

    /**
     * Get the cached Plugin XML document
     *
     * @return ?string
     */
    protected function getCachedDocument(): ?string
    {
        $cacheManager = CacheManager::getManager();
        /** @var FileCache */
        $cache = $cacheManager->getCache(
            'loadPluginsXML',
            Application::CONTEXT_SITE,
            function (FileCache $cache) {
                $cache->setEntireCache($this->getExternalDocument());
            }
        );

        $cacheTime = $cache->getCacheTime();

        // Checking if the cache is older than 1 day, or its null
        if ($cacheTime === null || (time() - $cacheTime > self::TTL_CACHE_SECONDS)) {
            // This cache is out of date; so, lets request a new version.
            $response = $this->getExternalDocument();

            // The plugins.xml request wasnt empty, so lets replace it
            if ($response !== null) {
                $cache->setEntireCache($response);
            }
        }

        return $cache->getContents();
    }

    /**
     * Get the DOM document for the plugin gallery.
     *
     * @return DOMDocument
     */
    private function _getDocument()
    {
        $doc = new DOMDocument('1.0', 'utf-8');
        $doc->loadXML($this->getCachedDocument());

        return $doc;
    }

    /**
     * Construct a new data object.
     *
     * @return GalleryPlugin
     */
    public function newDataObject()
    {
        return new GalleryPlugin();
    }

    /**
     * Build a GalleryPlugin from a DOM element, using the newest compatible
     * release with the supplied Application.
     *
     * @param DOMElement $element
     * @param Application $application
     *
     * @return GalleryPlugin|null, if no compatible plugin was available
     */
    protected function _compatibleFromElement($element, $application)
    {
        $plugin = $this->newDataObject();
        $plugin->setCategory($element->getAttribute('category'));
        $plugin->setProduct($element->getAttribute('product'));
        $doc = $element->ownerDocument;
        $foundRelease = false;
        for ($n = $element->firstChild; $n; $n = $n->nextSibling) {
            if (!($n instanceof DOMElement)) {
                continue;
            }
            switch ($n->tagName) {
                case 'name':
                    $plugin->setName($n->nodeValue, $n->getAttribute('locale'));
                    break;
                case 'homepage':
                    $plugin->setHomepage($n->nodeValue);
                    break;
                case 'description':
                    $plugin->setDescription($n->nodeValue, $n->getAttribute('locale'));
                    break;
                case 'installation':
                    $plugin->setInstallationInstructions($n->nodeValue, $n->getAttribute('locale'));
                    break;
                case 'summary':
                    $plugin->setSummary($n->nodeValue, $n->getAttribute('locale'));
                    break;
                case 'maintainer':
                    $this->_handleMaintainer($n, $plugin);
                    break;
                case 'release':
                    // If a compatible release couldn't be
                    // found, return null.
                    if ($this->_handleRelease($n, $plugin, $application)) {
                        $foundRelease = true;
                    }
                    break;
                default:
                    // Not erroring out here so that future
                    // additions won't break old releases.
            }
        }
        if (!$foundRelease) {
            // No compatible release was found.
            return null;
        }
        return $plugin;
    }

    /**
     * Handle a maintainer element
     *
     * @param GalleryPlugin $plugin
     */
    public function _handleMaintainer($element, $plugin)
    {
        for ($n = $element->firstChild; $n; $n = $n->nextSibling) {
            if (!($n instanceof DOMElement)) {
                continue;
            }
            switch ($n->tagName) {
                case 'name':
                    $plugin->setContactName($n->nodeValue);
                    break;
                case 'institution':
                    $plugin->setContactInstitutionName($n->nodeValue);
                    break;
                case 'email':
                    $plugin->setContactEmail($n->nodeValue);
                    break;
                default:
                    // Not erroring out here so that future
                    // additions won't break old releases.
            }
        }
    }

    /**
     * Handle a release element
     *
     * @param GalleryPlugin $plugin
     * @param PKPApplication $application
     */
    public function _handleRelease($element, $plugin, $application)
    {
        $release = [
            'date' => strtotime($element->getAttribute('date')),
            'version' => $element->getAttribute('version'),
            'md5' => $element->getAttribute('md5'),
        ];

        $compatible = false;
        for ($n = $element->firstChild; $n; $n = $n->nextSibling) {
            if (!($n instanceof DOMElement)) {
                continue;
            }
            switch ($n->tagName) {
                case 'description':
                    $release[$n->tagName][$n->getAttribute('locale')] = $n->nodeValue;
                    break;
                case 'package':
                    $release['package'] = $n->nodeValue;
                    break;
                case 'compatibility':
                    // If a compatible release couldn't be
                    // found, return null.
                    if ($this->_handleCompatibility($n, $plugin, $application)) {
                        $compatible = true;
                    }
                    break;
                case 'certification':
                    $release[$n->tagName][] = $n->getAttribute('type');
                    break;
                default:
                    // Not erroring out here so that future
                    // additions won't break old releases.
            }
        }

        if ($compatible && (!$plugin->getData('version') || version_compare($plugin->getData('version'), $release['version'], '<'))) {
            // This release is newer than the one found earlier, or
            // this is the first compatible release we've found.
            $plugin->setDate($release['date']);
            $plugin->setVersion($release['version']);
            $plugin->setReleaseMD5($release['md5']);
            $plugin->setReleaseDescription($release['description']);
            $plugin->setReleaseCertifications($release['certification'] ?? []);
            $plugin->setReleasePackage($release['package']);
            return true;
        }
        return false;
    }

    /**
     * Handle a compatibility element, fishing out the most recent statement
     * of compatibility.
     *
     * @param GalleryPlugin $plugin
     * @param PKPApplication $application
     *
     * @return bool True iff a compatibility statement matched this app
     */
    public function _handleCompatibility($element, $plugin, $application)
    {
        // Check that the compatibility statement refers to this app
        if ($element->getAttribute('application') != $application->getName()) {
            return false;
        }

        for ($n = $element->firstChild; $n; $n = $n->nextSibling) {
            if (!($n instanceof DOMElement)) {
                continue;
            }
            switch ($n->tagName) {
                case 'version':
                    $installedVersion = $application->getCurrentVersion();
                    if ($installedVersion->isCompatible($n->nodeValue)) {
                        // Compatibility was determined.
                        return true;
                    }
                    break;
            }
        }

        // No applicable compatibility statement found.
        return false;
    }
}

if (!PKP_STRICT_MODE) {
    class_alias('\PKP\plugins\PluginGalleryDAO', '\PluginGalleryDAO');
    define('PLUGIN_GALLERY_XML_URL', PluginGalleryDAO::PLUGIN_GALLERY_XML_URL);
}
