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

use Joomla\CMS\Adapter\Adapter;
use Joomla\CMS\Application\ApplicationHelper;
use Joomla\CMS\Event\Extension\AfterInstallEvent;
use Joomla\CMS\Event\Extension\AfterUninstallEvent;
use Joomla\CMS\Event\Extension\AfterUpdateEvent;
use Joomla\CMS\Event\Extension\BeforeInstallEvent;
use Joomla\CMS\Event\Extension\BeforeUninstallEvent;
use Joomla\CMS\Event\Extension\BeforeUpdateEvent;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Table\Extension;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseAwareInterface;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\Exception\ExecutionFailureException;
use Joomla\Database\ParameterType;
use Joomla\DI\ContainerAwareInterface;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Folder;
use Joomla\Filesystem\Path;

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

/**
 * Joomla base installer class
 *
 * @since  3.1
 */
class Installer extends Adapter implements DatabaseAwareInterface
{
    use DatabaseAwareTrait;

    /**
     * Array of paths needed by the installer
     *
     * @var    array
     * @since  3.1
     */
    protected $paths = [];

    /**
     * True if package is an upgrade
     *
     * @var    boolean
     * @since  3.1
     */
    protected $upgrade = null;

    /**
     * The manifest trigger class
     *
     * @var    object
     * @since  3.1
     */
    public $manifestClass = null;

    /**
     * True if existing files can be overwritten
     *
     * @var    boolean
     * @since  3.0.0
     */
    protected $overwrite = false;

    /**
     * Stack of installation steps
     * - Used for installation rollback
     *
     * @var    array
     * @since  3.1
     */
    protected $stepStack = [];

    /**
     * Extension Table Entry
     *
     * @var    Extension
     * @since  3.1
     */
    public $extension = null;

    /**
     * The output from the install/uninstall scripts
     *
     * @var    string
     * @since  3.1
     * */
    public $message = null;

    /**
     * The installation manifest XML object
     *
     * @var    object
     * @since  3.1
     */
    public $manifest = null;

    /**
     * The extension message that appears
     *
     * @var    string
     * @since  3.1
     */
    protected $extension_message = null;

    /**
     * The redirect URL if this extension (can be null if no redirect)
     *
     * @var    string
     * @since  3.1
     */
    protected $redirect_url = null;

    /**
     * Flag if the uninstall process was triggered by uninstalling a package
     *
     * @var    boolean
     * @since  3.7.0
     */
    protected $packageUninstall = false;

    /**
     * Backup extra_query during update_sites rebuild
     *
     * @var    string
     * @since  3.9.26
     */
    public $extraQuery = '';

    /**
     * JInstaller instances container.
     *
     * @var    Installer[]
     * @since  3.4
     */
    protected static $instances;

    /**
     * A comment marker to indicate that an update SQL query may fail without triggering an update error.
     *
     * @since  4.2.0
     */
    protected const CAN_FAIL_MARKER = '/** CAN FAIL **/';

    /**
     * The length of the CAN_FAIL_MARKER string
     *
     * @since  4.2.0
     */
    protected const CAN_FAIL_MARKER_LENGTH = 16;

    /**
     * Constructor
     *
     * @param   string  $basepath       Base Path of the adapters
     * @param   string  $classprefix    Class prefix of adapters
     * @param   string  $adapterfolder  Name of folder to append to base path
     *
     * @since   3.1
     */
    public function __construct($basepath = __DIR__, $classprefix = '\\Joomla\\CMS\\Installer\\Adapter', $adapterfolder = 'Adapter')
    {
        parent::__construct($basepath, $classprefix, $adapterfolder);

        $this->extension = Table::getInstance('extension');
    }

    /**
     * Returns the global Installer object, only creating it if it doesn't already exist.
     *
     * @param   string  $basepath       Base Path of the adapters
     * @param   string  $classprefix    Class prefix of adapters
     * @param   string  $adapterfolder  Name of folder to append to base path
     *
     * @return  Installer  An installer object
     *
     * @since   3.1
     */
    public static function getInstance($basepath = __DIR__, $classprefix = '\\Joomla\\CMS\\Installer\\Adapter', $adapterfolder = 'Adapter')
    {
        if (!isset(self::$instances[$basepath])) {
            self::$instances[$basepath] = new static($basepath, $classprefix, $adapterfolder);
            self::$instances[$basepath]->setDatabase(Factory::getContainer()->get(DatabaseInterface::class));
        }

        return self::$instances[$basepath];
    }

    /**
     * Splits a string of multiple queries into an array of individual queries.
     *
     * This is different than DatabaseDriver::splitSql. It supports the special CAN FAIL comment
     * marker which indicates that a SQL statement could fail without raising an error during the
     * installation.
     *
     * @param   string|null  $sql  Input SQL string with which to split into individual queries.
     *
     * @return  array
     *
     * @since   4.2.0
     */
    public static function splitSql(?string $sql): array
    {
        if (empty($sql)) {
            return [];
        }

        $start     = 0;
        $open      = false;
        $comment   = false;
        $endString = '';
        $end       = \strlen($sql);
        $queries   = [];
        $query     = '';

        for ($i = 0; $i < $end; $i++) {
            $current      = substr($sql, $i, 1);
            $current2     = substr($sql, $i, 2);
            $current3     = substr($sql, $i, 3);
            $lenEndString = \strlen($endString);
            $testEnd      = substr($sql, $i, $lenEndString);

            if (
                $current === '"' || $current === "'" || $current2 === '--'
                || ($current2 === '/*' && $current3 !== '/*!' && $current3 !== '/*+')
                || ($current === '#' && $current3 !== '#__')
                || ($comment && $testEnd === $endString)
            ) {
                // Check if quoted with previous backslash
                $n = 2;

                while (substr($sql, $i - $n + 1, 1) === '\\' && $n < $i) {
                    $n++;
                }

                // Not quoted
                if ($n % 2 === 0) {
                    if ($open) {
                        if ($testEnd === $endString) {
                            if ($comment) {
                                $comment = false;

                                if ($lenEndString > 1) {
                                    $i += ($lenEndString - 1);
                                    $current = substr($sql, $i, 1);
                                }

                                $start = $i + 1;
                            }

                            $open      = false;
                            $endString = '';
                        }
                    } else {
                        $open = true;

                        if ($current2 === '--') {
                            $endString = "\n";
                            $comment   = true;
                        } elseif ($current2 === '/*') {
                            $endString = '*/';
                            $comment   = true;
                        } elseif ($current === '#') {
                            $endString = "\n";
                            $comment   = true;
                        } else {
                            $endString = $current;
                        }

                        if ($comment && $start < $i) {
                            $query .= substr($sql, $start, $i - $start);
                        }
                    }
                }
            }

            if ($comment) {
                $start = $i + 1;
            }

            if (($current === ';' && !$open) || $i === $end - 1) {
                if ($current === ';' && !$open && $start <= $i && $start > self::CAN_FAIL_MARKER_LENGTH) {
                    $possibleMarker = substr($sql, $start - self::CAN_FAIL_MARKER_LENGTH, $i - $start + self::CAN_FAIL_MARKER_LENGTH);

                    if (strtoupper($possibleMarker) === self::CAN_FAIL_MARKER) {
                        $start -= self::CAN_FAIL_MARKER_LENGTH;
                    }
                }

                if ($start <= $i) {
                    $query .= substr($sql, $start, $i - $start + 1);
                }

                $query = trim($query);

                if ($query) {
                    if (($i === $end - 1) && ($current !== ';')) {
                        $query .= ';';
                    }

                    $queries[] = $query;
                }

                $query = '';
                $start = $i + 1;
            }

            $endComment = false;
        }

        return $queries;
    }

    /**
     * Get the allow overwrite switch
     *
     * @return  boolean  Allow overwrite switch
     *
     * @since   3.1
     */
    public function isOverwrite()
    {
        return $this->overwrite;
    }

    /**
     * Set the allow overwrite switch
     *
     * @param   boolean  $state  Overwrite switch state
     *
     * @return  boolean  True it state is set, false if it is not
     *
     * @since   3.1
     */
    public function setOverwrite($state = false)
    {
        $tmp = $this->overwrite;

        if ($state) {
            $this->overwrite = true;
        } else {
            $this->overwrite = false;
        }

        return $tmp;
    }

