<?php
namespace TYPO3\CMS\Frontend\Hooks;

/**
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

use TYPO3\CMS\Backend\Utility\BackendUtility;

/**
 * Class that hooks into TCEmain and listens for updates to pages to update the
 * treelist cache
 *
 * @author Ingo Renner <ingo@typo3.org>
 */
class TreelistCacheUpdateHooks {

	// Should not be manipulated from others except through the
	// configuration provided @see __construct()
	private $updateRequiringFields = array(
		'pid',
		'php_tree_stop',
		'extendToSubpages'
	);

	/**
	 * Constructor, adds update requiring fields to the default ones
	 */
	public function __construct() {
		// As enableFields can be set dynamically we add them here
		$pagesEnableFields = $GLOBALS['TCA']['pages']['ctrl']['enablecolumns'];
		foreach ($pagesEnableFields as $pagesEnableField) {
			$this->updateRequiringFields[] = $pagesEnableField;
		}
		$this->updateRequiringFields[] = $GLOBALS['TCA']['pages']['ctrl']['delete'];
		// Extension can add fields to the pages table that require an
		// update of the treelist cache, too; so we also add those
		// example: $TYPO3_CONF_VARS['BE']['additionalTreelistUpdateFields'] .= ',my_field';
		if (!empty($GLOBALS['TYPO3_CONF_VARS']['BE']['additionalTreelistUpdateFields'])) {
			$additionalTreelistUpdateFields = \TYPO3\CMS\Core\Utility\GeneralUtility::trimExplode(',', $GLOBALS['TYPO3_CONF_VARS']['BE']['additionalTreelistUpdateFields'], TRUE);
			$this->updateRequiringFields = array_merge($this->updateRequiringFields, $additionalTreelistUpdateFields);
		}
	}

	/**
	 * waits for TCEmain commands and looks for changed pages, if found further
	 * changes take place to determine whether the cache needs to be updated
	 *
	 * @param string $status TCEmain operation status, either 'new' or 'update'
	 * @param string $table The DB table the operation was carried out on
	 * @param mixed $recordId The record's uid for update records, a string to look the record's uid up after it has been created
	 * @param array $updatedFields Array of changed fiels and their new values
	 * @param \TYPO3\CMS\Core\DataHandling\DataHandler $tceMain TCEmain parent object
	 * @return void
	 */
	public function processDatamap_afterDatabaseOperations($status, $table, $recordId, array $updatedFields, \TYPO3\CMS\Core\DataHandling\DataHandler $tceMain) {
		if ($table == 'pages' && $this->requiresUpdate($updatedFields)) {
			$affectedPagePid = 0;
			$affectedPageUid = 0;
			if ($status == 'new') {
				// Detect new pages
				// Resolve the uid
				$affectedPageUid = $tceMain->substNEWwithIDs[$recordId];
				$affectedPagePid = $updatedFields['pid'];
			} elseif ($status == 'update') {
				// Detect updated pages
				$affectedPageUid = $recordId;
				// When updating a page the pid is not directly available so we
				// need to retrieve it ourselves.
				$fullPageRecord = BackendUtility::getRecord($table, $recordId);
				$affectedPagePid = $fullPageRecord['pid'];
			}
			$clearCacheActions = $this->determineClearCacheActions($status, $updatedFields);
			$this->processClearCacheActions($affectedPageUid, $affectedPagePid, $updatedFields, $clearCacheActions);
		}
	}

	/**
	 * Waits for DataHandler commands and looks for deleted pages or swapped pages, if found
	 * further changes take place to determine whether the cache needs to be updated
	 *
	 * @param string $command The TCE command
	 * @param string $table The record's table
	 * @param integer $recordId The record's uid
	 * @param array $commandValue The commands value, typically an array with more detailed command information
	 * @param \TYPO3\CMS\Core\DataHandling\DataHandler $tceMain The TCEmain parent object
	 * @return void
	 */
	public function processCmdmap_postProcess($command, $table, $recordId, $commandValue, \TYPO3\CMS\Core\DataHandling\DataHandler $tceMain) {
		$action = (is_array($commandValue) && isset($commandValue['action'])) ? (string)$commandValue['action'] : '';
		if ($table === 'pages' && ($command === 'delete' || ($command === 'version' && $action === 'swap'))) {

			$affectedRecord = BackendUtility::getRecord($table, $recordId, '*', '', FALSE);
			$affectedPageUid = $affectedRecord['uid'];
			$affectedPagePid = $affectedRecord['pid'];

			// Faking the updated fields
			$updatedFields = array();
			if ($command === 'delete') {
				$updatedFields['deleted'] = 1;
			} else {
				// page was published to live (swapped)
				$updatedFields['t3ver_wsid'] = 0;
			}
			$clearCacheActions = $this->determineClearCacheActions(
				'update',
				$updatedFields
			);

			$this->processClearCacheActions($affectedPageUid, $affectedPagePid, $updatedFields, $clearCacheActions);
		}
	}

