<?php namespace ProcessWire;

/**
 * ProcessWire Pages Trash
 *
 * Implements page trash/restore/empty methods of the $pages API variable
 *
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 *
 */

class PagesTrash extends Wire {

	/**
	 * @var Pages
	 * 
	 */
	protected $pages;

	/**
	 * Construct
	 *
	 * @param Pages $pages
	 * 
	 */
	public function __construct(Pages $pages) {
		parent::__construct();
		$this->pages = $pages;
	}
	
	/**
	 * Move a page to the trash
	 *
	 * If you have already set the parent to somewhere in the trash, then this method won't attempt to set it again.
	 *
	 * @param Page $page
	 * @param bool $save Set to false if you will perform the save() call, as is the case when called from the Pages::save() method.
	 * @return bool
	 * @throws WireException
	 *
	 */
	public function trash(Page $page, $save = true) {
		
		if(!$this->pages->isDeleteable($page) || $page->template->noTrash) {
			throw new WireException("This page (id=$page->id) may not be placed in the trash");
		}
		
		$trash = $this->pages->get($this->config->trashPageID);
		if(!$trash->id) {
			throw new WireException("Unable to load trash page defined by config::trashPageID");
		}
		
		$this->pages->trashReady($page);
		
		$page->addStatus(Page::statusTrash);
		
		if(!$page->parent->isTrash()) {
			$parentPrevious = $page->parent;
			$page->parent = $trash;
		} else if($page->parentPrevious && $page->parentPrevious->id != $page->parent->id) {
			$parentPrevious = $page->parentPrevious;
		} else {
			$parentPrevious = null;
		}
		
		$nameInfo = $this->parseTrashPageName($page->name);
		
		if(!$nameInfo || $nameInfo['id'] != $page->id) {
			// make the name unique when in trash, to avoid namespace collision and maintain parent restore info
			$name = $page->id;
			if($parentPrevious && $parentPrevious->id) {
				$name .= "." . $parentPrevious->id;
				$name .= "." . $page->sort;
			}
			$page->name = ($name . "_" . $page->name);
		
			// do the same for other languages, if present
			$languages = $this->wire()->languages;
			if($languages && $languages->hasPageNames()) {
				foreach($languages as $language) {
					if($language->isDefault()) continue;
					$langName = (string) $page->get("name$language->id");
					if(!strlen($langName)) continue; 
					$page->set("name$language->id", $name . "_" . $langName);
				}
			}
		}
		
		if($save) $this->pages->save($page);
		$this->pages->editor()->savePageStatus($page->id, Page::statusTrash, true, false);
		if($save) $this->pages->trashed($page);
		$this->pages->debugLog('trash', $page, true);
		
		return true;
	}

	/**
	 * Restore a page from the trash back to a non-trash state
	 *
	 * Note that this method assumes already have set a new parent, but have not yet saved.
	 * If you do not set a new parent, then it will restore to the original parent, when possible.
	 *
	 * @param Page $page
	 * @param bool $save Set to false if you only want to prep the page for restore (i.e. being saved elsewhere)
	 * @return bool
	 *
	 */
	public function restore(Page $page, $save = true) {
		$info = $this->getRestoreInfo($page, true);

		if($info['restorable']) {
			// we detected original parent
			if($save) $page->save();
			
		} else if(!$page->parent->isTrash()) {
			// page has had new parent already set
			$page->removeStatus(Page::statusTrash);
			if($save) $page->save();
			$this->pages->editor()->savePageStatus($page->id, Page::statusTrash, true, true);
			if($save) $this->pages->restored($page);
			$this->pages->debugLog('restore', $page, true);
		} else {
			// page is in trash and we cannot detect new parent
			return false;
		}
		
		return true;
	}
	
