<?php

/**
 * ListModule
 * Superclass of every module, which retrieves a MAPI message list. It
 * extends the Module class.
 */
class ListModule extends Module {
	/**
	 * @var array list of columns which are selected in the previous request
	 */
	public $properties;

	/**
	 * @var array sort
	 */
	public $sort;

	/**
	 * @var int startrow in the table
	 */
	public $start;

	/**
	 * @var array contains (when needed) a restriction used when searching and filtering the records
	 */
	public $restriction;

	/**
	 * @var bool contains check whether a search result is listed or just the contents of a normal folder
	 */
	public $searchFolderList;

	/**
	 * @var array stores entryids and last modification time of
	 *            messages that are already sent to the server
	 */
	public $searchResults;

	/**
	 * @var MAPIMessage resource of the freebusy message which holds
	 *                  information regarding delegation details, this variable will
	 *                  only be populated when user is a delegate
	 */
	public $localFreeBusyMessage;

	/**
	 * @var BinString binary string of PR_MDB_PROVIDER property
	 *                of a store, this variable will only be populated when user is a delegate
	 */
	public $storeProviderGuid;

	/**
	 * Constructor.
	 *
	 * @param int   $id     unique id
	 * @param array $data   list of all actions
	 * @param mixed $events
	 */
	public function __construct($id, $data, $events = false) {
		$this->start = 0;

		$this->restriction = false;
		$this->searchFolderList = false;
		$this->localFreeBusyMessage = false;
		$this->storeProviderGuid = false;

		$this->sort = [
			PR_IMPORTANCE => TABLE_SORT_DESCEND,
			PR_MESSAGE_DELIVERY_TIME => TABLE_SORT_DESCEND,
		];

		parent::__construct($id, $data);
	}

	/**
	 * Executes all the actions in the $data variable.
	 */
	#[Override]
	public function execute() {
		foreach ($this->data as $actionType => $action) {
			if (isset($actionType)) {
				try {
					$store = $this->getActionStore($action);
					$parententryid = $this->getActionParentEntryID($action);
					$entryid = $this->getActionEntryID($action);

					switch ($actionType) {
						case "list":
							$this->getDelegateFolderInfo($store);
							$this->messageList($store, $entryid, $action, $actionType);
							break;

						default:
							$this->handleUnknownActionType($actionType);
					}
				}
				catch (MAPIException|SearchException $e) {
					$this->processException($e, $actionType, $store, $parententryid, $entryid, $action);
				}
			}
		}
	}

	/**
	 * Function does customization of MAPIException based on module data.
	 * like, here it will generate display message based on actionType
	 * for particular exception.
	 *
	 * @param object     $e             Exception object
	 * @param string     $actionType    the action type, sent by the client
	 * @param MAPIobject $store         store object of the current user
	 * @param string     $parententryid parent entryid of the message
	 * @param string     $entryid       entryid of the message/folder
	 * @param array      $action        the action data, sent by the client
	 */
	#[Override]
	public function handleException(&$e, $actionType = null, $store = null, $parententryid = null, $entryid = null, $action = null) {
		if (is_null($e->displayMessage)) {
			$hexEntryid = $entryid != null ? bin2hex($entryid) : 'null';

			switch ($actionType) {
				case "list":
					if ($e->getCode() == MAPI_E_NO_ACCESS) {
						$e->setDisplayMessage(_("You have insufficient privileges to see the contents of this folder.") . " ({$hexEntryid})");
					}
					else {
						$e->setDisplayMessage(_("Could not load the contents of this folder.") . " ({$hexEntryid})");
					}
					break;
			}
		}

		parent::handleException($e, $actionType, $store, $parententryid, $entryid, $action);
	}