	/**
	 * waits for TCEmain commands and looks for moved pages, if found further
	 * changes take place to determine whether the cache needs to be updated
	 *
	 * @param string $table Table name of the moved record
	 * @param integer $recordId The record's uid
	 * @param integer $destinationPid The record's destination page id
	 * @param array $movedRecord The record that moved
	 * @param array $updatedFields Array of changed fields
	 * @param \TYPO3\CMS\Core\DataHandling\DataHandler $tceMain TCEmain parent object
	 * @return void
	 */
	public function moveRecord_firstElementPostProcess($table, $recordId, $destinationPid, array $movedRecord, array $updatedFields, \TYPO3\CMS\Core\DataHandling\DataHandler $tceMain) {
		if ($table == 'pages' && $this->requiresUpdate($updatedFields)) {
			$affectedPageUid = $recordId;
			$affectedPageOldPid = $movedRecord['pid'];
			$affectedPageNewPid = $updatedFields['pid'];
			$clearCacheActions = $this->determineClearCacheActions('update', $updatedFields);
			// Clear treelist entries for old parent page
			$this->processClearCacheActions($affectedPageUid, $affectedPageOldPid, $updatedFields, $clearCacheActions);
			// Clear treelist entries for new parent page
			$this->processClearCacheActions($affectedPageUid, $affectedPageNewPid, $updatedFields, $clearCacheActions);
		}
	}

	/**
	 * Waits for TCEmain commands and looks for moved pages, if found further
	 * changes take place to determine whether the cache needs to be updated
	 *
	 * @param string $table Table name of the moved record
	 * @param integer $recordId The record's uid
	 * @param integer $destinationPid The record's destination page id
	 * @param integer $originalDestinationPid (negative) page id th page has been moved after
	 * @param array $movedRecord The record that moved
	 * @param array $updatedFields Array of changed fields
	 * @param \TYPO3\CMS\Core\DataHandling\DataHandler $tceMain TCEmain parent object
	 * @return void
	 */
	public function moveRecord_afterAnotherElementPostProcess($table, $recordId, $destinationPid, $originalDestinationPid, array $movedRecord, array $updatedFields, \TYPO3\CMS\Core\DataHandling\DataHandler $tceMain) {
		if ($table == 'pages' && $this->requiresUpdate($updatedFields)) {
			$affectedPageUid = $recordId;
			$affectedPageOldPid = $movedRecord['pid'];
			$affectedPageNewPid = $updatedFields['pid'];
			$clearCacheActions = $this->determineClearCacheActions('update', $updatedFields);
			// Clear treelist entries for old parent page
			$this->processClearCacheActions($affectedPageUid, $affectedPageOldPid, $updatedFields, $clearCacheActions);
			// Clear treelist entries for new parent page
			$this->processClearCacheActions($affectedPageUid, $affectedPageNewPid, $updatedFields, $clearCacheActions);
		}
	}

	/**
	 * Checks whether the change requires an update of the treelist cache
	 *
	 * @param array $updatedFields Array of changed fields
	 * @return boolean TRUE if the treelist cache needs to be updated, FALSE if no update to the cache is required
	 */
	protected function requiresUpdate(array $updatedFields) {
		$requiresUpdate = FALSE;
		$updatedFieldNames = array_keys($updatedFields);
		foreach ($updatedFieldNames as $updatedFieldName) {
			if (in_array($updatedFieldName, $this->updateRequiringFields)) {
				$requiresUpdate = TRUE;
				break;
			}
		}
		return $requiresUpdate;
	}

	/**
	 * Calls the cache maintainance functions according to the determined actions
	 *
	 * @param integer $affectedPage uid of the affected page
	 * @param integer $affectedParentPage parent uid of the affected page
	 * @param array $updatedFields Array of updated fields and their new values
	 * @param array $actions Array of actions to carry out
	 * @return void
	 */
	protected function processClearCacheActions($affectedPage, $affectedParentPage, $updatedFields, array $actions) {
		$actionNames = array_keys($actions);
		foreach ($actionNames as $actionName) {
			switch ($actionName) {
				case 'allParents':
					$this->clearCacheForAllParents($affectedParentPage);
					break;
				case 'setExpiration':
					// Only used when setting an end time for a page
					$expirationTime = $updatedFields['endtime'];
					$this->setCacheExpiration($affectedPage, $expirationTime);
					break;
				case 'uidInTreelist':
					$this->clearCacheWhereUidInTreelist($affectedPage);
					break;
			}
		}
		// From time to time clean the cache from expired entries
		// (theoretically every 1000 calls)
		$randomNumber = rand(1, 1000);
		if ($randomNumber == 500) {
			$this->removeExpiredCacheEntries();
		}
	}