    /**
     * Get the redirect location
     *
     * @return  string  Redirect location (or null)
     *
     * @since   3.1
     */
    public function getRedirectUrl()
    {
        return $this->redirect_url;
    }

    /**
     * Set the redirect location
     *
     * @param   string  $newurl  New redirect location
     *
     * @return  void
     *
     * @since   3.1
     */
    public function setRedirectUrl($newurl)
    {
        $this->redirect_url = $newurl;
    }

    /**
     * Get whether this installer is uninstalling extensions which are part of a package
     *
     * @return  boolean
     *
     * @since   3.7.0
     */
    public function isPackageUninstall()
    {
        return $this->packageUninstall;
    }

    /**
     * Set whether this installer is uninstalling extensions which are part of a package
     *
     * @param   boolean  $uninstall  True if a package triggered the uninstall, false otherwise
     *
     * @return  void
     *
     * @since   3.7.0
     */
    public function setPackageUninstall($uninstall)
    {
        $this->packageUninstall = $uninstall;
    }

    /**
     * Get the upgrade switch
     *
     * @return  boolean
     *
     * @since   3.1
     */
    public function isUpgrade()
    {
        return $this->upgrade;
    }

    /**
     * Set the upgrade switch
     *
     * @param   boolean  $state  Upgrade switch state
     *
     * @return  boolean  True if upgrade, false otherwise
     *
     * @since   3.1
     */
    public function setUpgrade($state = false)
    {
        $tmp = $this->upgrade;

        if ($state) {
            $this->upgrade = true;
        } else {
            $this->upgrade = false;
        }

        return $tmp;
    }

    /**
     * Get the installation manifest object
     *
     * @return  \SimpleXMLElement  Manifest object
     *
     * @since   3.1
     */
    public function getManifest()
    {
        if (!\is_object($this->manifest)) {
            $this->findManifest();
        }

        return $this->manifest;
    }

    /**
     * Get an installer path by name
     *
     * @param   string  $name     Path name
     * @param   string  $default  Default value
     *
     * @return  string  Path
     *
     * @since   3.1
     */
    public function getPath($name, $default = null)
    {
        return (!empty($this->paths[$name])) ? $this->paths[$name] : $default;
    }

    /**
     * Sets an installer path by name
     *
     * @param   string  $name   Path name
     * @param   string  $value  Path
     *
     * @return  void
     *
     * @since   3.1
     */
    public function setPath($name, $value)
    {
        $this->paths[$name] = $value;
    }

    /**
     * Pushes a step onto the installer stack for rolling back steps
     *
     * @param   array  $step  Installer step
     *
     * @return  void
     *
     * @since   3.1
     */
    public function pushStep($step)
    {
        $this->stepStack[] = $step;
    }

    /**
     * Installation abort method
     *
     * @param   string  $msg   Abort message from the installer
     * @param   string  $type  Package type if defined
     *
     * @return  boolean  True if successful
     *
     * @since   3.1
     */
    public function abort($msg = null, $type = null)
    {
        $retval = true;
        $step   = array_pop($this->stepStack);

        // Raise abort warning
        if ($msg) {
            Log::add($msg, Log::WARNING, 'jerror');
        }

        while ($step != null) {
            switch ($step['type']) {
                case 'file':
                    // Remove the file
                    if (is_file($step['path']) && !($stepval = File::delete($step['path']))) {
                        Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_FILE_FOLDER', $step['path']), Log::WARNING, 'jerror');
                    }
                    break;

                case 'folder':
                    // Remove the folder
                    if (is_dir($step['path']) && !($stepval = Folder::delete($step['path']))) {
                        Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_FILE_FOLDER', $step['path']), Log::WARNING, 'jerror');
                    }
                    break;

                case 'query':
                    // Execute the query.
                    $stepval = $this->parseSQLFiles($step['script']);
                    break;

                case 'extension':
                    // Get database connector object
                    $db     = $this->getDatabase();
                    $query  = $db->getQuery(true);
                    $stepId = (int) $step['id'];

                    // Remove the entry from the #__extensions table
                    $query->delete($db->quoteName('#__extensions'))
                        ->where($db->quoteName('extension_id') . ' = :step_id')
                        ->bind(':step_id', $stepId, ParameterType::INTEGER);
                    $db->setQuery($query);

                    try {
                        $db->execute();

                        $stepval = true;
                    } catch (ExecutionFailureException) {
                        // The database API will have already logged the error it caught, we just need to alert the user to the issue
                        Log::add(Text::_('JLIB_INSTALLER_ABORT_ERROR_DELETING_EXTENSIONS_RECORD'), Log::WARNING, 'jerror');

                        $stepval = false;
                    }

                    break;

                default:
                    if ($type && \is_object($this->_adapters[$type])) {
                        // Build the name of the custom rollback method for the type
                        $method = '_rollback_' . $step['type'];

                        // Custom rollback method handler
                        if (method_exists($this->_adapters[$type], $method)) {
                            $stepval = $this->_adapters[$type]->$method($step);
                        }
                    } else {
                        // Set it to false
                        $stepval = false;
                    }
                    break;
            }

            // Only set the return value if it is false
            if ($stepval === false) {
                $retval = false;
            }

            // Get the next step and continue
            $step = array_pop($this->stepStack);
        }

        return $retval;
    }

    // Adapter functions

    /**
     * Package installation method
     *
     * @param   string  $path  Path to package source folder
     *
     * @return  boolean  True if successful
     *
     * @since   3.1
     */
    public function install($path = null)
    {
        if ($path && is_dir(Path::clean($path))) {
            $this->setPath('source', $path);
        } else {
            $this->abort(Text::_('JLIB_INSTALLER_ABORT_NOINSTALLPATH'));

            return false;
        }

        if (!$adapter = $this->setupInstall('install', true)) {
            $this->abort(Text::_('JLIB_INSTALLER_ABORT_DETECTMANIFEST'));

            return false;
        }

        if (!\is_object($adapter)) {
            return false;
        }

        // Add the languages from the package itself
        if (method_exists($adapter, 'loadLanguage')) {
            $adapter->loadLanguage($path);
        }

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

        // Fire the onExtensionBeforeInstall event.
        PluginHelper::importPlugin('extension', null, true, $dispatcher);
        $dispatcher->dispatch('onExtensionBeforeInstall', new BeforeInstallEvent('onExtensionBeforeInstall', [
            'method'    => 'install',
            'type'      => $this->manifest->attributes()->type,
            'manifest'  => $this->manifest,
            'extension' => 0,
        ]));

        // Run the install
        $result = $adapter->install();

        // Make sure Joomla can figure out what has changed
        clearstatcache();

        // Fire the onExtensionAfterInstall
        $dispatcher->dispatch('onExtensionAfterInstall', new AfterInstallEvent('onExtensionAfterInstall', [
            'installer' => clone $this,
            'eid'       => $result,
        ]));

        if ($result !== false) {
            // Refresh versionable assets cache
            Factory::getApplication()->flushAssets();

            return true;
        }

        return false;
    }