	/**
	 * Get info needed to restore a Page that is in the trash
	 *
	 * Returns array with the following info:
	 *  - `restorable` (bool): Is the page restorable to a previous known/existing parent?
	 *  - `notes` (array): Any additional notes to explain restore info (like reason why not restorable, or why name changed, etc.)
	 *  - `parent` (Page|NullPage): Parent page that it should restore to
	 *  - `parent_id` (int): ID of parent page that it should restore to
	 *  - `sort` (int): Sort order that should be restored to page
	 *  - `name` (string): Name that should be restored to page’s “name” property.
	 *  - `namePrevious` (string): Previous name, if we had to modify the original name to make it restorable. 
	 *  - `name{id}` (string): Name that should be restored  to language where {id} is language ID (if appliable).
	 *
	 * @param Page $page Page to restore
	 * @param bool $populateToPage Populate this information to given page? (default=false)
	 * @return array
	 *
	 */
	public function getRestoreInfo(Page $page, $populateToPage = false) {

		$info = array(
			'restorable' => false,
			'notes' => array(), 
			'parent' => $this->pages->newNullPage(),
			'parent_id' => 0,
			'sort' => 0,
			'name' => '',
			'namePrevious' => '',
		);
		
		$languages = $this->wire()->languages;
		if(!$languages || !$languages->hasPageNames()) $languages = array();
		
		// initialize name properties in $info for each language 
		foreach($languages as $language) {
			$info["name$language->id"] = '';
		}
		
		$result = $this->parseTrashPageName($page->name);

		if(!$result || $result['id'] !== $page->id) {
			// page does not have restore info
			$info['notes'][] = 'Page name does not contain restore information';
			return $info;
		}
	
		$name = $result['name'];
		$trashPrefix = $result['prefix']; // pageID.parentID.sort_ prefix for testing other language names later
		$newParent = null; // auto-detected new parent, or null if new parent already on $page
		$parentID = $result['parent_id'];
		$sort = $result['sort'];

		if($parentID && $parentID != $page->id) { 
			if($page->rootParent()->isTrash()) {
				// no new parent was defined, so use the one in the page name
				$newParent = $this->pages->get($parentID);
				if(!$newParent->id) {
					$newParent = null;
					$info['notes'][] = 'Original parent no longer exists';
				}
			} else {
				$info['notes'][] = 'Page root parent is not trash or page already has new parent';
			}
			
		} else if($parentID) {
			$info['notes'][] = "Invalid parent ID: $parentID";
			
		} else {
			// page was likely trashed a long time ago, before this info was stored
			$info['notes'][] = 'Page name does not contain previous parent or sort info';
		}

		$info['parent'] = $newParent ? $newParent : $this->pages->newNullPage();
		$info['parent_id'] = $parentID;
		$info['sort'] = $sort;
		
		$namePrevious = $name;
		$nameParent = $newParent ? $newParent : $page->parent;

		if($newParent || $this->pages->count("parent=$nameParent, name=$name, id!=$page->id, include=all")) {
			// check if there is already a page at the restore location with the same name
			$name = $this->pages->names()->uniquePageName($name, $page, array('parent' => $nameParent));
			if($name !== $namePrevious) {
				$info['notes'][] = "Name changed from '$namePrevious' to '$name' to be unique in new parent";
				$info['namePrevious'] = $namePrevious;
			}
		}
		
		$info['name'] = $name;
		$info['restorable'] = $newParent !== null;
		
		if($populateToPage) {
			$page->name = $name;
			if($newParent) {
				$page->sort = $sort;
				$page->parent = $newParent;
			}
		}

		// do the same for other languages, when applicable
		foreach($languages as $language) {
			/** @var Language $language */
			if($language->isDefault()) continue;
			$langKey = "name$language->id";
			$langName = (string) $page->get($langKey);
			if(!strlen($langName)) continue;
			if(strpos($langName, $trashPrefix) === 0) {
				list(,$langName) = explode('_', $langName);
			}
			$langNamePrevious = $langName;
			if($this->pages->count("parent=$nameParent, $langKey=$langName, id!=$page->id, include=all")) {
				$langName = $this->pages->names()->uniquePageName($langName, $page, array(
					'parent' => $nameParent,
					'language' => $language
				));
				if($populateToPage) $page->set($langKey, $langName);
			}
			$info[$langKey] = $langName;
			if($langName !== $langNamePrevious) {
				$info['notes'][] = $language->get('title|name') . ' ' . 
					"name changed from '$langNamePrevious' to '$langName' to be unique in new parent";
			}
		}
		
		return $info;
	}

	/**
	 * Parse a trashed page name into an array of its components
	 * 
	 * @param string $name
	 * @return array|bool Returns array of info if name is a trash/restore name, or boolean false if not
	 * 
	 */
	public function parseTrashPageName($name) {
		
		$info = array(
			'id' => 0,
			'parent_id' => 0, 
			'sort' => 0, 
			'name' => $name,
			'prefix' => '', 
			'note' => '', 
		);
		
		// match "pageID.parentID.sort_name" in page name (1).(2.2)_3
		if(!preg_match('/^(\d+)((?:\.\d+\.\d+)?)_(.+)$/', $name, $matches)) return false;
		
		$info['id'] = (int) $matches[1];
		$info['name'] = $matches[3];
		
		if($matches[2]) {
			// matches[2] contains ".parentID.sort"
			list(, $parentID, $sort) = explode('.', $matches[2]);
			$info['parent_id'] = (int) $parentID;
			$info['sort'] = (int) $sort;
		} else {
			// page was likely trashed a long time ago, before this info was stored
			$info['note'] = 'Page name does not contain previous parent or sort info';
		}
		
		// pageID.parentID.sort_ prefix that can be used with other language names
		$info['prefix'] = $matches[1] . $matches[2] . '_'; 
		
		return $info;
	}