	/**
	 * Function which retrieves a list of messages in a folder.
	 *
	 * @param object $store      MAPI Message Store Object
	 * @param string $entryid    entryid of the folder
	 * @param array  $action     the action data, sent by the client
	 * @param string $actionType the action type, sent by the client
	 */
	public function messageList($store, $entryid, $action, $actionType) {
		$this->searchFolderList = false; // Set to indicate this is not the search result, but a normal folder content

		if (!$store || !$entryid) {
			return;
		}

		// Restriction
		$this->parseRestriction($action);

		// Sort
		$this->parseSortOrder($action, null, true);

		$limit = false;
		if (isset($action['restriction']['limit'])) {
			$limit = $action['restriction']['limit'];
		}
		else {
			$limit = $GLOBALS['settings']->get('zarafa/v1/main/page_size', 50);
		}

		$isSearchFolder = isset($action['search_folder_entryid']);
		$entryid = $isSearchFolder ? hex2bin((string) $action['search_folder_entryid']) : $entryid;

		// Get the table and merge the arrays
		$data = $GLOBALS["operations"]->getTable($store, $entryid, $this->properties, $this->sort, $this->start, $limit, $this->restriction);

		// If the request come from search folder then no need to send folder information
		if (!$isSearchFolder) {
			// Open the folder.
			$folder = mapi_msgstore_openentry($store, $entryid);
			$data["folder"] = [];

			// Obtain some statistics from the folder contents
			$contentcount = mapi_getprops($folder, [PR_CONTENT_COUNT, PR_CONTENT_UNREAD]);
			if (isset($contentcount[PR_CONTENT_COUNT])) {
				$data["folder"]["content_count"] = $contentcount[PR_CONTENT_COUNT];
			}

			if (isset($contentcount[PR_CONTENT_UNREAD])) {
				$data["folder"]["content_unread"] = $contentcount[PR_CONTENT_UNREAD];
			}
		}

		$data = $this->filterPrivateItems($data);

		// Allowing to hook in just before the data sent away to be sent to the client
		$GLOBALS['PluginManager']->triggerHook('server.module.listmodule.list.after', [
			'moduleObject' => &$this,
			'store' => $store,
			'entryid' => $entryid,
			'action' => $action,
			'data' => &$data,
		]);

		// unset will remove the value but will not regenerate array keys, so we need to
		// do it here
		$data["item"] = array_values($data["item"]);

		if (isset($action['use_searchfolder']) && $action['use_searchfolder'] === true) {
			$data["search_meta"] = [];
			$data["search_meta"]["search_store_entryid"] = $action["store_entryid"];
			$data["search_meta"]["results"] = count($data["item"]);
		}

		$this->addActionData($actionType, $data);
		$GLOBALS["bus"]->addData($this->getResponseData());
	}