    /**
     * Discovered package installation method
     *
     * @param   integer  $eid  Extension ID
     *
     * @return  boolean  True if successful
     *
     * @since   3.1
     */
    public function discover_install($eid = null)
    {
        if (!$eid) {
            $this->abort(Text::_('JLIB_INSTALLER_ABORT_EXTENSIONNOTVALID'));

            return false;
        }

        if (!$this->extension->load($eid)) {
            $this->abort(Text::_('JLIB_INSTALLER_ABORT_LOAD_DETAILS'));

            return false;
        }

        if ($this->extension->state != -1) {
            $this->abort(Text::_('JLIB_INSTALLER_ABORT_ALREADYINSTALLED'));

            return false;
        }

        // Load the adapter(s) for the install manifest
        $type   = $this->extension->type;
        $params = ['extension' => $this->extension, 'route' => 'discover_install'];

        $adapter = $this->loadAdapter($type, $params);

        if (!\is_object($adapter)) {
            return false;
        }

        if (!method_exists($adapter, 'discover_install') || !$adapter->getDiscoverInstallSupported()) {
            $this->abort(Text::sprintf('JLIB_INSTALLER_ERROR_DISCOVER_INSTALL_UNSUPPORTED', $type));

            return false;
        }

        // The adapter needs to prepare itself
        if (method_exists($adapter, 'prepareDiscoverInstall')) {
            try {
                $adapter->prepareDiscoverInstall();
            } catch (\RuntimeException $e) {
                $this->abort($e->getMessage());

                return false;
            }
        }

        // Add the languages from the package itself
        if (method_exists($adapter, 'loadLanguage')) {
            $adapter->loadLanguage();
        }

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

        // Fire the onExtensionBeforeInstall event.
        PluginHelper::importPlugin('extension', null, true, $dispatcher);
        $dispatcher->dispatch('onExtensionBeforeInstall', new BeforeInstallEvent('onExtensionBeforeInstall', [
            'method'    => 'discover_install',
            'type'      => $this->extension->type,
            'manifest'  => null,
            'extension' => (int) $this->extension->extension_id,
        ]));

        // Run the install
        $result = $adapter->discover_install();

        // Fire the onExtensionAfterInstall
        $dispatcher->dispatch('onExtensionAfterInstall', new AfterInstallEvent('onExtensionAfterInstall', [
            'installer' => clone $this,
            'eid'       => $result,
        ]));

        if ($result !== false) {
            // Refresh versionable assets cache
            Factory::getApplication()->flushAssets();

            return true;
        }

        return false;
    }

    /**
     * Extension discover method
     *
     * Asks each adapter to find extensions
     *
     * @return  InstallerExtension[]
     *
     * @since   3.1
     */
    public function discover()
    {
        $results = [];

        foreach ($this->getAdapters() as $adapter) {
            $instance = $this->loadAdapter($adapter);

            // Joomla! 1.5 installation adapter legacy support
            if (method_exists($instance, 'discover')) {
                $tmp = $instance->discover();

                // If its an array and has entries
                if (\is_array($tmp) && \count($tmp)) {
                    // Merge it into the system
                    $results = array_merge($results, $tmp);
                }
            }
        }

        return $results;
    }

    /**
     * Package update method
     *
     * @param   string  $path  Path to package source folder
     *
     * @return  boolean  True if successful
     *
     * @since   3.1
     */
    public function update($path = null)
    {
        if ($path && is_dir(Path::clean($path))) {
            $this->setPath('source', $path);
        } else {
            $this->abort(Text::_('JLIB_INSTALLER_ABORT_NOUPDATEPATH'));

            return false;
        }

        if (!$adapter = $this->setupInstall('update', true)) {
            $this->abort(Text::_('JLIB_INSTALLER_ABORT_DETECTMANIFEST'));

            return false;
        }

        if (!\is_object($adapter)) {
            return false;
        }

        // Add the languages from the package itself
        if (method_exists($adapter, 'loadLanguage')) {
            $adapter->loadLanguage($path);
        }

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

        // Fire the onExtensionBeforeUpdate event.
        PluginHelper::importPlugin('extension', null, true, $dispatcher);
        $dispatcher->dispatch('onExtensionBeforeUpdate', new BeforeUpdateEvent('onExtensionBeforeUpdate', [
            'type'     => $this->manifest->attributes()->type,
            'manifest' => $this->manifest,
        ]));

        // Run the update
        $result = $adapter->update();

        // Fire the onExtensionAfterUpdate
        $dispatcher->dispatch('onExtensionAfterUpdate', new AfterUpdateEvent('onExtensionAfterUpdate', [
            'installer' => clone $this,
            'eid'       => $result,
        ]));

        if ($result !== false) {
            return true;
        }

        return false;
    }

    /**
     * Package uninstallation method
     *
     * @param   string  $type        Package type
     * @param   mixed   $identifier  Package identifier for adapter
     *
     * @return  boolean  True if successful
     *
     * @since   3.1
     */
    public function uninstall($type, $identifier)
    {
        $params = ['extension' => $this->extension, 'route' => 'uninstall'];

        $adapter = $this->loadAdapter($type, $params);

        if (!\is_object($adapter)) {
            return false;
        }

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

        // We don't load languages here, we get the extension adapter to work it out
        // Fire the onExtensionBeforeUninstall event.
        PluginHelper::importPlugin('extension', null, true, $dispatcher);
        $dispatcher->dispatch('onExtensionBeforeUninstall', new BeforeUninstallEvent('onExtensionBeforeUninstall', [
            'eid' => (int) $identifier,
        ]));

        // Run the uninstall
        $result = $adapter->uninstall($identifier);

        // Fire the onExtensionAfterInstall
        $dispatcher->dispatch('onExtensionAfterUninstall', new AfterUninstallEvent('onExtensionAfterUninstall', [
            'installer' => clone $this,
            'eid'       => (int) $identifier,
            'removed'   => $result,
        ]));

        // Refresh versionable assets cache
        Factory::getApplication()->flushAssets();

        return $result;
    }

    /**
     * Refreshes the manifest cache stored in #__extensions
     *
     * @param   integer  $eid  Extension ID
     *
     * @return  boolean
     *
     * @since   3.1
     */
    public function refreshManifestCache($eid)
    {
        if ($eid) {
            if (!$this->extension->load($eid)) {
                $this->abort(Text::_('JLIB_INSTALLER_ABORT_LOAD_DETAILS'));

                return false;
            }

            if ($this->extension->state == -1) {
                $this->abort(Text::sprintf('JLIB_INSTALLER_ABORT_REFRESH_MANIFEST_CACHE', $this->extension->name));

                return false;
            }

            // Fetch the adapter
            $adapter = $this->loadAdapter($this->extension->type);

            if (!\is_object($adapter)) {
                return false;
            }

            if (!method_exists($adapter, 'refreshManifestCache')) {
                $this->abort(Text::sprintf('JLIB_INSTALLER_ABORT_METHODNOTSUPPORTED_TYPE', $this->extension->type));

                return false;
            }

            $result = $adapter->refreshManifestCache();

            if ($result !== false) {
                return true;
            }

            return false;
        }

        $this->abort(Text::_('JLIB_INSTALLER_ABORT_REFRESH_MANIFEST_CACHE_VALID'));

        return false;
    }

    // Utility functions

    /**
     * Prepare for installation: this method sets the installation directory, finds
     * and checks the installation file and verifies the installation type.
     *
     * @param   string   $route          The install route being followed
     * @param   boolean  $returnAdapter  Flag to return the instantiated adapter
     *
     * @return  boolean|InstallerAdapter  InstallerAdapter object if explicitly requested otherwise boolean
     *
     * @since   3.1
     */
    public function setupInstall($route = 'install', $returnAdapter = false)
    {
        // We need to find the installation manifest file
        if (!$this->findManifest()) {
            return false;
        }

        // Load the adapter(s) for the install manifest
        $type   = (string) $this->manifest->attributes()->type;
        $params = ['route' => $route, 'manifest' => $this->getManifest()];

        // Load the adapter
        $adapter = $this->loadAdapter($type, $params);

        if ($returnAdapter) {
            return $adapter;
        }

        return true;
    }

    /**
     * Backward compatible method to parse through a queries element of the
     * installation manifest file and take appropriate action.
     *
     * @param   \SimpleXMLElement  $element  The XML node to process
     *
     * @return  mixed  Number of queries processed or False on error
     *
     * @since   3.1
     */
    public function parseQueries(\SimpleXMLElement $element)
    {
        // Get the database connector object
        $db = & $this->_db;

        if (!$element || !\count($element->children())) {
            // Either the tag does not exist or has no children therefore we return zero files processed.
            return 0;
        }

        // Get the array of query nodes to process
        $queries = $element->children();

        if (\count($queries) === 0) {
            // No queries to process
            return 0;
        }

        $update_count = 0;

        // Process each query in the $queries array (children of $tagName).
        foreach ($queries as $query) {
            try {
                $db->setQuery($query)->execute();
            } catch (ExecutionFailureException $e) {
                Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_SQL_ERROR', $e->getMessage()), Log::WARNING, 'jerror');

                return false;
            }

            $update_count++;
        }

        return $update_count;
    }