	/**
	 * Delete all pages in the trash
	 *
	 * Populates error notices when there are errors deleting specific pages.
	 *
	 * @param array $options
	 *  - `chunkSize` (int): Pages will be deleted in chunks of this many pages per chunk (default=100).
	 *  - `chunkTimeLimit` (int): Maximum seconds allowed to process deletion of each chunk (default=600). 
	 *  - `chunkLimit' (int): Maximum chunks to process in an emptyTrash() call (default=1000);
	 *  - `pageLimit` (int): Maximum pages to delete per emptyTrash() call (default=0, no limit).
	 *  - `timeLimit` (int): Maximum time (in seconds) to allow for trash empty (default=3600). 
	 *  - `pass2` (bool): Perform a secondary pass using alternate method as a backup? (default=true)
	 *     Note: pass2 is always disabled when a pageLimit is in use or timeLimit has been exceeded. 
	 *  - `verbose` (bool): Return verbose array of information about the trash empty process? For debug/dev purposes (default=false)
	 * @return int|array Returns integer (default) or array in verbose mode. 
	 *  - By default, returns total number of pages deleted from trash. This number is negative or 0 if not 
	 *    all pages could be deleted and error notices may be present.
	 *  - Returns associative array with verbose information if verbose option is chosen. 
	 *
	 */
	public function emptyTrash(array $options = array()) {

		$defaults = array(
			'chunkSize' => 100,
			'chunkTimeLimit' => 600, 
			'chunkLimit' => 100, 
			'pageLimit' => 0, 
			'timeLimit' => 3600, 
			'pass2' => true, 
			'verbose' => false, 
		);
		
		$options = array_merge($defaults, $options);
		$trashPage = $this->getTrashPage();
		$masterSelector = "include=all, children.count=0, status=" . Page::statusTrash;
		$totalDeleted = 0;
		$lastTotalInTrash = 0;
		$chunkCnt = 0;
		$errorCnt = 0;
		$nonTrashIDs = array(); // page IDs that had trash status but did not have trash parent
		$result = array();
		$timer = $options['verbose'] ? Debug::timer() : null;
		$startTime = time();
		$stopTime = $options['timeLimit'] ? $startTime + $options['timeLimit'] : false;
		$stopNow = false;
		$database = $this->wire()->database;
		$useTransaction = $database->supportsTransaction();
		$options['stopTime'] = $stopTime; // for pass2
		$timeExpired = false;
		$onlyDirectChildren = true; // limit to direct children at first
		
		if($options['chunkTimeLimit'] > $options['timeLimit']) {
			$options['chunkTimeLimit'] = $options['timeLimit'];
		}
		
		// Empty trash pass1:
		// Operates by finding pages in trash using Page::statusTrash that have no children
		do {
			$selector = $masterSelector;
			
			if($options['chunkTimeLimit']) {
				set_time_limit($options['chunkTimeLimit']);
			}
			
			if(count($nonTrashIDs)) {
				$selector .= ", id!=" . implode('|', $nonTrashIDs);
			}

			if($onlyDirectChildren) {
				// limit to direct children of trash page that themselves have no children
				$selector .= ", parent_id=$trashPage->id";
			} else {
				$totalInTrash = $this->pages->count($selector);
				if(!$totalInTrash || $totalInTrash == $lastTotalInTrash) break;
				$lastTotalInTrash = $totalInTrash;
			}
			
			if($options['chunkSize'] > 0) {
				$selector .= ", limit=$options[chunkSize]";
			}
			
			$items = $this->pages->find($selector);
			$numItems = $items->count();
			$totalItems = $items->getTotal();
			$numDeleted = 0;
			
			if($useTransaction) $database->beginTransaction();
			
			foreach($items as $item) {
			
				// determine if any limits have been reached
				if($stopTime && time() > $stopTime) {
					$stopNow = true;
					$timeExpired = true;
				}
				if($options['pageLimit'] && $totalDeleted >= $options['pageLimit']) {
					$stopNow = true;
				}
				if($stopNow) break;
				
				// if page does not have trash as a parent, then this is a page with trash status
				// that is somewhere else in the page tree (not likely)
				if(!$onlyDirectChildren && $item->rootParent()->id !== $trashPage->id) {
					$nonTrashIDs[$item->id] = $item->id;
					$errorCnt++;
					continue;
				}
			
				// delete the page
				try {
					$numDeleted += $this->pages->delete($item, true);
				} catch(\Exception $e) {
					$this->error($e->getMessage());
					$errorCnt++;
				}
			}

			$totalDeleted += $numDeleted;
			if($useTransaction) $database->commit();
			$this->pages->uncacheAll();
			
			if($options['chunkLimit'] && $chunkCnt >= $options['chunkLimit']) {
				// if chunk limit exceeded then stop now
				$stopNow = true;
				
			} else if($onlyDirectChildren) {
				// move past direct children next if all were loaded in this chunk
				if($totalItems === $numItems || !$numDeleted) $onlyDirectChildren = false;
				
			} else if(!$numDeleted) {
				// if no items deleted (and we're beyond direct children), we should stop now
				$stopNow = true;
			}
			
			if(!$stopNow) $chunkCnt++;
			
		} while(!$stopNow);
		
		// if recording verbose info, populate it for pass1 now
		if($options['verbose']) {
			$result['pass1_cnt'] = $chunkCnt;
			$result['pass1_numDeleted'] = $totalDeleted;
			$result['pass1_numErrors'] = $errorCnt;
			$result['pass1_elapsedTime'] = Debug::timer($timer);
			$result['pass1_timeExpired'] = $timeExpired;
		}
		
		if(count($nonTrashIDs)) {
			// remove trash status from the pages that should not have it
			$this->pages->editor()->savePageStatus($nonTrashIDs, Page::statusTrash, false, true);
		}

		// Empty trash pass2:
		// Operates by finding pages that are children of the Trash and performing recursive delete upon them
		if($options['pass2'] && !$stopNow && !$options['pageLimit']) {
			if($useTransaction) $database->beginTransaction();
			$totalDeleted += $this->emptyTrashPass2($options, $result);
			if($useTransaction) $database->commit();
		}
			
		if($totalDeleted || $options['verbose']) {
			$numTrashChildren = $this->pages->trasher()->getTrashTotal();
			// return a negative number if pages still remain in trash
			if($numTrashChildren && !$options['verbose']) $totalDeleted = $totalDeleted * -1;
		} else {
			$numTrashChildren = 0;
		}
		
		if($options['verbose']) {
			$result['startTime'] = $startTime;
			$result['elapsedTime'] = Debug::timer($timer);
			$result['pagesPerSecond'] = $totalDeleted ? round($totalDeleted / $result['elapsedTime'], 2) : 0;
			$result['timeExpired'] = !empty($result['pass1_timeExpired']) || !empty($result['pass2_timeExpired']);
			$result['numDeleted'] = $totalDeleted;
			$result['numRemain'] = $numTrashChildren;
			$result['numErrors'] = $errorCnt;
			$result['numMispaced'] = count($nonTrashIDs);
			$result['idsMisplaced'] = $nonTrashIDs;
			$result['options'] = $options;
			return $result;
		}

		return $totalDeleted;
	}

