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

namespace Joomla\Component\Installer\Administrator\Model;

\defined('_JEXEC') or die;

use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Schema\ChangeSet;
use Joomla\CMS\Table\Extension;
use Joomla\CMS\Version;
use Joomla\Component\Installer\Administrator\Helper\InstallerHelper;
use Joomla\Database\DatabaseQuery;
use Joomla\Database\Exception\ExecutionFailureException;
use Joomla\Database\ParameterType;
use Joomla\Registry\Registry;

\JLoader::register('JoomlaInstallerScript', JPATH_ADMINISTRATOR . '/components/com_admin/script.php');

/**
 * Installer Database Model
 *
 * @since  1.6
 */
class DatabaseModel extends InstallerModel
{
	/**
	 * Set the model context
	 *
	 * @var    string
	 *
	 * @since  4.0.0
	 */
	protected $_context = 'com_installer.discover';

	/**
	 * ChangeSet of all extensions
	 *
	 * @var    array
	 *
	 * @since  4.0.0
	 */
	private $changeSetList = array();

	/**
	 * Total of errors
	 *
	 * @var    integer
	 *
	 * @since  4.0.0
	 */
	private $errorCount = 0;

	/**
	 * Constructor.
	 *
	 * @param   array                $config   An optional associative array of configuration settings.
	 * @param   MVCFactoryInterface  $factory  The factory.
	 *
	 * @see     ListModel
	 * @since   4.0.0
	 */
	public function __construct($config = array(), MVCFactoryInterface $factory = null)
	{
		if (empty($config['filter_fields']))
		{
			$config['filter_fields'] = array(
				'update_site_name',
				'name',
				'client_id',
				'client', 'client_translated',
				'status',
				'type', 'type_translated',
				'folder', 'folder_translated',
				'extension_id'
			);
		}

		parent::__construct($config, $factory);
	}

	/**
	 * Method to return the total number of errors in all the extensions, saved in cache.
	 *
	 * @return  integer
	 *
	 * @throws  \Exception
	 *
	 * @since   4.0.0
	 */
	public function getErrorCount()
	{
		return $this->errorCount;
	}