    /**
     * Method to extract the name of a discreet installation sql file from the installation manifest file.
     *
     * @param   object  $element  The XML node to process
     *
     * @return  mixed  Number of queries processed or False on error
     *
     * @since   3.1
     */
    public function parseSQLFiles($element)
    {
        if (!$element || !\count($element->children())) {
            // The tag does not exist.
            return 0;
        }

        $db          = &$this->_db;
        $dbDriver    = $db->getServerType();
        $updateCount = 0;

        // Get the name of the sql file to process
        foreach ($element->children() as $file) {
            $fCharset = strtolower($file->attributes()->charset) === 'utf8' ? 'utf8' : '';
            $fDriver  = strtolower($file->attributes()->driver);

            if ($fDriver === 'mysqli' || $fDriver === 'pdomysql') {
                $fDriver = 'mysql';
            } elseif ($fDriver === 'pgsql') {
                $fDriver = 'postgresql';
            }

            if ($fCharset !== 'utf8' || $fDriver != $dbDriver) {
                continue;
            }

            $sqlfile = $this->getPath('extension_root') . '/' . trim($file);

            // Check that sql files exists before reading. Otherwise raise error for rollback
            if (!file_exists($sqlfile)) {
                Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_SQL_FILENOTFOUND', $sqlfile), Log::WARNING, 'jerror');

                return false;
            }

            $buffer = file_get_contents($sqlfile);

            // Graceful exit and rollback if read not successful
            if ($buffer === false) {
                Log::add(Text::_('JLIB_INSTALLER_ERROR_SQL_READBUFFER'), Log::WARNING, 'jerror');

                return false;
            }

            // Create an array of queries from the sql file
            $queries = self::splitSql($buffer);

            if (\count($queries) === 0) {
                // No queries to process
                continue;
            }

            // Process each query in the $queries array (split out of sql file).
            foreach ($queries as $query) {
                $canFail = \strlen($query) > self::CAN_FAIL_MARKER_LENGTH + 1 &&
                    strtoupper(substr($query, -self::CAN_FAIL_MARKER_LENGTH - 1)) === (self::CAN_FAIL_MARKER . ';');
                $query   = $canFail ? (substr($query, 0, -self::CAN_FAIL_MARKER_LENGTH - 1) . ';') : $query;

                try {
                    $db->setQuery($query)->execute();
                } catch (ExecutionFailureException $e) {
                    if (!$canFail) {
                        Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_SQL_ERROR', $e->getMessage()), Log::WARNING, 'jerror');

                        return false;
                    }
                }

                $updateCount++;
            }
        }

        return $updateCount;
    }

    /**
     * Set the schema version for an extension by looking at its latest update
     *
     * @param   \SimpleXMLElement  $schema  Schema Tag
     * @param   integer            $eid     Extension ID
     *
     * @return  void
     *
     * @since   3.1
     */
    public function setSchemaVersion(\SimpleXMLElement $schema, $eid)
    {
        if ($eid && $schema) {
            $db          = $this->getDatabase();
            $schemapaths = $schema->children();

            if (!$schemapaths) {
                return;
            }

            if (\count($schemapaths)) {
                $dbDriver = $db->getServerType();

                $schemapath = '';

                foreach ($schemapaths as $entry) {
                    $attrs = $entry->attributes();

                    if ($attrs['type'] == $dbDriver) {
                        $schemapath = $entry;
                        break;
                    }
                }

                if ($schemapath !== '') {
                    $files = str_replace('.sql', '', Folder::files($this->getPath('extension_root') . '/' . $schemapath, '\.sql$'));
                    usort($files, 'version_compare');

                    // Update the database
                    $query = $db->getQuery(true)
                        ->delete('#__schemas')
                        ->where('extension_id = :extension_id')
                        ->bind(':extension_id', $eid, ParameterType::INTEGER);
                    $db->setQuery($query);

                    if ($db->execute()) {
                        $schemaVersion = end($files);

                        $query->clear()
                            ->insert($db->quoteName('#__schemas'))
                            ->columns([$db->quoteName('extension_id'), $db->quoteName('version_id')])
                            ->values(':extension_id, :version_id')
                            ->bind(':extension_id', $eid, ParameterType::INTEGER)
                            ->bind(':version_id', $schemaVersion);
                        $db->setQuery($query);
                        $db->execute();
                    }
                }
            }
        }
    }

    /**
     * Method to process the updates for an item
     *
     * @param   \SimpleXMLElement  $schema  The XML node to process
     * @param   integer            $eid     Extension Identifier
     *
     * @return  boolean|int  Number of SQL updates executed; false on failure.
     *
     * @since   3.1
     */
    public function parseSchemaUpdates(\SimpleXMLElement $schema, $eid)
    {
        $updateCount = 0;

        // Ensure we have an XML element and a valid extension id
        if (!$eid || !$schema) {
            return $updateCount;
        }

        $db          = $this->getDatabase();
        $schemapaths = $schema->children();

        if (!\count($schemapaths)) {
            return $updateCount;
        }

        $dbDriver = $db->getServerType();

        $schemapath = '';

        foreach ($schemapaths as $entry) {
            $attrs = $entry->attributes();

            // Assuming that the type is a mandatory attribute but if it is not mandatory then there should be a discussion for it.
            $uDriver = strtolower($attrs['type']);

            if ($uDriver === 'mysqli' || $uDriver === 'pdomysql') {
                $uDriver = 'mysql';
            } elseif ($uDriver === 'pgsql') {
                $uDriver = 'postgresql';
            }

            if ($uDriver == $dbDriver) {
                $schemapath = $entry;
                break;
            }
        }

        if ($schemapath === '') {
            return $updateCount;
        }

        $files = Folder::files($this->getPath('extension_root') . '/' . $schemapath, '\.sql$');

        if (empty($files)) {
            return $updateCount;
        }

        Log::add(Text::_('JLIB_INSTALLER_SQL_BEGIN'), Log::INFO, 'Update');

        $files = str_replace('.sql', '', $files);
        usort($files, 'version_compare');

        $query = $db->getQuery(true)
            ->select('version_id')
            ->from('#__schemas')
            ->where('extension_id = :extension_id')
            ->bind(':extension_id', $eid, ParameterType::INTEGER);
        $db->setQuery($query);

        $hasVersion = true;

        try {
            $version = $db->loadResult();

            // No version - use initial version.
            if (!$version) {
                $version    = '0.0.0';
                $hasVersion = false;
            }
        } catch (ExecutionFailureException $e) {
            $version = '0.0.0';
        }

        Log::add(Text::sprintf('JLIB_INSTALLER_SQL_BEGIN_SCHEMA', $version), Log::INFO, 'Update');

        foreach ($files as $file) {
            // Skip over files earlier or equal to the latest schema version recorded for this extension.
            if (version_compare($file, $version) <= 0) {
                continue;
            }

            $buffer = file_get_contents(\sprintf("%s/%s/%s.sql", $this->getPath('extension_root'), $schemapath, $file));

            // Graceful exit and rollback if read not successful
            if ($buffer === false) {
                Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_SQL_READBUFFER'), Log::WARNING, 'jerror');

                return false;
            }

            // Create an array of queries from the sql file
            $queries = self::splitSql($buffer);

            // Process each query in the $queries array (split out of sql file).
            foreach ($queries as $query) {
                $canFail = \strlen($query) > self::CAN_FAIL_MARKER_LENGTH + 1 &&
                    strtoupper(substr($query, -self::CAN_FAIL_MARKER_LENGTH - 1)) === (self::CAN_FAIL_MARKER . ';');
                $query   = $canFail ? (substr($query, 0, -self::CAN_FAIL_MARKER_LENGTH - 1) . ';') : $query;

                $queryString = (string) $query;
                $queryString = str_replace(["\r", "\n"], ['', ' '], substr($queryString, 0, 80));

                try {
                    $db->setQuery($query)->execute();
                } catch (\RuntimeException $e) {
                    if (!$canFail) {
                        $errorMessage = Text::sprintf('JLIB_INSTALLER_ERROR_SQL_ERROR', $e->getMessage());

                        // Log the error in the update log file
                        Log::add(Text::sprintf('JLIB_INSTALLER_UPDATE_LOG_QUERY', $file, $queryString), Log::INFO, 'Update');
                        Log::add($errorMessage, Log::INFO, 'Update');
                        Log::add(Text::_('JLIB_INSTALLER_SQL_END_NOT_COMPLETE'), Log::INFO, 'Update');

                        // Show the error message to the user
                        Log::add($errorMessage, Log::WARNING, 'jerror');

                        return false;
                    }
                }

                Log::add(Text::sprintf('JLIB_INSTALLER_UPDATE_LOG_QUERY', $file, $queryString), Log::INFO, 'Update');

                $updateCount++;
            }

            // Update the schema version for this extension
            try {
                $this->updateSchemaTable($eid, $file, $hasVersion);
                $hasVersion = true;
            } catch (ExecutionFailureException $e) {
                Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_SQL_ERROR', $e->getMessage()), Log::WARNING, 'jerror');

                return false;
            }
        }

        Log::add(Text::_('JLIB_INSTALLER_SQL_END'), Log::INFO, 'Update');

        return $updateCount;
    }