	/**
	 *	Function will set search restrictions on search folder and start search process
	 *	and it will also parse visible columns and sorting data when sending results to client.
	 *
	 * @param object $store      MAPI Message Store Object
	 * @param string $entryid    entryid of the folder
	 * @param object $action     the action data, sent by the client
	 * @param string $actionType the action type, sent by the client
	 */
	public function search($store, $entryid, $action, $actionType) {
		$useSearchFolder = $action["use_searchfolder"] ?? false;
		if (!$useSearchFolder) {
			/*
			 * store doesn't support search folders so we can't use this
			 * method instead we will pass restriction to messageList and
			 * it will give us the restricted results
			 */
			return $this->messageList($store, $entryid, $action, "list");
		}

		$this->searchFolderList = true; // Set to indicate this is not the normal folder, but a search folder
		$this->restriction = false;
		$searchInTodoList = $GLOBALS['entryid']->compareEntryIds(bin2hex($entryid), bin2hex(TodoList::getEntryId()));

		// Parse Restriction
		$this->parseRestriction($action);
		if ($this->restriction == false) {
			// if error in creating restriction then send error to client
			$errorInfo = [];
			$errorInfo["error_message"] = _("Error in search, please try again") . ".";
			$errorInfo["original_error_message"] = "Error in parsing restrictions.";

			return $this->sendSearchErrorToClient($store, $entryid, $action, $errorInfo);
		}

		if ($searchInTodoList) {
			// Since we cannot search in a search folder, we will combine the search restriction
			// with the search restriction of the to-do list to mimic searching in the To-do list
			$this->restriction = [
				RES_AND,
				[
					$this->restriction,
					TodoList::_createRestriction(),
				],
			];

			// When searching in the To-do list we will actually always search in the IPM subtree, so
			// set the entryid to that.
			$userStore = WebAppAuthentication::getMAPISession()->getDefaultMessageStore();
			$props = mapi_getprops($userStore, [PR_IPM_SUBTREE_ENTRYID]);
			$entryid = $props[PR_IPM_SUBTREE_ENTRYID];
		}

		$isSetSearchFolderEntryId = isset($action['search_folder_entryid']);
		if ($isSetSearchFolderEntryId) {
			$this->sessionData['searchFolderEntryId'] = $action['search_folder_entryid'];
		}

		if (isset($action['forceCreateSearchFolder']) && $action['forceCreateSearchFolder']) {
			$isSetSearchFolderEntryId = false;
		}

		// create or open search folder
		$searchFolder = $this->createSearchFolder($store, $isSetSearchFolderEntryId);
		if ($searchFolder === false) {
			// if error in creating search folder then send error to client
			$errorInfo = [];

			$errorInfo["error_message"] = match (mapi_last_hresult()) {
				MAPI_E_NO_ACCESS => _("Unable to perform search query, no permissions to create search folder."),
				MAPI_E_NOT_FOUND => _("Unable to perform search query, search folder not found."),
				default => _("Unable to perform search query, store might not support searching."),
			};

			$errorInfo["original_error_message"] = _("Error in creating search folder.");

			return $this->sendSearchErrorToClient($store, $entryid, $action, $errorInfo);
		}

		$subfolder_flag = 0;
		if ($searchInTodoList || (isset($action["subfolders"]) && $action["subfolders"] == "true")) {
			$subfolder_flag = RECURSIVE_SEARCH;
		}

		if (!is_array($entryid)) {
			$entryids = [$entryid];
		}
		else {
			$entryids = $entryid;
		}

		$searchFolderEntryId = $this->sessionData['searchFolderEntryId'];

		// check if searchcriteria has changed
		$restrictionCheck = md5(serialize($this->restriction) . $searchFolderEntryId . $subfolder_flag);

		// check if there is need to set searchcriteria again
		if (!isset($this->sessionData['searchCriteriaCheck']) || $restrictionCheck != $this->sessionData['searchCriteriaCheck']) {
			if (!empty($this->sessionData['searchOriginalEntryids'])) {
				// get entryids of original folders, and use it to set new search criteria
				$entryids = [];
				for ($index = 0; $index < count($this->sessionData['searchOriginalEntryids']); ++$index) {
					$entryids[] = hex2bin((string) $this->sessionData['searchOriginalEntryids'][$index]);
				}
			}
			else {
				// store entryids of original folders, so that can be used for re-setting the search criteria if needed
				$this->sessionData['searchOriginalEntryids'] = [];
				for ($index = 0, $len = count($entryids); $index < $len; ++$index) {
					$this->sessionData['searchOriginalEntryids'][] = bin2hex((string) $entryids[$index]);
				}
			}

			mapi_folder_setsearchcriteria($searchFolder, $this->restriction, $entryids, $subfolder_flag);
			$this->sessionData['searchCriteriaCheck'] = $restrictionCheck;
		}

		if (isset($this->sessionData['searchCriteriaCheck']) || $restrictionCheck == $this->sessionData['searchCriteriaCheck']) {
			$folderEntryid = bin2hex((string) $entryid);
			if ($this->sessionData['searchOriginalEntryids'][0] !== $folderEntryid) {
				$this->sessionData['searchOriginalEntryids'][0] = $folderEntryid;
				mapi_folder_setsearchcriteria($searchFolder, $this->restriction, [$entryid], $subfolder_flag);
			}
		}

		unset($action["restriction"]);

		// Sort
		$this->parseSortOrder($action);

		// Create the data array, which will be send back to the client
		$data = [];

		// Wait until we have some data, no point in returning before we have data. Stop waiting after 10 seconds
		$start = time();
		$table = mapi_folder_getcontentstable($searchFolder, MAPI_DEFERRED_ERRORS);

		sleep(1);

		while (time() - $start < 10) {
			$count = mapi_table_getrowcount($table);
			$result = mapi_folder_getsearchcriteria($searchFolder);

			// Stop looping if we have data or the search is finished
			if ($count > 0) {
				break;
			}

			if (($result["searchstate"] & SEARCH_REBUILD) == 0) {
				break;
			} // Search is done

			sleep(1);
		}

		// Get the table and merge the arrays
		$table = $GLOBALS["operations"]->getTable($store, hex2bin((string) $searchFolderEntryId), $this->properties, $this->sort, $this->start);
		$data = array_merge($data, $table);

		$this->getDelegateFolderInfo($store);
		$data = $this->filterPrivateItems($data);

		// remember which entryid's are send to the client
		$searchResults = [];
		foreach ($table["item"] as $item) {
			// store entryid => last_modification_time mapping
			$searchResults[$item["entryid"]] = $item["props"]["last_modification_time"];
		}

		// store search results into session data
		if (!isset($this->sessionData['searchResults'])) {
			$this->sessionData['searchResults'] = [];
		}
		$this->sessionData['searchResults'][$searchFolderEntryId] = $searchResults;

		$result = mapi_folder_getsearchcriteria($searchFolder);

		$data["search_meta"] = [];
		$data["search_meta"]["searchfolder_entryid"] = $searchFolderEntryId;
		$data["search_meta"]["search_store_entryid"] = $action["store_entryid"];
		$data["search_meta"]["searchstate"] = $result["searchstate"];
		$data["search_meta"]["results"] = count($searchResults);

		// Reopen the search folder, because otherwise the suggestion property will
		// not have been updated
		$searchFolder = $this->createSearchFolder($store, true);
		$storeProps = mapi_getprops($searchFolder, [PR_EC_SUGGESTION]);
		if (isset($storeProps[PR_EC_SUGGESTION])) {
			$data["search_meta"]["suggestion"] = $storeProps[PR_EC_SUGGESTION];
		}

		$this->addActionData("search", $data);
		$GLOBALS["bus"]->addData($this->getResponseData());

		return true;
	}