	/**
	 * Secondary pass for trash deletion
	 * 
	 * This works by finding the children of the trash page and performing a recursive delete on them.
	 * 
	 * @param array $options Options passed to emptyTrash() method
	 * @param array $result Verbose array, modified directly
	 * @return int
	 * 
	 */
	protected function emptyTrashPass2(array $options, &$result) {
		
		if($options['chunkTimeLimit']) {
			set_time_limit($options['chunkTimeLimit']);
		}

		$timer = $options['verbose'] ? Debug::timer() : null;
		$numErrors = 0;
		$numDeleted = 0;
		$timeExpired = false;
		$trashPage = $this->getTrashPage();
		$trashPages = $trashPage->children("include=all");

		foreach($trashPages as $t) {
			try {
				// perform recursive delete
				$numDeleted += $this->pages->delete($t, true);
			} catch(\Exception $e) {
				$this->error($e->getMessage());
				$numErrors++;
			}
			if($options['stopTime'] && time() > $options['stopTime']) {
				$timeExpired = true;
				break;
			}
		}

		$this->pages->uncacheAll();

		if($options['verbose']) {
			$result['pass2_numDeleted'] = $numDeleted;
			$result['pass2_numErrors'] = $numErrors;
			$result['pass2_elapsedTime'] = Debug::timer($timer);
			$result['pass2_timeExpired'] = $timeExpired;
		}

		return $numDeleted;
	}

	/**
	 * Get total number of pages in trash
	 * 
	 * @return int
	 * 
	 */
	public function getTrashTotal() {
		return $this->pages->count("include=all, status=" . Page::statusTrash);
	}

	/**
	 * Return the root parent trash page
	 * 
	 * @return Page
	 * @throws WireException if trash page cannot be located (highly unlikely)
	 * 
	 */
	public function getTrashPage() {
		$trashPageID = $this->wire()->config->trashPageID;
		$trashPage = $this->pages->get((int) $trashPageID);
		if(!$trashPage->id || $trashPage->id != $trashPageID) {
			throw new WireException("Cannot find trash page $trashPageID");
		}
		return $trashPage;
	}

}