    /**
     * Update the schema table with the latest version
     *
     * @param   int     $eid      Extension ID.
     * @param   string  $version  Latest schema version ID.
     * @param   boolean $update   Should I run an update against an existing record or insert a new one?
     *
     * @return  void
     *
     * @since   4.2.0
     */
    protected function updateSchemaTable(int $eid, string $version, bool $update = false): void
    {
        $db = $this->getDatabase();

        $o = (object) [
            'extension_id' => $eid,
            'version_id'   => $version,
        ];

        try {
            if ($update) {
                $db->updateObject('#__schemas', $o, 'extension_id');
            } else {
                $db->insertObject('#__schemas', $o);
            }
        } catch (ExecutionFailureException) {
            /**
             * Safe fallback: delete any existing record and insert afresh.
             *
             * It is possible that the schema version may be populated after we detected it does not
             * exist (or removed after we detected it exists) and before we finish executing the SQL
             * update script. This could happen e.g. if the update SQL script messes with it, or if
             * another process is also tinkering with the #__schemas table.
             *
             * The safe fallback below even runs inside a transaction to prevent interference from
             * another process.
             */
            $db->transactionStart();

            $query = $db->getQuery(true)
                ->delete('#__schemas')
                ->where('extension_id = :extension_id')
                ->bind(':extension_id', $eid, ParameterType::INTEGER);

            $db->setQuery($query)->execute();

            $db->insertObject('#__schemas', $o);

            $db->transactionCommit();
        }
    }

    /**
     * Method to parse through a files element of the installation manifest and take appropriate
     * action.
     *
     * @param   \SimpleXMLElement  $element   The XML node to process
     * @param   integer            $cid       Application ID of application to install to
     * @param   array              $oldFiles  List of old files (SimpleXMLElement's)
     * @param   array              $oldMD5    List of old MD5 sums (indexed by filename with value as MD5)
     *
     * @return  boolean      True on success
     *
     * @since   3.1
     */
    public function parseFiles(\SimpleXMLElement $element, $cid = 0, $oldFiles = null, $oldMD5 = null)
    {
        // Get the array of file nodes to process; we checked whether this had children above.
        if (!$element || !\count($element->children())) {
            // Either the tag does not exist or has no children (hence no files to process) therefore we return zero files processed.
            return 0;
        }

        $copyfiles = [];

        // Get the client info
        $client = ApplicationHelper::getClientInfo($cid);

        /*
         * Here we set the folder we are going to remove the files from.
         */
        if ($client) {
            $pathname    = 'extension_' . $client->name;
            $destination = $this->getPath($pathname);
        } else {
            $pathname    = 'extension_root';
            $destination = $this->getPath($pathname);
        }

        /*
         * Here we set the folder we are going to copy the files from.
         *
         * Does the element have a folder attribute?
         *
         * If so this indicates that the files are in a subdirectory of the source
         * folder and we should append the folder attribute to the source path when
         * copying files.
         */

        $folder = (string) $element->attributes()->folder;

        if ($folder && file_exists($this->getPath('source') . '/' . $folder)) {
            $source = $this->getPath('source') . '/' . $folder;
        } else {
            $source = $this->getPath('source');
        }

        // Work out what files have been deleted
        if ($oldFiles && ($oldFiles instanceof \SimpleXMLElement)) {
            $oldEntries = $oldFiles->children();

            if (\count($oldEntries)) {
                $deletions = $this->findDeletedFiles($oldEntries, $element->children());

                foreach ($deletions['folders'] as $deleted_folder) {
                    $folder = $destination . '/' . $deleted_folder;

                    if (is_dir($folder) && !Folder::delete($folder)) {
                        Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_FILE_FOLDER', $folder), Log::WARNING, 'jerror');
                    }
                }

                foreach ($deletions['files'] as $deleted_file) {
                    $file = $destination . '/' . $deleted_file;

                    if (is_file($file) && !File::delete($file)) {
                        Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_FILE_FOLDER', $file), Log::WARNING, 'jerror');
                    }
                }
            }
        }

        $path = [];

        // Copy the MD5SUMS file if it exists
        if (file_exists($source . '/MD5SUMS')) {
            $path['src']  = $source . '/MD5SUMS';
            $path['dest'] = $destination . '/MD5SUMS';
            $path['type'] = 'file';
            $copyfiles[]  = $path;
        }

        // Process each file in the $files array (children of $tagName).
        foreach ($element->children() as $file) {
            $path['src']  = $source . '/' . $file;
            $path['dest'] = $destination . '/' . $file;

            // Is this path a file or folder?
            $path['type'] = $file->getName() === 'folder' ? 'folder' : 'file';

            /*
             * Before we can add a file to the copyfiles array we need to ensure
             * that the folder we are copying our file to exists and if it doesn't,
             * we need to create it.
             */

            if (basename($path['dest']) !== $path['dest']) {
                $newdir = \dirname($path['dest']);

                if (!Folder::create($newdir)) {
                    Log::add(
                        Text::sprintf(
                            'JLIB_INSTALLER_ABORT_CREATE_DIRECTORY',
                            Text::_('JLIB_INSTALLER_INSTALL'),
                            $newdir
                        ),
                        Log::WARNING,
                        'jerror'
                    );

                    return false;
                }
            }

            // Add the file to the copyfiles array
            $copyfiles[] = $path;
        }

        return $this->copyFiles($copyfiles);
    }

    /**
     * Method to parse through a languages element of the installation manifest and take appropriate
     * action.
     *
     * @param   \SimpleXMLElement  $element  The XML node to process
     * @param   integer            $cid      Application ID of application to install to
     *
     * @return  boolean  True on success
     *
     * @since   3.1
     */
    public function parseLanguages(\SimpleXMLElement $element, $cid = 0)
    {
        // @todo: work out why the below line triggers 'node no longer exists' errors with files
        if (!$element || !\count($element->children())) {
            // Either the tag does not exist or has no children therefore we return zero files processed.
            return 0;
        }

        $copyfiles = [];

        // Get the client info
        $client = ApplicationHelper::getClientInfo($cid);

        // Here we set the folder we are going to copy the files to.
        // 'languages' Files are copied to JPATH_BASE/language/ folder

        $destination = $client->path . '/language';

        /*
         * Here we set the folder we are going to copy the files from.
         *
         * Does the element have a folder attribute?
         *
         * If so this indicates that the files are in a subdirectory of the source
         * folder and we should append the folder attribute to the source path when
         * copying files.
         */

        $folder = (string) $element->attributes()->folder;

        if ($folder && file_exists($this->getPath('source') . '/' . $folder)) {
            $source = $this->getPath('source') . '/' . $folder;
        } else {
            $source = $this->getPath('source');
        }

        // Process each file in the $files array (children of $tagName).
        foreach ($element->children() as $file) {
            /*
             * Language files go in a subfolder based on the language code, ie.
             * <language tag="en-US">en-US.mycomponent.ini</language>
             * would go in the en-US subdirectory of the language folder.
             */

            // We will only install language files where a core language pack
            // already exists.

            if ((string) $file->attributes()->tag !== '') {
                $path        = [];
                $path['src'] = $source . '/' . $file;

                if ((string) $file->attributes()->client !== '') {
                    // Override the client
                    $langclient   = ApplicationHelper::getClientInfo((string) $file->attributes()->client, true);
                    $path['dest'] = $langclient->path . '/language/' . $file->attributes()->tag . '/' . basename((string) $file);
                } else {
                    // Use the default client
                    $path['dest'] = $destination . '/' . $file->attributes()->tag . '/' . basename((string) $file);
                }

                // If the language folder is not present, then the core pack hasn't been installed... ignore
                if (!is_dir(Path::clean(\dirname($path['dest'])))) {
                    continue;
                }
            } else {
                $path         = [];
                $path['src']  = $source . '/' . $file;
                $path['dest'] = $destination . '/' . $file;
            }

            /*
             * Before we can add a file to the copyfiles array we need to ensure
             * that the folder we are copying our file to exists and if it doesn't,
             * we need to create it.
             */

            if (basename($path['dest']) !== $path['dest']) {
                $newdir = \dirname($path['dest']);

                if (!Folder::create($newdir)) {
                    Log::add(
                        Text::sprintf(
                            'JLIB_INSTALLER_ABORT_CREATE_DIRECTORY',
                            Text::_('JLIB_INSTALLER_INSTALL'),
                            $newdir
                        ),
                        Log::WARNING,
                        'jerror'
                    );

                    return false;
                }
            }

            // Add the file to the copyfiles array
            $copyfiles[] = $path;
        }

        return $this->copyFiles($copyfiles);
    }