	/**
	 *	Function will check for the status of the search on server
	 *	and it will also send intermediate results of search, so we don't have to wait
	 *	until search is finished on server to send results.
	 *
	 * @param object    $store   MAPI Message Store Object
	 * @param hexString $entryid entryid of the folder
	 * @param object    $action  the action data, sent by the client
	 */
	public function updatesearch($store, $entryid, $action) {
		if (!isset($entryid) || !$entryid) {
			// if no entryid is present then we can't do anything here
			return;
		}

		$listData = [];
		if (isset($action['search_folder_entryid'])) {
			$entryid = hex2bin($action['search_folder_entryid']);
		}
		$searchFolder = mapi_msgstore_openentry($store, $entryid);
		$searchResult = mapi_folder_getsearchcriteria($searchFolder);
		$searchState = $searchResult["searchstate"];
		$table = mapi_folder_getcontentstable($searchFolder, MAPI_DEFERRED_ERRORS);

		if (is_array($this->sort) && !empty($this->sort)) {
			// this sorting will be done on currently fetched results, not all results
			// @TODO find a way to do sorting on all search results
			mapi_table_sort($table, $this->sort, TBL_BATCH);
		}

		$rowCount = $GLOBALS['settings']->get('zarafa/v1/main/page_size', 50);

		$searchResults = [];
		$entryid = bin2hex($entryid);
		if (isset($this->sessionData['searchResults'][$entryid])) {
			$searchResults = $this->sessionData['searchResults'][$entryid];
		}

		// searchResults contains entryids of messages
		// that are already sent to the server
		$numberOfResults = count($searchResults);

		if ($numberOfResults < $rowCount) {
			$items = mapi_table_queryallrows($table, [PR_ENTRYID, PR_LAST_MODIFICATION_TIME]);

			foreach ($items as $props) {
				$sendItemToClient = false;
				$hexEntryid = bin2hex((string) $props[PR_ENTRYID]);

				if (!array_key_exists($hexEntryid, $searchResults)) {
					$sendItemToClient = true;
				}
				else {
					/*
					 * it could happen that an item in search folder has been changed
					 * after we have sent it to client, so we have to again send it
					 * so we will have to use last_modification_time of item to check
					 * that item has been modified since we have sent it to client
					 */
					// TODO if any item is deleted from search folder it will be not notified to client
					if ($searchResults[$hexEntryid] < $props[PR_LAST_MODIFICATION_TIME]) {
						$sendItemToClient = true;
					}
				}

				if ($sendItemToClient) {
					// only get primitive properties, no need to get body, attachments or recipient information
					$message = $GLOBALS["operations"]->openMessage($store, $props[PR_ENTRYID]);
					array_push($listData, $GLOBALS["operations"]->getProps($message, $this->properties));

					// store entryid => last_modification_time mapping
					$searchResults[$hexEntryid] = $props[PR_LAST_MODIFICATION_TIME];
				}

				// when we have more results then fit in the client, we break here,
				// we only need to update the counters from this point
				$numberOfResults = count($searchResults);
				if ($numberOfResults >= $rowCount) {
					break;
				}
			}
		}

		$totalRowCount = mapi_table_getrowcount($table);

		$data = [];
		$data["search_meta"] = [];
		$data["search_meta"]["searchfolder_entryid"] = $entryid;
		$data["search_meta"]["search_store_entryid"] = $action["store_entryid"];
		$data["search_meta"]["searchstate"] = $searchState;
		$data["search_meta"]["results"] = $numberOfResults;		// actual number of items that we are sending to client

		$data["page"] = [];
		$data["page"]["start"] = 0;
		$data["page"]["rowcount"] = $rowCount;
		$data["page"]["totalrowcount"] = $totalRowCount;	// total number of items

		if (!empty($listData)) {
			$data["item"] = array_merge([], $listData);
		}

		// search is finished so we no more need entryids of search results so clear it up
		if ($searchState & SEARCH_REBUILD === 0) {
			// remove search result entryids stored in session
			unset($this->sessionData['searchResults'][$entryid]);
		}
		else {
			// store data for next request
			$this->sessionData['searchResults'][$entryid] = $searchResults;
		}

		$this->addActionData("updatesearch", $data);
		$GLOBALS["bus"]->addData($this->getResponseData());

		return true;
	}