	/**
	 * Method to populate the schema cache.
	 *
	 * @param   integer  $cid  The extension ID to get the schema for
	 *
	 * @return  void
	 *
	 * @throws  \Exception
	 *
	 * @since   4.0.0
	 */
	private function fetchSchemaCache($cid = 0)
	{
		// We already have it
		if (array_key_exists($cid, $this->changeSetList))
		{
			return;
		}

		// Add the ID to the state so it can be used for filtering
		if ($cid)
		{
			$this->setState('filter.extension_id', $cid);
		}

		// With the parent::save it can get the limit and we need to make sure it gets all extensions
		$results = $this->_getList($this->getListQuery());

		foreach ($results as $result)
		{
			$errorMessages = array();
			$errorCount    = 0;

			if (strcmp($result->element, 'joomla') === 0)
			{
				$result->element = 'com_admin';

				if (!$this->getDefaultTextFilters())
				{
					$errorMessages[] = Text::_('COM_INSTALLER_MSG_DATABASE_FILTER_ERROR');
					$errorCount++;
				}
			}

			$db        = $this->getDbo();

			if ($result->type === 'component')
			{
				$basePath = JPATH_ADMINISTRATOR . '/components/' . $result->element;
			}
			elseif ($result->type === 'plugin')
			{
				$basePath = JPATH_PLUGINS . '/' . $result->folder . '/' . $result->element;
			}
			elseif ($result->type === 'module')
			{
				// Typehint to integer to normalise some DBs returning strings and others integers
				if ((int) $result->client_id === 1)
				{
					$basePath = JPATH_ADMINISTRATOR . '/modules/' . $result->element;
				}
				elseif ((int) $result->client_id === 0)
				{
					$basePath = JPATH_SITE . '/modules/' . $result->element;
				}
				else
				{
					// Module with unknown client id!? - bail
					continue;
				}
			}
			// Specific bodge for the Joomla CMS special database check which points to com_admin
			elseif ($result->type === 'file' && $result->element === 'com_admin')
			{
				$basePath = JPATH_ADMINISTRATOR . '/components/' . $result->element;
			}
			else
			{
				// Unknown extension type (library, files etc which don't have known SQL paths right now)
				continue;
			}

			// Search the standard SQL Path for the SQL Updates and then if not there check the configuration of the XML
			// file. This just gives us a small performance win of not parsing the XML every time.
			$folderTmp = $basePath . '/sql/updates/';

			if (!file_exists($folderTmp))
			{
				$installationXML = InstallerHelper::getInstallationXML(
					$result->element,
					$result->type,
					$result->client_id,
					$result->type === 'plugin' ? $result->folder : null
				);

				if ($installationXML !== null)
				{
					$folderTmp = (string) $installationXML->update->schemas->schemapath[0];
					$a = explode('/', $folderTmp);
					array_pop($a);
					$folderTmp = $basePath . '/' . implode('/', $a);
				}
			}

			// Can't find the folder still - give up now and move on.
			if (!file_exists($folderTmp))
			{
				continue;
			}

			$changeSet = new ChangeSet($db, $folderTmp);

			// If the version in the #__schemas is different
			// than the update files, add to problems message
			$schema = $changeSet->getSchema();

			// If the schema is empty we couldn't find any update files. Just ignore the extension.
			if (empty($schema))
			{
				continue;
			}

			if ($result->version_id !== $schema)
			{
				$errorMessages[] = Text::sprintf('COM_INSTALLER_MSG_DATABASE_SCHEMA_ERROR', $result->version_id, $schema);
				$errorCount++;
			}

			// If the version in the manifest_cache is different than the
			// version in the installation xml, add to problems message
			$compareUpdateMessage = $this->compareUpdateVersion($result);

			if ($compareUpdateMessage)
			{
				$errorMessages[] = $compareUpdateMessage;
				$errorCount++;
			}

			// If there are errors in the database, add to the problems message
			$errors = $changeSet->check();

			$errorsMessage = $this->getErrorsMessage($errors);

			if ($errorsMessage)
			{
				$errorMessages = array_merge($errorMessages, $errorsMessage);
				$errorCount++;
			}

			// Number of database tables Checked and Skipped
			$errorMessages = array_merge($errorMessages, $this->getOtherInformationMessage($changeSet->getStatus()));

			// Set the total number of errors
			$this->errorCount += $errorCount;

			// Collect the extension details
			$this->changeSetList[$result->extension_id] = array(
				'folderTmp'     => $folderTmp,
				'errorsMessage' => $errorMessages,
				'errorsCount'   => $errorCount,
				'results'       => $changeSet->getStatus(),
				'schema'        => $schema,
				'extension'     => $result
			);
		}
	}