    /**
     * Method to parse through a media element of the installation manifest and take appropriate
     * action.
     *
     * @param   \SimpleXMLElement  $element  The XML node to process
     * @param   integer            $cid      Application ID of application to install to
     *
     * @return  boolean     True on success
     *
     * @since   3.1
     */
    public function parseMedia(\SimpleXMLElement $element, $cid = 0)
    {
        if (!$element || !\count($element->children())) {
            // Either the tag does not exist or has no children therefore we return zero files processed.
            return 0;
        }

        $copyfiles = [];

        // Here we set the folder we are going to copy the files to.
        // Default 'media' Files are copied to the JPATH_BASE/media folder

        $folder      = ((string) $element->attributes()->destination) ? '/' . $element->attributes()->destination : null;
        $destination = Path::clean(JPATH_ROOT . '/media' . $folder);

        // Here we set the folder we are going to copy the files from.

        /*
         * Does the element have a folder attribute?
         * If so this indicates that the files are in a subdirectory of the source
         * folder and we should append the folder attribute to the source path when
         * copying files.
         */

        $folder = (string) $element->attributes()->folder;

        if ($folder && file_exists($this->getPath('source') . '/' . $folder)) {
            $source = $this->getPath('source') . '/' . $folder;
        } else {
            $source = $this->getPath('source');
        }

        // Process each file in the $files array (children of $tagName).
        foreach ($element->children() as $file) {
            $path         = [];
            $path['src']  = $source . '/' . $file;
            $path['dest'] = $destination . '/' . $file;

            // Is this path a file or folder?
            $path['type'] = $file->getName() === 'folder' ? 'folder' : 'file';

            /*
             * Before we can add a file to the copyfiles array we need to ensure
             * that the folder we are copying our file to exists and if it doesn't,
             * we need to create it.
             */

            if (basename($path['dest']) !== $path['dest']) {
                $newdir = \dirname($path['dest']);

                if (!Folder::create($newdir)) {
                    Log::add(
                        Text::sprintf(
                            'JLIB_INSTALLER_ABORT_CREATE_DIRECTORY',
                            Text::_('JLIB_INSTALLER_INSTALL'),
                            $newdir
                        ),
                        Log::WARNING,
                        'jerror'
                    );

                    return false;
                }
            }

            // Add the file to the copyfiles array
            $copyfiles[] = $path;
        }

        return $this->copyFiles($copyfiles);
    }

    /**
     * Method to parse the parameters of an extension, build the JSON string for its default parameters, and return the JSON string.
     *
     * @return  string  JSON string of parameter values
     *
     * @since   3.1
     * @note    This method must always return a JSON compliant string
     */
    public function getParams()
    {
        // Validate that we have a fieldset to use
        if (!isset($this->manifest->config->fields->fieldset)) {
            return '{}';
        }

        // Getting the fieldset tags
        $fieldsets = $this->manifest->config->fields->fieldset;

        // Creating the data collection variable:
        $ini = [];

        // Iterating through the fieldsets:
        foreach ($fieldsets as $fieldset) {
            if (!\count($fieldset->children())) {
                // Either the tag does not exist or has no children therefore we return zero files processed.
                return '{}';
            }

            // Iterating through the fields and collecting the name/default values:
            foreach ($fieldset as $field) {
                // Check against the null value since otherwise default values like "0"
                // cause entire parameters to be skipped.

                if (($name = $field->attributes()->name) === null) {
                    continue;
                }

                if (($value = $field->attributes()->default) === null) {
                    continue;
                }

                $ini[(string) $name] = (string) $value;
            }
        }

        return json_encode($ini);
    }

    /**
     * Copyfiles
     *
     * Copy files from source directory to the target directory
     *
     * @param   array    $files      Array with filenames
     * @param   boolean  $overwrite  True if existing files can be replaced
     *
     * @return  boolean  True on success
     *
     * @since   3.1
     */
    public function copyFiles($files, $overwrite = null)
    {
        /*
         * To allow for manual override on the overwriting flag, we check to see if
         * the $overwrite flag was set and is a boolean value.  If not, use the object
         * allowOverwrite flag.
         */

        if ($overwrite === null || !\is_bool($overwrite)) {
            $overwrite = $this->overwrite;
        }

        /*
         * $files must be an array of filenames.  Verify that it is an array with
         * at least one file to copy.
         */
        if (\is_array($files) && \count($files) > 0) {
            foreach ($files as $file) {
                // Get the source and destination paths
                $filesource = Path::clean($file['src']);
                $filedest   = Path::clean($file['dest']);
                $filetype   = \array_key_exists('type', $file) ? $file['type'] : 'file';

                if (!file_exists($filesource)) {
                    /*
                     * The source file does not exist.  Nothing to copy so set an error
                     * and return false.
                     */
                    Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_NO_FILE', $filesource), Log::WARNING, 'jerror');

                    return false;
                }

                if (($exists = file_exists($filedest)) && !$overwrite) {
                    // It's okay if the manifest already exists
                    if ($this->getPath('manifest') === $filesource) {
                        continue;
                    }

                    // The destination file already exists and the overwrite flag is false.
                    // Set an error and return false.
                    Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_FILE_EXISTS', $filedest), Log::WARNING, 'jerror');

                    return false;
                }

                // Copy the folder or file to the new location.
                if ($filetype === 'folder') {
                    if (!Folder::copy($filesource, $filedest, null, $overwrite)) {
                        Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_FAIL_COPY_FOLDER', $filesource, $filedest), Log::WARNING, 'jerror');

                        return false;
                    }

                    $step = ['type' => 'folder', 'path' => $filedest];
                } else {
                    if (!File::copy($filesource, $filedest, null)) {
                        Log::add(Text::sprintf('JLIB_INSTALLER_ERROR_FAIL_COPY_FILE', $filesource, $filedest), Log::WARNING, 'jerror');

                        // In 3.2, TinyMCE language handling changed.  Display a special notice in case an older language pack is installed.
                        if (strpos($filedest, 'media/editors/tinymce/jscripts/tiny_mce/langs')) {
                            Log::add(Text::_('JLIB_INSTALLER_NOT_ERROR'), Log::WARNING, 'jerror');
                        }

                        return false;
                    }

                    $step = ['type' => 'file', 'path' => $filedest];
                }

                /*
                 * Since we copied a file/folder, we want to add it to the installation step stack so that
                 * in case we have to roll back the installation we can remove the files copied.
                 */
                if (!$exists) {
                    $this->stepStack[] = $step;
                }
            }
        } else {
            // The $files variable was either not an array or an empty array
            return false;
        }

        return \count($files);
    }