	/**
	 *	Function will stop search on the server if search folder exists.
	 *
	 * @param object    $store   MAPI Message Store Object
	 * @param hexString $entryid entryid of the folder
	 * @param object    $action  the action data, sent by the client
	 */
	public function stopSearch($store, $entryid, $action) {
		// if no entryid is present in the request then get the search folder entryid from session data
		$entryid = !empty($entryid) ? $entryid : (!empty($action['search_folder_entryid']) ? hex2bin((string) $action['search_folder_entryid']) : null);

		if (empty($entryid)) {
			// still no entryid? sorry i can't help you anymore
			$this->addActionData("stopsearch", ['success' => false]);
			$GLOBALS["bus"]->addData($this->getResponseData());

			return;
		}

		// remove search result entryids stored in session
		unset($this->sessionData['searchResults'][bin2hex($entryid)], $this->sessionData['searchCriteriaCheck'], $this->sessionData['searchFolderEntryId'], $this->sessionData['searchOriginalEntryids']);

		$searchFolder = mapi_msgstore_openentry($store, $entryid);
		$searchResult = mapi_folder_getsearchcriteria($searchFolder);

		// check if search folder exists and search is in progress
		if ($searchResult !== false && ($searchResult["searchstate"] & SEARCH_REBUILD !== 0)) {
			mapi_folder_setsearchcriteria($searchFolder, $searchResult['restriction'], $searchResult['folderlist'], STOP_SEARCH);
		}

		/*
		 * when stopping search process, we have to remove search folder also,
		 * so next search request with same restriction will not get uncompleted results
		 */
		$this->deleteSearchFolder($store, $entryid, $action);

		// send success message to client
		$this->addActionData("stopsearch", ['success' => true]);
		$GLOBALS["bus"]->addData($this->getResponseData());
	}

	/**
	 * Function will delete search folder.
	 *
	 * @param object    $store   MAPI Message Store Object
	 * @param hexString $entryid entryid of the folder
	 * @param array     $action  the action data, sent by the client
	 *
	 * @return bool true on success or false on failure
	 */
	public function deleteSearchFolder($store, $entryid, $action) {
		if ($entryid && $store) {
			$storeProps = mapi_getprops($store, [PR_FINDER_ENTRYID]);

			$finderFolder = mapi_msgstore_openentry($store, $storeProps[PR_FINDER_ENTRYID]);

			if (mapi_last_hresult() != NOERROR) {
				return;
			}

			$hierarchyTable = mapi_folder_gethierarchytable($finderFolder, MAPI_DEFERRED_ERRORS);

			$restriction = [RES_CONTENT,
				[
					FUZZYLEVEL => FL_FULLSTRING,
					ULPROPTAG => PR_ENTRYID,
					VALUE => [PR_ENTRYID => $entryid],
				],
			];

			mapi_table_restrict($hierarchyTable, $restriction, TBL_BATCH);

			// entryids are unique so there would be only one matching row,
			// so only fetch first row
			$folders = mapi_table_queryrows($hierarchyTable, [PR_ENTRYID], 0, 1);

			// delete search folder
			if (is_array($folders) && is_array($folders[0])) {
				mapi_folder_deletefolder($finderFolder, $folders[0][PR_ENTRYID]);
			}

			return true;
		}

		return false;
	}

	/**
	 * Search folder name prefix used to identify grommunio search folders.
	 */
	public const SEARCH_FOLDER_PREFIX = 'grommunio Web Search ';