	/**
	 * Method to auto-populate the model state.
	 *
	 * Note. Calling getState in this method will result in recursion.
	 *
	 * @param   string  $ordering   An optional ordering field.
	 * @param   string  $direction  An optional direction (asc|desc).
	 *
	 * @return  void
	 *
	 * @since   1.6
	 */
	protected function populateState($ordering = 'name', $direction = 'asc')
	{
		$this->setState('filter.search', $this->getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string'));
		$this->setState('filter.client_id', $this->getUserStateFromRequest($this->context . '.filter.client_id', 'filter_client_id', null, 'int'));
		$this->setState('filter.type', $this->getUserStateFromRequest($this->context . '.filter.type', 'filter_type', '', 'string'));
		$this->setState('filter.folder', $this->getUserStateFromRequest($this->context . '.filter.folder', 'filter_folder', '', 'string'));

		parent::populateState($ordering, $direction);
	}

	/**
	 * Fixes database problems.
	 *
	 * @param   array  $cids  List of the selected extensions to fix
	 *
	 * @return  void|boolean
	 *
	 * @throws  \Exception
	 *
	 * @since   4.0.0
	 */
	public function fix($cids = array())
	{
		$db = $this->getDbo();

		foreach ($cids as $i => $cid)
		{
			// Load the database issues
			$this->fetchSchemaCache($cid);

			$changeSet = $this->changeSetList[$cid];
			$changeSet['changeset'] = new ChangeSet($db, $changeSet['folderTmp']);
			$changeSet['changeset']->fix();

			$this->fixSchemaVersion($changeSet['changeset'], $changeSet['extension']->extension_id);
			$this->fixUpdateVersion($changeSet['extension']->extension_id);

			if ($changeSet['extension']->element === 'com_admin')
			{
				$installer = new \JoomlaInstallerScript;
				$installer->deleteUnexistingFiles();
				$this->fixDefaultTextFilters();

				/*
				 * Finally, if the schema updates succeeded, make sure the database table is
				 * converted to utf8mb4 or, if not supported by the server, compatible to it.
				 */
				$statusArray = $changeSet['changeset']->getStatus();

				if (count($statusArray['error']) == 0)
				{
					$installer->convertTablesToUtf8mb4(false);
				}
			}
		}
	}

	/**
	 * Gets the changeset array.
	 *
	 * @return  array  Array with the information of the versions problems, errors and the extensions itself
	 *
	 * @throws  \Exception
	 *
	 * @since   4.0.0
	 */
	public function getItems()
	{
		$this->fetchSchemaCache();

		$results = parent::getItems();
		$results = $this->mergeSchemaCache($results);

		return $results;
	}

	/**
	 * Method to get the database query
	 *
	 * @return  DatabaseQuery  The database query
	 *
	 * @since   4.0.0
	 */
	protected function getListQuery()
	{
		$db    = $this->getDbo();
		$query = $db->getQuery(true)
			->select(
				$db->quoteName(
					[
						'extensions.client_id',
						'extensions.element',
						'extensions.extension_id',
						'extensions.folder',
						'extensions.manifest_cache',
						'extensions.name',
						'extensions.type',
						'schemas.version_id'
					]
				)
			)
			->from(
				$db->quoteName(
					'#__schemas',
					'schemas'
				)
			)
			->join(
				'INNER',
				$db->quoteName('#__extensions', 'extensions'),
				$db->quoteName('schemas.extension_id') . ' = ' . $db->quoteName('extensions.extension_id')
			);

		$type        = $this->getState('filter.type');
		$clientId    = $this->getState('filter.client_id');
		$extensionId = $this->getState('filter.extension_id');
		$folder      = $this->getState('filter.folder');

		if ($type)
		{
			$query->where($db->quoteName('extensions.type') . ' = :type')
				->bind(':type', $type);
		}

		if ($clientId != '')
		{
			$clientId = (int) $clientId;
			$query->where($db->quoteName('extensions.client_id') . ' = :clientid')
				->bind(':clientid', $clientId, ParameterType::INTEGER);
		}

		if ($extensionId != '')
		{
			$extensionId = (int) $extensionId;
			$query->where($db->quoteName('extensions.extension_id') . ' = :extensionid')
				->bind(':extensionid', $extensionId, ParameterType::INTEGER);
		}

		if ($folder != '' && in_array($type, array('plugin', 'library', '')))
		{
			$folder = $folder === '*' ? '' : $folder;
			$query->where($db->quoteName('extensions.folder') . ' = :folder')
				->bind(':folder', $folder);
		}

		// Process search filter (update site id).
		$search = $this->getState('filter.search');

		if (!empty($search) && stripos($search, 'id:') === 0)
		{
			$ids = (int) substr($search, 3);
			$query->where($db->quoteName('schemas.extension_id') . ' = :eid')
				->bind(':eid', $ids, ParameterType::INTEGER);
		}

		return $query;
	}

	/**
	 * Merge the items that will be visible with the changeSet information in cache
	 *
	 * @param   array  $results  extensions returned from parent::getItems().
	 *
	 * @return  array  the changeSetList of the merged items
	 *
	 * @since   4.0.0
	 */
	protected function mergeSchemaCache($results)
	{
		$changeSetList = $this->changeSetList;
		$finalResults  = array();

		foreach ($results as $result)
		{
			if (array_key_exists($result->extension_id, $changeSetList) && $changeSetList[$result->extension_id])
			{
				$finalResults[] = $changeSetList[$result->extension_id];
			}
		}

		return $finalResults;
	}

	/**
	 * Get version from #__schemas table.
	 *
	 * @param   integer  $extensionId  id of the extensions.
	 *
	 * @return  mixed  the return value from the query, or null if the query fails.
	 *
	 * @throws  \Exception
	 *
	 * @since   4.0.0
	 */
	public function getSchemaVersion($extensionId)
	{
		$db          = $this->getDbo();
		$extensionId = (int) $extensionId;
		$query       = $db->getQuery(true)
			->select($db->quoteName('version_id'))
			->from($db->quoteName('#__schemas'))
			->where($db->quoteName('extension_id') . ' = :extensionid')
			->bind(':extensionid', $extensionId, ParameterType::INTEGER);
		$db->setQuery($query);

		return $db->loadResult();
	}

	/**
	 * Fix schema version if wrong.
	 *
	 * @param   ChangeSet  $changeSet    Schema change set.
	 * @param   integer    $extensionId  ID of the extensions.
	 *
	 * @return  mixed  string schema version if success, false if fail.
	 *
	 * @throws  \Exception
	 *
	 * @since   4.0.0
	 */
	public function fixSchemaVersion($changeSet, $extensionId)
	{
		// Get correct schema version -- last file in array.
		$schema = $changeSet->getSchema();

		// Check value. If ok, don't do update.
		if ($schema == $this->getSchemaVersion($extensionId))
		{
			return $schema;
		}

		// Delete old row.
		$extensionId = (int) $extensionId;
		$db          = $this->getDbo();
		$query       = $db->getQuery(true)
			->delete($db->quoteName('#__schemas'))
			->where($db->quoteName('extension_id') . ' = :extensionid')
			->bind(':extensionid', $extensionId, ParameterType::INTEGER);
		$db->setQuery($query)->execute();

		// Add new row.
		$query->clear()
			->insert($db->quoteName('#__schemas'))
			->columns($db->quoteName('extension_id') . ',' . $db->quoteName('version_id'))
			->values(':extensionid, :schema')
			->bind(':extensionid', $extensionId, ParameterType::INTEGER)
			->bind(':schema', $schema);
		$db->setQuery($query);

		try
		{
			$db->execute();
		}
		catch (ExecutionFailureException $e)
		{
			return false;
		}

		return $schema;
	}

	/**
	 * Get current version from #__extensions table.
	 *
	 * @param   object  $extension  data from #__extensions of a single extension.
	 *
	 * @return  mixed  string message with the errors with the update version or null if none
	 *
	 * @since   4.0.0
	 */
	public function compareUpdateVersion($extension)
	{
		$updateVersion = json_decode($extension->manifest_cache)->version;

		if ($extension->element === 'com_admin')
		{
			$extensionVersion = JVERSION;
		}
		else
		{
			$installationXML = InstallerHelper::getInstallationXML(
				$extension->element,
				$extension->type,
				$extension->client_id,
				$extension->type === 'plugin' ? $extension->folder : null
			);

			$extensionVersion = (string) $installationXML->version;
		}

		if (version_compare($extensionVersion, $updateVersion) != 0)
		{
			return Text::sprintf('COM_INSTALLER_MSG_DATABASE_UPDATEVERSION_ERROR', $updateVersion, $extension->name, $extensionVersion);
		}

		return null;
	}

	/**
	 * Get a message of the tables skipped and checked
	 *
	 * @param   array  $status  status of of the update files
	 *
	 * @return  array  Messages with the errors with the update version
	 *
	 * @since   4.0.0
	 */
	private function getOtherInformationMessage($status)
	{
		$problemsMessage = array();
		$problemsMessage[] = Text::sprintf('COM_INSTALLER_MSG_DATABASE_CHECKED_OK', count($status['ok']));
		$problemsMessage[] = Text::sprintf('COM_INSTALLER_MSG_DATABASE_SKIPPED', count($status['skipped']));

		return $problemsMessage;
	}

	/**
	 * Get a message with all errors found in a given extension
	 *
	 * @param   array  $errors  data from #__extensions of a single extension.
	 *
	 * @return  array  List of messages with the errors in the database
	 *
	 * @since   4.0.0
	 */
	private function getErrorsMessage($errors)
	{
		$errorMessages = array();

		foreach ($errors as $line => $error)
		{
			$key             = 'COM_INSTALLER_MSG_DATABASE_' . $error->queryType;
			$messages        = $error->msgElements;
			$file            = basename($error->file);
			$message0        = isset($messages[0]) ? $messages[0] : ' ';
			$message1        = isset($messages[1]) ? $messages[1] : ' ';
			$message2        = isset($messages[2]) ? $messages[2] : ' ';
			$errorMessages[] = Text::sprintf($key, $file, $message0, $message1, $message2);
		}

		return $errorMessages;
	}

	/**
	 * Fix Joomla version in #__extensions table if wrong (doesn't equal \JVersion short version).
	 *
	 * @param   integer  $extensionId  id of the extension
	 *
	 * @return  mixed  string update version if success, false if fail.
	 *
	 * @since   4.0.0
	 */
	public function fixUpdateVersion($extensionId)
	{
		$table = new Extension($this->getDbo());
		$table->load($extensionId);
		$cache = new Registry($table->manifest_cache);
		$updateVersion = $cache->get('version');

		if ($table->get('type') === 'file' && $table->get('element') === 'joomla')
		{
			$extensionVersion = new Version;
			$extensionVersion = $extensionVersion->getShortVersion();
		}
		else
		{
			$installationXML = InstallerHelper::getInstallationXML(
				$table->get('element'),
				$table->get('type'),
				$table->get('client_id'),
				$table->get('type') === 'plugin' ? $table->get('folder') : null
			);
			$extensionVersion = (string) $installationXML->version;
		}

		if ($updateVersion === $extensionVersion)
		{
			return $updateVersion;
		}

		$cache->set('version', $extensionVersion);
		$table->set('manifest_cache', $cache->toString());

		if ($table->store())
		{
			return $extensionVersion;
		}

		return false;
	}

	/**
	 * For version 2.5.x only
	 * Check if com_config parameters are blank.
	 *
	 * @return  string  default text filters (if any).
	 *
	 * @since   4.0.0
	 */
	public function getDefaultTextFilters()
	{
		$table = new Extension($this->getDbo());
		$table->load($table->find(array('name' => 'com_config')));

		return $table->params;
	}

	/**
	 * For version 2.5.x only
	 * Check if com_config parameters are blank. If so, populate with com_content text filters.
	 *
	 * @return  void
	 *
	 * @since   4.0.0
	 */
	private function fixDefaultTextFilters()
	{
		$table = new Extension($this->getDbo());
		$table->load($table->find(array('name' => 'com_config')));

		// Check for empty $config and non-empty content filters.
		if (!$table->params)
		{
			// Get filters from com_content and store if you find them.
			$contentParams = ComponentHelper::getComponent('com_content')->getParams();

			if ($contentParams->get('filters'))
			{
				$newParams = new Registry;
				$newParams->set('filters', $contentParams->get('filters'));
				$table->params = (string) $newParams;
				$table->store();
			}
		}
	}
}