    /**
     * Method to parse through a files element of the installation manifest and remove
     * the files that were installed
     *
     * @param   object   $element  The XML node to process
     * @param   integer  $cid      Application ID of application to remove from
     *
     * @return  boolean  True on success
     *
     * @since   3.1
     */
    public function removeFiles($element, $cid = 0)
    {
        if (!$element || !\count($element->children())) {
            // Either the tag does not exist or has no children therefore we return zero files processed.
            return true;
        }

        $retval = true;

        // Get the client info if we're using a specific client
        if ($cid > -1) {
            $client = ApplicationHelper::getClientInfo($cid);
        } else {
            $client = null;
        }

        // Get the array of file nodes to process
        $files = $element->children();

        if (\count($files) === 0) {
            // No files to process
            return true;
        }

        $folder = '';

        /*
         * Here we set the folder we are going to remove the files from.  There are a few
         * special cases that need to be considered for certain reserved tags.
         */
        switch ($element->getName()) {
            case 'media':
                if ((string) $element->attributes()->destination) {
                    $folder = (string) $element->attributes()->destination;
                } else {
                    $folder = '';
                }

                $source = $client->path . '/media/' . $folder;

                break;

            case 'languages':
                $lang_client = (string) $element->attributes()->client;

                if ($lang_client) {
                    $client = ApplicationHelper::getClientInfo($lang_client, true);
                    $source = $client->path . '/language';
                } else {
                    if ($client) {
                        $source = $client->path . '/language';
                    } else {
                        $source = '';
                    }
                }

                break;

            default:
                if ($client) {
                    $pathname = 'extension_' . $client->name;
                    $source   = $this->getPath($pathname);
                } else {
                    $pathname = 'extension_root';
                    $source   = $this->getPath($pathname);
                }

                break;
        }

        // Process each file in the $files array (children of $tagName).
        foreach ($files as $file) {
            /*
             * If the file is a language, we must handle it differently.  Language files
             * go in a subdirectory based on the language code, ie.
             * <language tag="en_US">en_US.mycomponent.ini</language>
             * would go in the en_US subdirectory of the languages directory.
             */

            if ($file->getName() === 'language' && (string) $file->attributes()->tag !== '') {
                if ($source) {
                    $path = $source . '/' . $file->attributes()->tag . '/' . basename((string) $file);
                } else {
                    $target_client = ApplicationHelper::getClientInfo((string) $file->attributes()->client, true);
                    $path          = $target_client->path . '/language/' . $file->attributes()->tag . '/' . basename((string) $file);
                }

                // If the language folder is not present, then the core pack hasn't been installed... ignore
                if (!is_dir(Path::clean(\dirname($path)))) {
                    continue;
                }
            } else {
                $path = $source . '/' . $file;
            }

            // Actually delete the files/folders

            if (is_dir($path)) {
                $val = Folder::delete($path);
            } else {
                $val = File::delete($path);
            }

            if ($val === false) {
                Log::add('Failed to delete ' . $path, Log::WARNING, 'jerror');
                $retval = false;
            }
        }

        if (!empty($folder)) {
            Folder::delete($source);
        }

        return $retval;
    }

    /**
     * Copies the installation manifest file to the extension folder in the given client
     *
     * @param   integer  $cid  Where to copy the installfile [optional: defaults to 1 (admin)]
     *
     * @return  boolean  True on success, False on error
     *
     * @since   3.1
     */
    public function copyManifest($cid = 1)
    {
        // Get the client info
        $client = ApplicationHelper::getClientInfo($cid);

        $path = ['src' => $this->getPath('manifest')];

        if ($client) {
            $pathname     = 'extension_' . $client->name;
            $path['dest'] = $this->getPath($pathname) . '/' . basename($this->getPath('manifest'));
        } else {
            $pathname     = 'extension_root';
            $path['dest'] = $this->getPath($pathname) . '/' . basename($this->getPath('manifest'));
        }

        return $this->copyFiles([$path], true);
    }

    /**
     * Tries to find the package manifest file
     *
     * @return  boolean  True on success, False on error
     *
     * @since   3.1
     */
    public function findManifest()
    {
        // Do nothing if folder does not exist for some reason
        if (!is_dir(Path::clean($this->getPath('source')))) {
            return false;
        }

        // Main folder manifests (higher priority)
        $parentXmlfiles = Folder::files($this->getPath('source'), '.xml$', false, true);

        // Search for children manifests (lower priority)
        $allXmlFiles    = Folder::files($this->getPath('source'), '.xml$', 1, true);

        // Create an unique array of files ordered by priority
        $xmlfiles = array_unique(array_merge($parentXmlfiles, $allXmlFiles));

        // If at least one XML file exists
        if (!empty($xmlfiles)) {
            foreach ($xmlfiles as $file) {
                // Is it a valid Joomla installation manifest file?
                $manifest = $this->isManifest($file);

                if ($manifest !== null) {
                    // If the root method attribute is set to upgrade, allow file overwrite
                    if ((string) $manifest->attributes()->method === 'upgrade') {
                        $this->upgrade   = true;
                        $this->overwrite = true;
                    }

                    // If the overwrite option is set, allow file overwriting
                    if ((string) $manifest->attributes()->overwrite === 'true') {
                        $this->overwrite = true;
                    }

                    // Set the manifest object and path
                    $this->manifest = $manifest;
                    $this->setPath('manifest', $file);

                    // Set the installation source path to that of the manifest file
                    $this->setPath('source', \dirname($file));

                    return true;
                }
            }

            // None of the XML files found were valid install files
            Log::add(Text::_('JLIB_INSTALLER_ERROR_NOTFINDJOOMLAXMLSETUPFILE'), Log::WARNING, 'jerror');

            return false;
        }

        // No XML files were found in the install folder
        Log::add(Text::_('JLIB_INSTALLER_ERROR_NOTFINDXMLSETUPFILE'), Log::WARNING, 'jerror');

        return false;
    }

    /**
     * Is the XML file a valid Joomla installation manifest file.
     *
     * @param   string  $file  An xmlfile path to check
     *
     * @return  \SimpleXMLElement|null  A \SimpleXMLElement, or null if the file failed to parse
     *
     * @since   3.1
     */
    public function isManifest($file)
    {
        $xml = simplexml_load_file($file);

        // If we cannot load the XML file return null
        if (!$xml) {
            return;
        }

        // Check for a valid XML root tag.
        if ($xml->getName() !== 'extension') {
            return;
        }

        // Valid manifest file return the object
        return $xml;
    }

    /**
     * Generates a manifest cache
     *
     * @return string serialised manifest data
     *
     * @since   3.1
     */
    public function generateManifestCache()
    {
        return json_encode(self::parseXMLInstallFile($this->getPath('manifest')));
    }

    /**
     * Cleans up discovered extensions if they're being installed some other way
     *
     * @param   string   $type     The type of extension (component, etc)
     * @param   string   $element  Unique element identifier (e.g. com_content)
     * @param   string   $folder   The folder of the extension (plugins; e.g. system)
     * @param   integer  $client   The client application (administrator or site)
     *
     * @return  object    Result of query
     *
     * @since   3.1
     */
    public function cleanDiscoveredExtension($type, $element, $folder = '', $client = 0)
    {
        $db    = $this->getDatabase();
        $query = $db->getQuery(true)
            ->delete($db->quoteName('#__extensions'))
            ->where('type = :type')
            ->where('element = :element')
            ->where('folder = :folder')
            ->where('client_id = :client_id')
            ->where('state = -1')
            ->bind(':type', $type)
            ->bind(':element', $element)
            ->bind(':folder', $folder)
            ->bind(':client_id', $client, ParameterType::INTEGER);
        $db->setQuery($query);

        return $db->execute();
    }