	/**
	 *	Function will create a search folder in FINDER_ROOT folder
	 *	if folder exists then it will open it.
	 *
	 *	Each search gets a uniquely named folder to prevent collisions when
	 *	multiple searches run in parallel or when different users access the
	 *	same store (e.g. shared/delegate mailboxes).
	 *
	 *	Folder naming convention:
	 *	  "grommunio Web Search {YYYYMMDD}-{8 hex random}"
	 *
	 *	On every creation any folder whose embedded date is older than today
	 *	is removed automatically, as well as legacy folders named exactly
	 *	"grommunio Web Search Folder".
	 *
	 * @param object $store        MAPI Message Store Object
	 * @param bool   $openIfExists open if folder exists
	 *
	 * @return bool|resource $folder created search folder
	 */
	public function createSearchFolder($store, $openIfExists = true) {
		if (isset($this->sessionData['searchFolderEntryId']) && $openIfExists) {
			try {
				$searchFolder = mapi_msgstore_openentry($store, hex2bin($this->sessionData['searchFolderEntryId']));

				if ($searchFolder !== false) {
					// search folder exists, don't create new search folder
					return $searchFolder;
				}
			}
			catch (MAPIException) {
				// ignore error and continue creation of search folder
				unset($this->sessionData['searchFolderEntryId']);
			}
		}

		// create new search folder
		$searchFolderRoot = $this->getSearchFoldersRoot($store);
		if ($searchFolderRoot === false) {
			// error in finding search root folder
			// or store doesn't support search folders
			return false;
		}

		$today = gmdate('Ymd');
		$folderName = self::SEARCH_FOLDER_PREFIX . $today . '-' . bin2hex(random_bytes(4));

		try {
			// Clean up stale search folders (older dates and legacy name)
			$this->cleanupStaleSearchFolders($searchFolderRoot, $today);

			$searchFolder = mapi_folder_createfolder($searchFolderRoot, $folderName, '', 0, FOLDER_SEARCH);

			$props = mapi_getprops($searchFolder, [PR_ENTRYID]);
			$this->sessionData['searchFolderEntryId'] = bin2hex((string) $props[PR_ENTRYID]);

			// we have created new search folder so search criteria check should be removed
			unset($this->sessionData['searchCriteriaCheck']);

			return $searchFolder;
		}
		catch (MAPIException $e) {
			// don't propagate the event to higher level exception handlers
			$e->setHandled();
		}

		return false;
	}

	/**
	 * Remove search folders whose embedded date is strictly before $today,
	 * as well as legacy folders named "grommunio Web Search Folder".
	 *
	 * Folders belonging to the current date are left untouched because they
	 * may be actively used by other sessions or users on the same store.
	 *
	 * @param resource $searchFolderRoot FINDER_ROOT folder
	 * @param string   $today            current date as YYYYMMDD
	 */
	private function cleanupStaleSearchFolders($searchFolderRoot, string $today): void {
		try {
			$table = mapi_folder_gethierarchytable($searchFolderRoot, 0);
			$rows = mapi_table_queryrows($table, [PR_DISPLAY_NAME, PR_ENTRYID], 0, 0xFFFF);
		}
		catch (MAPIException $e) {
			$e->setHandled();

			return;
		}

		$prefix = self::SEARCH_FOLDER_PREFIX;
		$prefixLen = strlen($prefix);

		foreach ($rows as $row) {
			$name = (string) ($row[PR_DISPLAY_NAME] ?? '');

			// Remove legacy folders with the old static name
			if (strcasecmp($name, 'grommunio Web Search Folder') === 0) {
				$this->safeDeleteSearchFolder($searchFolderRoot, $row[PR_ENTRYID]);

				continue;
			}

			// Only consider folders that match our naming convention
			if (strncmp($name, $prefix, $prefixLen) !== 0) {
				continue;
			}

			// Extract the YYYYMMDD date token from the folder name
			$suffix = substr($name, $prefixLen);
			$dashPos = strpos($suffix, '-');
			if ($dashPos === false) {
				continue;
			}
			$dateToken = substr($suffix, 0, $dashPos);

			// Validate that it looks like a date (8 digits)
			if (!ctype_digit($dateToken) || strlen($dateToken) !== 8) {
				continue;
			}

			// Delete only if the date is strictly before today
			if ($dateToken < $today) {
				$this->safeDeleteSearchFolder($searchFolderRoot, $row[PR_ENTRYID]);
			}
		}
	}

	/**
	 * Attempt to hard-delete a search folder, swallowing any MAPI errors.
	 *
	 * @param resource $parentFolder the FINDER_ROOT folder
	 * @param string   $entryid      binary entryid of the folder to delete
	 */
	protected function safeDeleteSearchFolder($parentFolder, string $entryid): void {
		try {
			mapi_folder_deletefolder($parentFolder, $entryid, DEL_FOLDERS | DEL_MESSAGES | DELETE_HARD_DELETE);
		}
		catch (MAPIException $e) {
			$e->setHandled();
		}
	}