	/**
	 * Clears the treelist cache for all parents of a changed page.
	 * gets called after creating a new page and after moving a page
	 *
	 * @param integer $affectedParentPage Parent page id of the changed page, the page to start clearing from
	 * @return void
	 */
	protected function clearCacheForAllParents($affectedParentPage) {
		$rootline = BackendUtility::BEgetRootLine($affectedParentPage);
		$rootlineIds = array();
		foreach ($rootline as $page) {
			if ($page['uid'] != 0) {
				$rootlineIds[] = $page['uid'];
			}
		}
		if (!empty($rootlineIds)) {
			$rootlineIdsImploded = implode(',', $rootlineIds);
			$GLOBALS['TYPO3_DB']->exec_DELETEquery('cache_treelist', 'pid IN(' . $rootlineIdsImploded . ')');
		}
	}

	/**
	 * Clears the treelist cache for all pages where the affected page is found
	 * in the treelist
	 *
	 * @param integer $affectedPage ID of the changed page
	 * @return void
	 */
	protected function clearCacheWhereUidInTreelist($affectedPage) {
		$GLOBALS['TYPO3_DB']->exec_DELETEquery('cache_treelist', $GLOBALS['TYPO3_DB']->listQuery('treelist', $affectedPage, 'cache_treelist'));
	}

	/**
	 * Sets an expiration time for all cache entries having the changed page in
	 * the treelist.
	 *
	 * @param integer $affectedPage Uid of the changed page
	 * @param integer $expirationTime
	 * @return void
	 */
	protected function setCacheExpiration($affectedPage, $expirationTime) {
		$GLOBALS['TYPO3_DB']->exec_UPDATEquery('cache_treelist', $GLOBALS['TYPO3_DB']->listQuery('treelist', $affectedPage, 'cache_treelist'), array(
			'expires' => $expirationTime
		));
	}

	/**
	 * Removes all expired treelist cache entries
	 *
	 * @return void
	 */
	protected function removeExpiredCacheEntries() {
		$GLOBALS['TYPO3_DB']->exec_DELETEquery('cache_treelist', 'expires <= ' . $GLOBALS['EXEC_TIME']);
	}

	/**
	 * Determines what happened to the page record, this is necessary to clear
	 * as less cache entries as needed later
	 *
	 * @param string $status TCEmain operation status, either 'new' or 'update'
	 * @param array $updatedFields Array of updated fields
	 * @return string List of actions that happened to the page record
	 */
	protected function determineClearCacheActions($status, $updatedFields) {
		$actions = array();
		if ($status == 'new') {
			// New page
			$actions['allParents'] = TRUE;
		} elseif ($status == 'update') {
			$updatedFieldNames = array_keys($updatedFields);
			foreach ($updatedFieldNames as $updatedFieldName) {
				switch ($updatedFieldName) {
					case 'pid':

					case $GLOBALS['TCA']['pages']['ctrl']['enablecolumns']['disabled']:

					case $GLOBALS['TCA']['pages']['ctrl']['delete']:

					case $GLOBALS['TCA']['pages']['ctrl']['enablecolumns']['starttime']:

					case $GLOBALS['TCA']['pages']['ctrl']['enablecolumns']['fe_group']:

					case 'extendToSubpages':

					case 't3ver_wsid':

					case 'php_tree_stop':
						// php_tree_stop
						$actions['allParents'] = TRUE;
						$actions['uidInTreelist'] = TRUE;
						break;
					case $GLOBALS['TCA']['pages']['ctrl']['enablecolumns']['endtime']:
						// end time set/unset
						// When setting an end time the cache entry needs an
						// expiration time. When unsetting the end time the
						// page must become listed in the treelist again.
						if ($updatedFields['endtime'] > 0) {
							$actions['setExpiration'] = TRUE;
						} else {
							$actions['uidInTreelist'] = TRUE;
						}
						break;
					default:
						if (in_array($updatedFieldName, $this->updateRequiringFields)) {
							$actions['uidInTreelist'] = TRUE;
						}
				}
			}
		}
		return $actions;
	}

}