    /**
     * Compares two "files" entries to find deleted files/folders
     *
     * @param   array  $oldFiles  An array of \SimpleXMLElement objects that are the old files
     * @param   array  $newFiles  An array of \SimpleXMLElement objects that are the new files
     *
     * @return  array  An array with the delete files and folders in findDeletedFiles[files] and findDeletedFiles[folders] respectively
     *
     * @since   3.1
     */
    public function findDeletedFiles($oldFiles, $newFiles)
    {
        // The magic find deleted files function!
        // The files that are new
        $files = [];

        // The folders that are new
        $folders = [];

        // The folders of the files that are new
        $containers = [];

        // A list of files to delete
        $files_deleted = [];

        // A list of folders to delete
        $folders_deleted = [];

        foreach ($newFiles as $file) {
            switch ($file->getName()) {
                case 'folder':
                    // Add any folders to the list
                    $folders[] = (string) $file;
                    break;

                case 'file':
                default:
                    // Add any files to the list
                    $files[] = (string) $file;

                    // Now handle the folder part of the file to ensure we get any containers
                    // Break up the parts of the directory
                    $container_parts = explode('/', \dirname((string) $file));

                    // Make sure this is clean and empty
                    $container = '';

                    foreach ($container_parts as $part) {
                        // Iterate through each part
                        // Add a slash if its not empty
                        if (!empty($container)) {
                            $container .= '/';
                        }

                        // Append the folder part
                        $container .= $part;

                        if (!\in_array($container, $containers)) {
                            // Add the container if it doesn't already exist
                            $containers[] = $container;
                        }
                    }
                    break;
            }
        }

        foreach ($oldFiles as $file) {
            switch ($file->getName()) {
                case 'folder':
                    if (!\in_array((string) $file, $folders)) {
                        // See whether the folder exists in the new list
                        if (!\in_array((string) $file, $containers)) {
                            // Check if the folder exists as a container in the new list
                            // If it's not in the new list or a container then delete it
                            $folders_deleted[] = (string) $file;
                        }
                    }
                    break;

                case 'file':
                default:
                    if (!\in_array((string) $file, $files)) {
                        // Look if the file exists in the new list
                        if (!\in_array(\dirname((string) $file), $folders)) {
                            // Look if the file is now potentially in a folder
                            $files_deleted[] = (string) $file;
                        }
                    }
                    break;
            }
        }

        return ['files' => $files_deleted, 'folders' => $folders_deleted];
    }

    /**
     * Loads an MD5SUMS file into an associative array
     *
     * @param   string  $filename  Filename to load
     *
     * @return  array  Associative array with filenames as the index and the MD5 as the value
     *
     * @since   3.1
     */
    public function loadMD5Sum($filename)
    {
        if (!file_exists($filename)) {
            // Bail if the file doesn't exist
            return false;
        }

        $data   = file($filename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
        $retval = [];

        foreach ($data as $row) {
            // Split up the data
            $results = explode('  ', $row);

            // Cull any potential prefix
            $results[1] = str_replace('./', '', $results[1]);

            // Throw into the array
            $retval[$results[1]] = $results[0];
        }

        return $retval;
    }

    /**
     * Parse a XML install manifest file.
     *
     * XML Root tag should be 'install' except for languages which use meta file.
     *
     * @param   string  $path  Full path to XML file.
     *
     * @return  array  XML metadata.
     *
     * @since   3.0.0
     */
    public static function parseXMLInstallFile($path)
    {
        // Check if xml file exists.
        if (!file_exists($path)) {
            return false;
        }

        // Read the file to see if it's a valid component XML file
        $xml = simplexml_load_file($path);

        if (!$xml) {
            return false;
        }

        // Check for a valid XML root tag.

        // Extensions use 'extension' as the root tag.  Languages use 'metafile' instead

        $name = $xml->getName();

        if ($name !== 'extension' && $name !== 'metafile') {
            unset($xml);

            return false;
        }

        $data = [];

        $data['name'] = (string) $xml->name;

        // Check if we're a language. If so use metafile.
        $data['type'] = $xml->getName() === 'metafile' ? 'language' : (string) $xml->attributes()->type;

        $data['creationDate'] = ((string) $xml->creationDate) ?: Text::_('JLIB_UNKNOWN');
        $data['author']       = ((string) $xml->author) ?: Text::_('JLIB_UNKNOWN');

        $data['copyright']    = (string) $xml->copyright;
        $data['authorEmail']  = (string) $xml->authorEmail;
        $data['authorUrl']    = (string) $xml->authorUrl;
        $data['version']      = (string) $xml->version;
        $data['description']  = (string) $xml->description;
        $data['group']        = (string) $xml->group;
        $data['changelogurl'] = (string) $xml->changelogurl;

        // Child template specific fields.
        if (isset($xml->inheritable)) {
            $data['inheritable'] = (string) $xml->inheritable === '0' ? false : true;
        }

        // Child template specific fields.
        if (isset($xml->namespace) && (string) $xml->namespace !== '') {
            $data['namespace'] = (string) $xml->namespace;
        }

        if (isset($xml->parent) && (string) $xml->parent !== '') {
            $data['parent'] = (string) $xml->parent;
        }

        if ($xml->files && \count($xml->files->children())) {
            $filename         = basename($path);
            $data['filename'] = File::stripExt($filename);

            foreach ($xml->files->children() as $oneFile) {
                if ((string) $oneFile->attributes()->plugin) {
                    $data['filename'] = (string) $oneFile->attributes()->plugin;
                    break;
                }
            }
        }

        return $data;
    }

    /**
     * Gets a list of available install adapters.
     *
     * @param   array  $options  An array of options to inject into the adapter
     * @param   array  $custom   Array of custom install adapters
     *
     * @return  string[]  An array of the class names of available install adapters.
     *
     * @since   3.4
     */
    public function getAdapters($options = [], array $custom = [])
    {
        $files    = new \DirectoryIterator($this->_basepath . '/' . $this->_adapterfolder);
        $adapters = [];

        // Process the core adapters
        foreach ($files as $file) {
            $fileName = $file->getFilename();

            // Only load for php files.
            if (!$file->isFile() || $file->getExtension() !== 'php') {
                continue;
            }

            // Derive the class name from the filename.
            $name  = str_ireplace('.php', '', trim($fileName));
            $name  = str_ireplace('adapter', '', trim($name));
            $class = rtrim($this->_classprefix, '\\') . '\\' . ucfirst($name) . 'Adapter';

            if (!class_exists($class)) {
                // Not namespaced
                $class = $this->_classprefix . ucfirst($name);
            }

            // Core adapters should autoload based on classname, keep this fallback just in case
            if (!class_exists($class)) {
                // Try to load the adapter object
                \JLoader::register($class, $this->_basepath . '/' . $this->_adapterfolder . '/' . $fileName);

                if (!class_exists($class)) {
                    // Skip to next one
                    continue;
                }
            }

            $adapters[] = $name;
        }

        // Add any custom adapters if specified
        if (\count($custom) >= 1) {
            foreach ($custom as $adapter) {
                // Setup the class name
                // @todo - Can we abstract this to not depend on the Joomla class namespace without PHP namespaces?
                $class = $this->_classprefix . ucfirst(trim($adapter));

                // If the class doesn't exist we have nothing left to do but look at the next type. We did our best.
                if (!class_exists($class)) {
                    continue;
                }

                $adapters[] = str_ireplace('.php', '', $fileName);
            }
        }

        return $adapters;
    }

    /**
     * Method to load an adapter instance
     *
     * @param   string  $adapter  Adapter name
     * @param   array   $options  Adapter options
     *
     * @return  InstallerAdapter
     *
     * @since   3.4
     * @throws  \InvalidArgumentException
     */
    public function loadAdapter($adapter, $options = [])
    {
        $class = rtrim($this->_classprefix, '\\') . '\\' . ucfirst($adapter) . 'Adapter';

        if (!class_exists($class)) {
            // Not namespaced
            $class = $this->_classprefix . ucfirst($adapter);
        }

        if (!class_exists($class)) {
            throw new \InvalidArgumentException(\sprintf('The %s install adapter does not exist.', $adapter));
        }

        // Ensure the adapter type is part of the options array
        $options['type'] = $adapter;

        // Check for a possible service from the container otherwise manually instantiate the class
        if (Factory::getContainer()->has($class)) {
            return Factory::getContainer()->get($class);
        }

        $adapter = new $class($this, $this->getDatabase(), $options);

        if ($adapter instanceof ContainerAwareInterface) {
            $adapter->setContainer(Factory::getContainer());
        }

        return $adapter;
    }
}