	/**
	 *	Function will open FINDER_ROOT folder in root container
	 *	public folder's don't have FINDER_ROOT folder.
	 *
	 *	@param		object			store MAPI message store object
	 * @param mixed $store
	 *
	 * @return bool|resource finder root folder for search folders
	 */
	public function getSearchFoldersRoot($store) {
		$searchRootFolder = false;

		// check if we can create search folders
		$storeProps = mapi_getprops($store, [PR_STORE_SUPPORT_MASK, PR_FINDER_ENTRYID, PR_DISPLAY_NAME]);
		if (($storeProps[PR_STORE_SUPPORT_MASK] & STORE_SEARCH_OK) !== STORE_SEARCH_OK) {
			// store doesn't support search folders, public store don't have FINDER_ROOT folder
			return false;
		}

		try {
			$searchRootFolder = mapi_msgstore_openentry($store, $storeProps[PR_FINDER_ENTRYID]);
		}
		catch (MAPIException $e) {
			$msg = "Unable to open FINDER_ROOT for store: %s.";
			error_log(sprintf($msg, $storeProps[PR_DISPLAY_NAME]) . ": " . $e->getMessage());
			// don't propagate the event to higher level exception handlers
			$e->setHandled();
		}

		return $searchRootFolder;
	}

	/**
	 *	Function will send error message to client if any error has occurred in search.
	 *
	 * @param object    $store     MAPI Message Store Object
	 * @param hexString $entryid   entryid of the folder
	 * @param object    $action    the action data, sent by the client
	 * @param object    $errorInfo the error information object
	 */
	public function sendSearchErrorToClient($store, $entryid, $action, $errorInfo) {
		if ($errorInfo) {
			$exception = new SearchException($errorInfo["original_error_message"] ?? $errorInfo['error_message'], mapi_last_hresult());
			$exception->setDisplayMessage($errorInfo['error_message']);

			// after sending error, remove error data
			$errorInfo = [];

			throw $exception;
		}

		return false;
	}

	/**
	 *	Function will create restriction based on restriction array.
	 *
	 * @param object $action the action data, sent by the client
	 */
	public function parseRestriction($action) {
		if (isset($action["restriction"]) && is_array($action['restriction'])) {
			if (isset($action["restriction"]["start"])) {
				// Set start variable
				$this->start = $action["restriction"]["start"];
			}
			foreach ($action["restriction"] as $key => $value) {
				$props = $this->properties;
				if (!empty($value)) {
					switch ($key) {
						case "search":
							$props = array_merge($this->properties, ['body' => PR_BODY]);

							// no break
						case "task":
						case "note":
						case "filter":
							$this->restriction = Conversion::json2restriction($props, $value);
							break;
					}
				}
			}
		}
	}

	/**
	 * Parses the incoming sort request and builds a MAPI sort order. Normally
	 * properties are mapped from the XML to MAPI by the standard $this->properties mapping. However,
	 * if you want other mappings, you can specify them in the optional $map mapping.
	 *
	 * $allow_multi_instance is used for creating multiple instance of MV property related items.
	 * $properties is used for using a custom set of properties instead of properties stored in module
	 *
	 * @param mixed $action
	 * @param mixed $map
	 * @param mixed $allow_multi_instance
	 * @param mixed $properties
	 */
	public function parseSortOrder($action, $map = false, $allow_multi_instance = false, $properties = false) {
		if (isset($action["sort"])) {
			$this->sort = [];

			if (!$properties) {
				$properties = $this->properties;
			}

			// Unshift MVI_FLAG of MV properties. So the table is not sort on it anymore.
			// Otherwise the server would generate multiple rows for one item (categories).
			foreach ($properties as $id => $property) {
				switch (mapi_prop_type($property)) {
					case PT_MV_STRING8 | MVI_FLAG:
					case PT_MV_LONG | MVI_FLAG:
						$properties[$id] = $properties[$id] & ~MV_INSTANCE;
						break;
				}
			}

			// Loop through the sort columns
			foreach ($action["sort"] as $column) {
				if (isset($column["direction"])) {
					if (isset($properties[$column["field"]]) || ($map && isset($map[$column["field"]]))) {
						if ($map && isset($map[$column["field"]])) {
							$property = $map[$column["field"]];
						}
						else {
							$property = $properties[$column["field"]];
						}

						// Check if column is a MV property
						switch (mapi_prop_type($property)) {
							case PT_MV_STRING8:
							case PT_MV_LONG:
								// Set MVI_FLAG.
								// The server will generate multiple rows for one item (for example: categories)
								if ($allow_multi_instance) {
									$properties[$column["field"]] = $properties[$column["field"]] | MVI_FLAG;
								}
								$property = $properties[$column["field"]];
								break;
						}

						// Set sort direction
						switch (strtolower($column["direction"])) {
							default:
							case "asc":
								$this->sort[$property] = TABLE_SORT_ASCEND;
								break;

							case "desc":
								$this->sort[$property] = TABLE_SORT_DESCEND;
								break;
						}
					}
				}
			}
		}
	}

