<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;


/**
 * Provide methods to handle versioning.
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class Versions extends \Controller
{

	/**
	 * Table
	 * @var string
	 */
	protected $strTable;

	/**
	 * Parent ID
	 * @var integer
	 */
	protected $intPid;

	/**
	 * Edit URL
	 * @var string
	 */
	protected $strEditUrl;

	/**
	 * Username
	 * @var string
	 */
	protected $strUsername;

	/**
	 * User ID
	 * @var integer
	 */
	protected $intUserId;


	/**
	 * Initialize the object
	 *
	 * @param string  $strTable
	 * @param integer $intPid
	 */
	public function __construct($strTable, $intPid)
	{
		$this->import('Database');
		parent::__construct();

		$this->strTable = $strTable;
		$this->intPid = $intPid;
	}


	/**
	 * Set the edit URL
	 *
	 * @param string $strEditUrl
	 */
	public function setEditUrl($strEditUrl)
	{
		$this->strEditUrl = $strEditUrl;
	}


	/**
	 * Set the username
	 *
	 * @param string $strUsername
	 */
	public function setUsername($strUsername)
	{
		$this->strUsername = $strUsername;
	}


	/**
	 * Set the user ID
	 *
	 * @param integer $intUserId
	 */
	public function setUserId($intUserId)
	{
		$this->intUserId = $intUserId;
	}


	/**
	 * Create the initial version of a record
	 */
	public function initialize()
	{
		if (!$GLOBALS['TL_DCA'][$this->strTable]['config']['enableVersioning'])
		{
			return;
		}

		$objVersion = $this->Database->prepare("SELECT COUNT(*) AS count FROM tl_version WHERE fromTable=? AND pid=?")
									 ->limit(1)
									 ->execute($this->strTable, $this->intPid);

		if ($objVersion->count > 0)
		{
			return;
		}

		$this->create();
	}


	/**
	 * Create a new version of a record
	 */
	public function create()
	{
		if (!$GLOBALS['TL_DCA'][$this->strTable]['config']['enableVersioning'])
		{
			return;
		}

		// Delete old versions from the database
		$tstamp = time() - intval(\Config::get('versionPeriod'));
		$this->Database->query("DELETE FROM tl_version WHERE tstamp<$tstamp");

		// Get the new record
		$objRecord = $this->Database->prepare("SELECT * FROM " . $this->strTable . " WHERE id=?")
									->limit(1)
									->execute($this->intPid);

		if ($objRecord->numRows < 1 || $objRecord->tstamp < 1)
		{
			return;
		}

		// Store the content if it is an editable file
		if ($this->strTable == 'tl_files')
		{
			$objModel = \FilesModel::findByPk($this->intPid);

			if ($objModel !== null && in_array($objModel->extension, trimsplit(',', strtolower(\Config::get('editableFiles')))))
			{
				$objFile = new \File($objModel->path, true);

				if ($objFile->extension == 'svgz')
				{
					$objRecord->content = gzdecode($objFile->getContent());
				}
				else
				{
					$objRecord->content = $objFile->getContent();
				}
			}
		}

		$intVersion = 1;

		$objVersion = $this->Database->prepare("SELECT MAX(version) AS version FROM tl_version WHERE pid=? AND fromTable=?")
									 ->execute($this->intPid, $this->strTable);

		if ($objVersion->version !== null)
		{
			$intVersion = $objVersion->version + 1;
		}

		$strDescription = '';

		if (!empty($objRecord->title))
		{
			$strDescription = $objRecord->title;
		}
		elseif (!empty($objRecord->name))
		{
			$strDescription = $objRecord->name;
		}
		elseif (!empty($objRecord->firstname))
		{
			$strDescription = $objRecord->firstname . ' ' . $objRecord->lastname;
		}
		elseif (!empty($objRecord->headline))
		{
			$chunks = deserialize($objRecord->headline);

			if (is_array($chunks) && isset($chunks['value']))
			{
				$strDescription = $chunks['value'];
			}
			else
			{
				$strDescription = $objRecord->headline;
			}
		}
		elseif (!empty($objRecord->selector))
		{
			$strDescription = $objRecord->selector;
		}
		elseif (!empty($objRecord->subject))
		{
			$strDescription = $objRecord->subject;
		}

		$this->Database->prepare("UPDATE tl_version SET active='' WHERE pid=? AND fromTable=?")
					   ->execute($this->intPid, $this->strTable);

		$this->Database->prepare("INSERT INTO tl_version (pid, tstamp, version, fromTable, username, userid, description, editUrl, active, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)")
					   ->execute($this->intPid, time(), $intVersion, $this->strTable, $this->getUsername(), $this->getUserId(), $strDescription, $this->getEditUrl(), serialize($objRecord->row()));

		// Trigger the oncreate_version_callback
		if (is_array($GLOBALS['TL_DCA'][$this->strTable]['config']['oncreate_version_callback']))
		{
			foreach ($GLOBALS['TL_DCA'][$this->strTable]['config']['oncreate_version_callback'] as $callback)
			{
				if (is_array($callback))
				{
					$this->import($callback[0]);
					$this->{$callback[0]}->{$callback[1]}($this->strTable, $this->intPid, $intVersion, $objRecord->row());
				}
				elseif (is_callable($callback))
				{
					$callback($this->strTable, $this->intPid, $intVersion, $objRecord->row());
				}
			}
		}

		$this->log('Version '.$intVersion.' of record "'.$this->strTable.'.id='.$this->intPid.'" has been created'.$this->getParentEntries($this->strTable, $this->intPid), __METHOD__, TL_GENERAL);
	}


	/**
	 * Restore a version
	 *
	 * @param integer $intVersion
	 */
	public function restore($intVersion)
	{
		if (!$GLOBALS['TL_DCA'][$this->strTable]['config']['enableVersioning'])
		{
			return;
		}

		$objData = $this->Database->prepare("SELECT * FROM tl_version WHERE fromTable=? AND pid=? AND version=?")
								  ->limit(1)
								  ->execute($this->strTable, $this->intPid, $intVersion);

		if ($objData->numRows < 1)
		{
			return;
		}

		$data = deserialize($objData->data);

		if (!is_array($data))
		{
			return;
		}

		// Restore the content if it is an editable file
		if ($this->strTable == 'tl_files')
		{
			$objModel = \FilesModel::findByPk($this->intPid);

			if ($objModel !== null && in_array($objModel->extension, trimsplit(',', strtolower(\Config::get('editableFiles')))))
			{
				$objFile = new \File($objModel->path, true);

				if ($objFile->extension == 'svgz')
				{
					$objFile->write(gzencode($data['content']));
				}
				else
				{
					$objFile->write($data['content']);
				}

				$objFile->close();
			}
		}

		// Get the currently available fields
		$arrFields = array_flip($this->Database->getFieldNames($this->strTable));

		// Unset fields that do not exist (see #5219)
		$data = array_intersect_key($data, $arrFields);

		$this->loadDataContainer($this->strTable);

		// Reset fields added after storing the version to their default value (see #7755)
		foreach (array_diff_key($arrFields, $data) as $k=>$v)
		{
			$data[$k] = \Widget::getEmptyValueByFieldType($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['sql']);
		}

		$this->Database->prepare("UPDATE " . $this->strTable . " %s WHERE id=?")
					   ->set($data)
					   ->execute($this->intPid);

		$this->Database->prepare("UPDATE tl_version SET active='' WHERE fromTable=? AND pid=?")
					   ->execute($this->strTable, $this->intPid);

		$this->Database->prepare("UPDATE tl_version SET active=1 WHERE fromTable=? AND pid=? AND version=?")
					   ->execute($this->strTable, $this->intPid, $intVersion);

		// Trigger the onrestore_version_callback
		if (is_array($GLOBALS['TL_DCA'][$this->strTable]['config']['onrestore_version_callback']))
		{
			foreach ($GLOBALS['TL_DCA'][$this->strTable]['config']['onrestore_version_callback'] as $callback)
			{
				if (is_array($callback))
				{
					$this->import($callback[0]);
					$this->{$callback[0]}->{$callback[1]}($this->strTable, $this->intPid, $intVersion, $data);
				}
				elseif (is_callable($callback))
				{
					$callback($this->strTable, $this->intPid, $intVersion, $data);
				}
			}
		}

		// Trigger the deprecated onrestore_callback
		if (is_array($GLOBALS['TL_DCA'][$this->strTable]['config']['onrestore_callback']))
		{
			@trigger_error('Using the onrestore_callback has been deprecated and will no longer work in Contao 5.0. Use the onrestore_version_callback instead.', E_USER_DEPRECATED);

			foreach ($GLOBALS['TL_DCA'][$this->strTable]['config']['onrestore_callback'] as $callback)
			{
				if (is_array($callback))
				{
					$this->import($callback[0]);
					$this->{$callback[0]}->{$callback[1]}($this->intPid, $this->strTable, $data, $intVersion);
				}
				elseif (is_callable($callback))
				{
					$callback($this->intPid, $this->strTable, $data, $intVersion);
				}
			}
		}

		$this->log('Version '.$intVersion.' of record "'.$this->strTable.'.id='.$this->intPid.'" has been restored'.$this->getParentEntries($this->strTable, $this->intPid), __METHOD__, TL_GENERAL);
	}


	/**
	 * Compare versions
	 */
	public function compare()
	{
		$strBuffer = '';
		$arrVersions = array();
		$intTo = 0;
		$intFrom = 0;

		$objVersions = $this->Database->prepare("SELECT * FROM tl_version WHERE pid=? AND fromTable=? ORDER BY version DESC")
									  ->execute($this->intPid, $this->strTable);

		if ($objVersions->numRows < 2)
		{
			$strBuffer = '<p>There are no versions of ' . $this->strTable . '.id=' . $this->intPid . '</p>';
		}
		else
		{
			$intIndex = 0;
			$from = array();

			// Store the versions and mark the active one
			while ($objVersions->next())
			{
				if ($objVersions->active)
				{
					$intIndex = $objVersions->version;
				}

				$arrVersions[$objVersions->version] = $objVersions->row();
				$arrVersions[$objVersions->version]['info'] = $GLOBALS['TL_LANG']['MSC']['version'].' '.$objVersions->version.' ('.\Date::parse(\Config::get('datimFormat'), $objVersions->tstamp).') '.$objVersions->username;
			}

			// To
			if (\Input::post('to') && isset($arrVersions[\Input::post('to')]))
			{
				$intTo = \Input::post('to');
				$to = deserialize($arrVersions[\Input::post('to')]['data']);
			}
			elseif (\Input::get('to') && isset($arrVersions[\Input::get('to')]))
			{
				$intTo = \Input::get('to');
				$to = deserialize($arrVersions[\Input::get('to')]['data']);
			}
			else
			{
				$intTo = $intIndex;
				$to = deserialize($arrVersions[$intTo]['data']);
			}

			// From
			if (\Input::post('from') && isset($arrVersions[\Input::post('from')]))
			{
				$intFrom = \Input::post('from');
				$from = deserialize($arrVersions[\Input::post('from')]['data']);
			}
			elseif (\Input::get('from') && isset($arrVersions[\Input::get('from')]))
			{
				$intFrom = \Input::get('from');
				$from = deserialize($arrVersions[\Input::get('from')]['data']);
			}
			elseif ($objVersions->numRows > $intIndex)
			{
				$intFrom = $objVersions->first()->version;
				$from = deserialize($arrVersions[$intFrom]['data']);
			}
			elseif ($intIndex > 1)
			{
				$intFrom = $intIndex - 1;
				$from = deserialize($arrVersions[$intFrom]['data']);
			}

			// Only continue if both version numbers are set
			if ($intTo > 0 && $intFrom > 0)
			{
				\System::loadLanguageFile($this->strTable);
				$this->loadDataContainer($this->strTable);

				// Get the order fields
				$objDcaExtractor = \DcaExtractor::getInstance($this->strTable);
				$arrOrder = $objDcaExtractor->getOrderFields();

				// Find the changed fields and highlight the changes
				foreach ($to as $k=>$v)
				{
					if ($from[$k] != $to[$k])
					{
						if ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['doNotShow'] || $GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['hideInput'])
						{
							continue;
						}

						$blnIsBinary = ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['inputType'] == 'fileTree' || in_array($k, $arrOrder));

						// Decrypt the values
						if ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['encrypt'])
						{
							$to[$k] = \Encryption::decrypt($to[$k]);
							$from[$k] = \Encryption::decrypt($from[$k]);
						}

						// Convert serialized arrays into strings
						if (is_array(($tmp = deserialize($to[$k]))) && !is_array($to[$k]))
						{
							$to[$k] = $this->implodeRecursive($tmp, $blnIsBinary);
						}
						if (is_array(($tmp = deserialize($from[$k]))) && !is_array($from[$k]))
						{
							$from[$k] = $this->implodeRecursive($tmp, $blnIsBinary);
						}

						unset($tmp);

						// Convert binary UUIDs to their hex equivalents (see #6365)
						if ($blnIsBinary && \Validator::isBinaryUuid($to[$k]))
						{
							$to[$k] = \StringUtil::binToUuid($to[$k]);
						}
						if ($blnIsBinary && \Validator::isBinaryUuid($from[$k]))
						{
							$to[$k] = \StringUtil::binToUuid($from[$k]);
						}

						// Convert date fields
						if ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['rgxp'] == 'date')
						{
							$to[$k] = \Date::parse(\Config::get('dateFormat'), $to[$k] ?: '');
							$from[$k] = \Date::parse(\Config::get('dateFormat'), $from[$k] ?: '');
						}
						elseif ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['rgxp'] == 'time')
						{
							$to[$k] = \Date::parse(\Config::get('timeFormat'), $to[$k] ?: '');
							$from[$k] = \Date::parse(\Config::get('timeFormat'), $from[$k] ?: '');
						}
						elseif ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['rgxp'] == 'datim' || $k == 'tstamp')
						{
							$to[$k] = \Date::parse(\Config::get('datimFormat'), $to[$k] ?: '');
							$from[$k] = \Date::parse(\Config::get('datimFormat'), $from[$k] ?: '');
						}

						// Convert strings into arrays
						if (!is_array($to[$k]))
						{
							$to[$k] = explode("\n", $to[$k]);
						}
						if (!is_array($from[$k]))
						{
							$from[$k] = explode("\n", $from[$k]);
						}

						$objDiff = new \Diff($from[$k], $to[$k]);
						$strBuffer .= $objDiff->render(new DiffRenderer(array('field'=>($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['label'][0] ?: (isset($GLOBALS['TL_LANG']['MSC'][$k]) ? (is_array($GLOBALS['TL_LANG']['MSC'][$k]) ? $GLOBALS['TL_LANG']['MSC'][$k][0] : $GLOBALS['TL_LANG']['MSC'][$k]) : $k)))));
					}
				}
			}
		}

		// Identical versions
		if ($strBuffer == '')
		{
			$strBuffer = '<p>'.$GLOBALS['TL_LANG']['MSC']['identicalVersions'].'</p>';
		}

		/** @var \BackendTemplate|object $objTemplate */
		$objTemplate = new \BackendTemplate('be_diff');

		// Template variables
		$objTemplate->content = $strBuffer;
		$objTemplate->versions = $arrVersions;
		$objTemplate->to = $intTo;
		$objTemplate->from = $intFrom;
		$objTemplate->showLabel = specialchars($GLOBALS['TL_LANG']['MSC']['showDifferences']);
		$objTemplate->theme = \Backend::getTheme();
		$objTemplate->base = \Environment::get('base');
		$objTemplate->language = $GLOBALS['TL_LANGUAGE'];
		$objTemplate->title = specialchars($GLOBALS['TL_LANG']['MSC']['showDifferences']);
		$objTemplate->charset = \Config::get('characterSet');
		$objTemplate->action = ampersand(\Environment::get('request'));

		\Config::set('debugMode', false);
		$objTemplate->output();

		exit;
	}


	/**
	 * Render the versions dropdown menu
	 *
	 * @return string
	 */
	public function renderDropdown()
	{
		$objVersion = $this->Database->prepare("SELECT tstamp, version, username, active FROM tl_version WHERE fromTable=? AND pid=? ORDER BY version DESC")
								     ->execute($this->strTable, $this->intPid);

		if ($objVersion->numRows < 2)
		{
			return '';
		}

		$versions = '';

		while ($objVersion->next())
		{
			$versions .= '
  <option value="'.$objVersion->version.'"'.($objVersion->active ? ' selected="selected"' : '').'>'.$GLOBALS['TL_LANG']['MSC']['version'].' '.$objVersion->version.' ('.\Date::parse(\Config::get('datimFormat'), $objVersion->tstamp).') '.$objVersion->username.'</option>';
		}

		return '
<div class="tl_version_panel">

<form action="'.ampersand(\Environment::get('request'), true).'" id="tl_version" class="tl_form" method="post">
<div class="tl_formbody">
<input type="hidden" name="FORM_SUBMIT" value="tl_version">
<input type="hidden" name="REQUEST_TOKEN" value="'.REQUEST_TOKEN.'">
<select name="version" class="tl_select">'.$versions.'
</select>
<input type="submit" name="showVersion" id="showVersion" class="tl_submit" value="'.specialchars($GLOBALS['TL_LANG']['MSC']['restore']).'">
<a href="'.\Backend::addToUrl('versions=1&amp;popup=1').'" title="'.specialchars($GLOBALS['TL_LANG']['MSC']['showDifferences']).'" onclick="Backend.openModalIframe({\'width\':768,\'title\':\''.specialchars(str_replace("'", "\\'", $GLOBALS['TL_LANG']['MSC']['showDifferences'])).'\',\'url\':this.href});return false">'.\Image::getHtml('diff.gif').'</a>
</div>
</form>

</div>
';
	}


	/**
	 * Add a list of versions to a template
	 *
	 * @param \BackendTemplate|object $objTemplate
	 */
	public static function addToTemplate(\BackendTemplate $objTemplate)
	{
		$arrVersions = array();

		$objUser = \BackendUser::getInstance();
		$objDatabase = \Database::getInstance();

		// Get the total number of versions
		$objTotal = $objDatabase->prepare("SELECT COUNT(*) AS count FROM tl_version WHERE version>1" . (!$objUser->isAdmin ? " AND userid=?" : ""))
								->execute($objUser->id);

		$intLast   = ceil($objTotal->count / 30);
		$intPage   = (\Input::get('vp') !== null) ? \Input::get('vp') : 1;
		$intOffset = ($intPage - 1) * 30;

		// Validate the page number
		if ($intPage < 1 || ($intLast > 0 && $intPage > $intLast))
		{
			header('HTTP/1.1 404 Not Found');
		}

		// Create the pagination menu
		$objPagination = new \Pagination($objTotal->count, 30, 7, 'vp', new \BackendTemplate('be_pagination'));
		$objTemplate->pagination = $objPagination->generate();

		// Get the versions
		$objVersions = $objDatabase->prepare("SELECT pid, tstamp, version, fromTable, username, userid, description, editUrl, active FROM tl_version" . (!$objUser->isAdmin ? " WHERE userid=?" : "") . " ORDER BY tstamp DESC, pid, version DESC")
								   ->limit(30, $intOffset)
								   ->execute($objUser->id);

		while ($objVersions->next())
		{
			$arrRow = $objVersions->row();

			// Add some parameters
			$arrRow['from'] = max(($objVersions->version - 1), 1); // see #4828
			$arrRow['to'] = $objVersions->version;
			$arrRow['date'] = date(\Config::get('datimFormat'), $objVersions->tstamp);
			$arrRow['description'] = \StringUtil::substr($arrRow['description'], 32);
			$arrRow['shortTable'] = \StringUtil::substr($arrRow['fromTable'], 18); // see #5769

			if ($arrRow['editUrl'] != '')
			{
				$arrRow['editUrl'] = preg_replace('/&(amp;)?rt=[a-f0-9]+/', '&amp;rt=' . REQUEST_TOKEN, ampersand($arrRow['editUrl']));
			}

			$arrVersions[] = $arrRow;
		}

		$intCount = -1;
		$arrVersions = array_values($arrVersions);

		// Add the "even" and "odd" classes
		foreach ($arrVersions as $k=>$v)
		{
			$arrVersions[$k]['class'] = (++$intCount % 2 == 0) ? 'even' : 'odd';

			try
			{
				// Mark deleted versions (see #4336)
				$objDeleted = $objDatabase->prepare("SELECT COUNT(*) AS count FROM " . $v['fromTable'] . " WHERE id=?")
										  ->execute($v['pid']);

				$arrVersions[$k]['deleted'] = ($objDeleted->count < 1);
			}
			catch (\Exception $e)
			{
				// Probably a disabled module
				--$intCount;
				unset($arrVersions[$k]);
			}

			// Skip deleted files (see #8480)
			if ($v['fromTable'] == 'tl_files' && $arrVersions[$k]['deleted'])
			{
				--$intCount;
				unset($arrVersions[$k]);
			}
		}

		$objTemplate->versions = $arrVersions;
	}


	/**
	 * Return the edit URL
	 *
	 * @return string
	 */
	protected function getEditUrl()
	{
		if ($this->strEditUrl !== null)
		{
			return sprintf($this->strEditUrl, $this->intPid);
		}

		$strUrl = \Environment::get('request');

		// Save the real edit URL if the visibility is toggled via Ajax
		if (preg_match('/&(amp;)?state=/', $strUrl))
		{
			$strUrl = preg_replace
			(
				array('/&(amp;)?id=[^&]+/', '/(&(amp;)?)t(id=[^&]+)/', '/(&(amp;)?)state=[^&]*/'),
				array('', '$1$3', '$1act=edit'), $strUrl
			);
		}

		// Adjust the URL of the "personal data" module (see #7987)
		if (preg_match('/do=login(&|$)/', $strUrl))
		{
			$strUrl = preg_replace('/do=login(&|$)/', 'do=user$1', $strUrl);
			$strUrl .= '&amp;act=edit&amp;id=' . $this->User->id . '&amp;rt=' . REQUEST_TOKEN;
		}

		// Correct the URL in "edit|override multiple" mode (see #7745)
		$strUrl = preg_replace('/act=(edit|override)All/', 'act=edit&id=' . $this->intPid, $strUrl);

		return $strUrl;
	}


	/**
	 * Return the username
	 *
	 * @return string
	 */
	protected function getUsername()
	{
		if ($this->strUsername !== null)
		{
			return $this->strUsername;
		}

		$this->import('BackendUser', 'User');

		return $this->User->username;
	}


	/**
	 * Return the user ID
	 *
	 * @return string
	 */
	protected function getUserId()
	{
		if ($this->intUserId !== null)
		{
			return $this->intUserId;
		}

		$this->import('BackendUser', 'User');

		return $this->User->id;
	}


	/**
	 * Implode a multi-dimensional array recursively
	 *
	 * @param mixed   $var
	 * @param boolean $binary
	 *
	 * @return string
	 */
	protected function implodeRecursive($var, $binary=false)
	{
		if (!is_array($var))
		{
			return $binary ? \StringUtil::binToUuid($var) : $var;
		}
		elseif (!is_array(current($var)))
		{
			if ($binary)
			{
				$var = array_map(function ($v) { return $v ? \StringUtil::binToUuid($v) : ''; }, $var);
			}

			return implode(', ', $var);
		}
		else
		{
			$buffer = '';

			foreach ($var as $k=>$v)
			{
				$buffer .= $k . ": " . $this->implodeRecursive($v) . "\n";
			}

			return trim($buffer);
		}
	}
}