	/**
	 * Function which gets the delegation details from localfreebusy message to use in
	 * processPrivateItems function.
	 *
	 * @param resource $store MAPI Message Store Object
	 */
	public function getDelegateFolderInfo($store) {
		$this->localFreeBusyMessage = false;
		$this->storeProviderGuid = false;

		try {
			$this->storeProviderGuid = mapi_getprops($store, [PR_MDB_PROVIDER]);
			$this->storeProviderGuid = $this->storeProviderGuid[PR_MDB_PROVIDER];

			if ($this->storeProviderGuid !== ZARAFA_STORE_DELEGATE_GUID) {
				// user is not a delegate, so no point of processing further
				return;
			}

			// get localfreebusy message
			$this->localFreeBusyMessage = FreeBusy::getLocalFreeBusyMessage($store);
		}
		catch (MAPIException $e) {
			// we got some error, but we don't care about that error instead just continue
			$e->setHandled();

			$this->localFreeBusyMessage = false;
			$this->storeProviderGuid = false;
		}
	}

	/**
	 * Helper function which loop through each item and filter out
	 * private items, if any.
	 *
	 * @param array array structure with row search data
	 * @param mixed $data
	 *
	 * @return array array structure with row search data
	 */
	public function filterPrivateItems($data) {
		// Disable private items
		if (isset($data["item"]) && is_array($data["item"])) {
			for ($index = 0, $len = count($data["item"]); $index < $len; ++$index) {
				$data["item"][$index] = $this->processPrivateItem($data["item"][$index]);

				if (empty($data["item"][$index])) {
					// remove empty results from data
					unset($data["item"][$index]);
				}
			}
		}

		return $data;
	}

	/**
	 * Function will be used to process private items in a list response, modules can
	 * can decide what to do with the private items, remove the entire row or just
	 * hide the data. This function will entirely remove the private message but
	 * if any child class needs different behavior then this can be overridden.
	 *
	 * @param object $item item properties
	 *
	 * @return object item properties if its non private item otherwise empty array
	 */
	public function processPrivateItem($item) {
		if ($this->checkPrivateItem($item)) {
			// hide the item by returning empty array, that can be removed from response
			return [];
		}

		return $item;
	}

	/**
	 * Function will be used check if any item is private or not and if it private then
	 * we should process it as private, because you don't want to process private items in
	 * user's default store.
	 * This function will check we are dealing with delegate stores or not if it is then
	 * the delegator has permission to see private items of delegate.
	 *
	 * @param object $item item properties
	 *
	 * @return bool true if items should be processed as private else false
	 */
	public function checkPrivateItem($item) {
		// flag to indicate that item should be considered as private
		$private = false;

		$isPrivate = (isset($item['props']['private']) && $item['props']['private'] === true);
		$isSensitive = (isset($item['props']['sensitivity']) && $item['props']['sensitivity'] === SENSITIVITY_PRIVATE);

		if ($isPrivate || $isSensitive) {
			// check for delegate permissions for delegate store
			if ($this->storeProviderGuid !== false && $this->storeProviderGuid === ZARAFA_STORE_DELEGATE_GUID) {
				// by default we should always hide the item if we are in delegate store
				$private = true;

				// find delegate properties
				if ($this->localFreeBusyMessage !== false) {
					try {
						$localFreeBusyMessageProps = mapi_getprops($this->localFreeBusyMessage, [PR_SCHDINFO_DELEGATE_ENTRYIDS, PR_DELEGATE_FLAGS]);

						if (isset($localFreeBusyMessageProps[PR_SCHDINFO_DELEGATE_ENTRYIDS], $localFreeBusyMessageProps[PR_DELEGATE_FLAGS])) {
							// if more then one delegates info is stored then find index of
							// current user
							$userEntryId = $GLOBALS['mapisession']->getUserEntryID();

							$userFound = false;
							$seePrivate = false;
							foreach ($localFreeBusyMessageProps[PR_SCHDINFO_DELEGATE_ENTRYIDS] as $key => $entryId) {
								if ($GLOBALS['entryid']->compareEntryIds(bin2hex((string) $userEntryId), bin2hex((string) $entryId))) {
									$userFound = true;
									$seePrivate = $localFreeBusyMessageProps[PR_DELEGATE_FLAGS][$key];
									break;
								}
							}

							if ($userFound !== false && $seePrivate === 1) {
								// if delegate has permission then don't hide the item
								$private = false;
							}
						}
					}
					catch (MAPIException $e) {
						if ($e->getCode() === MAPI_E_NOT_FOUND) {
							// no information available for delegates, ignore error
							$e->setHandled();
						}
					}
				}
			}
		}

		return $private;
	}
}
