<?php
/*
 * Copyright (c) 2026, Tribal Limited
 * All rights reserved.
 * 
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *     * Redistributions of source code must retain the above copyright
 *       notice, this list of conditions and the following disclaimer.
 *     * Redistributions in binary form must reproduce the above copyright
 *       notice, this list of conditions and the following disclaimer in the
 *       documentation and/or other materials provided with the distribution.
 *     * Neither the name of Zenario, Tribal Limited nor the
 *       names of its contributors may be used to endorse or promote products
 *       derived from this software without specific prior written permission.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL TRIBAL LTD BE LIABLE FOR ANY
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */


namespace ze;

class user {


	//Returns the IP address of the current visitor.
	//I'm defining it in the cache library instead of here so we can call it without the autoloader
	//having to load the user library when we want to serve pages from the cache.
	public static function ip() {
		return \ze\cache::visitorIP();
	}
	
	public static function anonymizeIP($ip) {
		$packedAddress = @inet_pton($ip);
		if (strlen($packedAddress) == 4) {
			$ipNetMask = '255.255.255.0';
		} elseif (strlen($packedAddress) == 16) {
			$ipNetMask = 'ffff:ffff:ffff:0000:0000:0000:0000:0000';
		} else {
			return '';
		}
		return inet_ntop(inet_pton($ip) & inet_pton($ipNetMask));
	}

	//Add an extranet user into a group, or out of a group
	public static function addToGroup($userId, $groupId, $remove = false) {
		if ($col = \ze\dataset::fieldDBColumn($groupId)) {
			\ze\row::set('users_custom_data', [$col => ($remove ? 0 : 1)], ['user_id' => $userId]);
		}
	}
	
	
	


	//Load information on the groups used for permissions
	protected static $allPermissionGroups;
	public static function permissionGroups() {
		if (is_null(static::$allPermissionGroups)) {
			static::$allPermissionGroups = \ze\row::getAssocs('custom_dataset_fields', ['id', 'label', 'db_column'], ['type' => 'group', 'is_system_field' => 0], 'db_column', 'db_column');
		}
		return static::$allPermissionGroups;
	}


	
	public static function groups($userId = null, $flat = true, $getLabelWhenFlat = false) {
		if (is_null($userId)) {
			if (empty($_SESSION['extranetUserID'])) {
				return [];
			} else {
				$userId = $_SESSION['extranetUserID'];
			}
		}
		
		$groups = [];
		$permissionGroups = \ze\user::permissionGroups();
	
		if (!empty($permissionGroups)) {
			//Get the row from the users_custom_data table for this user
			//(Note that the group names stored in static::$allPermissionGroups are the column names)
			$inGroups = \ze\row::get('users_custom_data', array_keys($permissionGroups), $userId);
		
			//Come up with a subsection of the groups that this user is in
			foreach ($permissionGroups as $groupCol => $group) {
				if (!empty($inGroups[$groupCol])) {
					if ($flat) {
						if ($getLabelWhenFlat) {
							$groups[$group['id']] = $group['label'];
						} else {
							$groups[$group['id']] = $groupCol;
						}
					} else {
						$groups[$group['id']] = $group;
					}
				}
			}
		}
	
		return $groups;
	}

	protected static $currentUsersPermissionGroups;
	const isInGroupFromTwig = true;
	public static function isInGroup($groupId, $userId = null) {
		
		$currentUserId = $_SESSION['extranetUserID'] ?? 0;
		if (is_null($userId)) {
			$userId = $currentUserId;
		}
	
		if (!$userId) {
			return false;
		}
		
		//Get the permission groups that this user is in.
		//(But if the user is the currently logged in extranet user, cache this to save a query next time.)
		$isCurrentUser = $userId == $currentUserId;
		
		if ($isCurrentUser && !is_null(static::$currentUsersPermissionGroups)) {
			$groups = static::$currentUsersPermissionGroups;
		} else {
			
			$groups = \ze\user::groups($userId);
			
			if ($isCurrentUser) {
				static::$currentUsersPermissionGroups = $groups;
			}
		}
		
		//Make this function flexible, and allow it to be called using group ID or code name
		if (is_numeric($groupId)) {
			return isset($groups[$groupId]);
		} else {
			return in_array($groupId, $groups);
		}
	}

	public static function getUserGroupsNames($userId) {
		$groups = \ze\user::groups($userId);
	
		if (empty($groups)) {
			return \ze\admin::phrase('No current group memberships');
		} else {
			return implode(', ', $groups);
		}
	}

	public static function getGroupLabel($groupId) {
		if ($groupId) {
			if (is_numeric($groupId)) {
				return \ze\row::get('custom_dataset_fields', 'label', $groupId);
			} else {
				return \ze\row::get('custom_dataset_fields', 'label', ['db_column' => $groupId]);
			}
		} else {
			return \ze\admin::phrase("All extranet users");
		}

	}
	
	public static function getGroupMemberCount($group) {
		//Please note: this will only count users who are not suspended.
		if (is_numeric($group)) {
			$group = \ze\dataset::fieldDetails($group);
		}
		
		$sql = '
			SELECT COUNT(*)
			FROM '. DB_PREFIX. 'users_custom_data AS ucd
			INNER JOIN '. DB_PREFIX. 'users AS u
			   ON ucd.user_id = u.id
			'. \ze\row::whereCol('users_custom_data', 'ucd', $group['db_column'], '=', 1, $first = true). '
			'. \ze\row::whereCol('users', 'u', 'status', '!=', 'suspended');
		
		return (int) \ze\sql::fetchValue($sql);
	}
	
	public static function idAndSessionIsValid($userId, $loggedIntoSite) {
		
		return 
			$loggedIntoSite == COOKIE_DOMAIN. SUBDIRECTORY. \ze::setting('site_id')
		 && \ze\row::exists('users', ['id' => (int) $userId, 'status' => 'active']);
	}
	

	//Attempt to automatically log a User in if the cookie is set on the User's Machine
	public static function logInAutomatically() {
		if (isset($_SESSION)) {
			if (empty($_SESSION['extranetUserID'])) {
			
				if (isset($_COOKIE['z_extranet_auto_login'])
				 && ($idAndMD5 = explode('_', $_COOKIE['z_extranet_auto_login'], 2))
				 && (count($idAndMD5) == 2)
				 && ($user = \ze\row::get('users', ['id', 'first_name', 'last_name', 'email', 'screen_name', 'password'], ['id' => (int) $idAndMD5[0], 'status' => 'active']))
				 && ($idAndMD5[1] === md5(\ze\link::host(). $user['id']. $user['screen_name']. $user['email']. $user['password']))) {
					\ze\user::logIn($user['id']);
					
					//On sites where we use cookie consent, if someone logs in as an extranet user,
					//assume they've read the T&Cs and we can serve them cookies now.
					//However make an exception if they specifically pressed the "reject" button previosuly.
					if (\ze::setting('cookie_require_consent')
					 && \ze\cookie::canSet('functionality')) {
						\ze\cookie::setConsent();
					}
				}
		
			//Check the session to see if the extranet user is for a different site that this one.
			//Also automatically log out any Users who have been suspended.
			} elseif (!\ze\user::idAndSessionIsValid($_SESSION['extranetUserID'], $_SESSION['extranetUser_logged_into_site'] ?? '')) {
				\ze\user::logOut();
			}
		}
	
		//A similar function for Admins
		if (isset($_SESSION['admin_userid']) && \ze\priv::check()) {
			//Check if we can find the current admin
			$admin = false;
			if (empty($_SESSION['admin_global_id'])) {
				if (!\ze::setting('in_moratorium')) {
					$admin = \ze\row::get('admins', ['modified_date'], ['authtype' => 'local', 'id' => $_SESSION['admin_userid'], 'status' => 'active']);
				}
		
			} elseif (\ze\db::connectGlobal()) {
				$admin = \ze\row\g::get('admins', ['modified_date'], ['authtype' => 'local', 'id' => $_SESSION['admin_global_id'], 'status' => 'active']);
			}
		
			//If not, log them out
			if (!$admin) {
				\ze\admin::unsetSession();
		
			//Update an Admin's permissions if their admin record has been modified since they were last set.
				//Note that I'm also triggering this logic if a couple of $_SESSION variables are missing;
				//this is to catch the case where someone migrates the site from the old admin permission
				//system to the new admin permissions system, when other admins are still logged in.
			} else
			if (empty($_SESSION['admin_permissions'])
			 || empty($_SESSION['admin_modified_date'])
			 || $_SESSION['admin_modified_date'] != $admin['modified_date']) {
				if (empty($_SESSION['admin_global_id'])) {
					\ze\admin::setSession($_SESSION['admin_userid']);
				} else {
					\ze\admin::setSession(\ze\adminAdm::syncMultisiteAdmins($_SESSION['admin_global_id']), $_SESSION['admin_global_id']);
				}
			}
		}

		\ze::$adminId = \ze\admin::id();
		\ze::$userId = \ze\user::id();
	}

	public static function logOut() {
		unset(
			$_SESSION['extranetUserID'],
			$_SESSION['extranetUser_logged_into_site'],
			$_SESSION['extranetUserImpersonated'],
			$_SESSION['extranetUserID_pending'],
			$_SESSION['extranetUserSteps'],
			$_SESSION['zenario_loggingInUserID'],
			$_SESSION['zenario_loggingInUserSite']
		);
		
		\ze\cookie::antiSessionFixationScript();
		
		$_SESSION['FORGET_EXTRANET_LOG_ME_IN_COOKIE'] = true;
	}

	public static function fieldDisplayValue($cfield, $userId = null, $returnCSV = true) {
		return \ze\dataset::fieldValue('users', $cfield, $userId ?? $_SESSION['extranetUserID'] ?? null, $returnCSV, true);
	}

	public static function fieldValue($cfield, $userId = null, $returnCSV = true) {
		return \ze\dataset::fieldValue('users', $cfield, $userId ?? $_SESSION['extranetUserID'] ?? null, $returnCSV, false);
	}

	const idFromTwig = true;
	public static function id() {
		return $_SESSION['extranetUserID'] ?? null;
	}

	public static function identifier($userId = null) {
		return \ze\row::get('users', 'identifier', $userId ?? $_SESSION['extranetUserID'] ?? null);
	}

	const emailFromTwig = true;
	public static function email($userId = null) {
		return \ze\row::get('users', 'email', $userId ?? $_SESSION['extranetUserID'] ?? null);
	}

	const screenNameFromTwig = true;
	public static function screenName($userId = null) {
		return \ze\row::get('users', 'screen_name', $userId ?? $_SESSION['extranetUserID'] ?? null);
	}

	const salutationFromTwig = true;
	public static function salutation($userId = null) {
		return \ze\row::get('users', 'salutation', $userId ?? $_SESSION['extranetUserID'] ?? null);
	}

	const firstNameFromTwig = true;
	public static function firstName($userId = null) {
		return \ze\row::get('users', 'first_name', $userId ?? $_SESSION['extranetUserID'] ?? null);
	}

	const lastNameFromTwig = true;
	public static function lastName($userId = null) {
		return \ze\row::get('users', 'last_name', $userId ?? $_SESSION['extranetUserID'] ?? null);
	}

	const nameFromTwig = true;
	public static function name($userId = null) {
		if ($row = \ze\row::get('users', ['first_name', 'last_name'], $userId ?? $_SESSION['extranetUserID'] ?? null)) {
			return $row['first_name']. ' '. $row['last_name'];
		}
		return null;
	}
	
	

	public static function getIdFromScreenName($screenName) {
		return \ze\row::get('users', 'id', ['screen_name' => $screenName]);
	}








	public static function logIn($userId, $impersonate = false) {
		
		\ze\cookie::antiSessionFixationScript();
	
		//Get details on this user
		$user = \ze\row::get('users', ['id', 'first_name', 'last_name', 'screen_name', 'email', 'password'], $userId);
	
		//Create a login hash (used for the \ze\user::logInAutomatically() function)
		$user['login_hash'] = $user['id']. '_'. md5(\ze\link::host(). $user['id']. $user['screen_name']. $user['email']. $user['password']);
		unset($user['password']);
	
		if (!$impersonate) {
			//Update their last login time
			\ze\row::update('users', ['last_login' => \ze\date::now()], $userId);
	
			if (\ze::setting('period_to_delete_sign_in_log')) {
				require_once CMS_ROOT. 'zenario/libs/manually_maintained/mit/browser/lib/browser.php';
				$browser = new \Browser();
				
				$sql = "
					INSERT INTO ". DB_PREFIX. "user_signin_log SET
						user_id = ". (int)  \ze\escape::sql($userId).",
						login_datetime = NOW(),
						browser = '". \ze\escape::sql($browser->getBrowser()). "',
						browser_version = '". \ze\escape::sql($browser->getVersion()). "',
						platform = '". \ze\escape::sql($browser->getPlatform()). "'";
				\ze\sql::update($sql);
			}
			\ze\module::sendSignal('eventUserLoggedIn',['user_id' => $userId]);
		}
	
		$_SESSION['extranetUserID'] = $userId;
		$_SESSION['extranetUser_logged_into_site'] = COOKIE_DOMAIN. SUBDIRECTORY. \ze::setting('site_id');
	
		return $user;
	}

	public static function details($userId) {
	
		if ($user = \ze\row::get('users', true, $userId)) {
			if ($custom_data = \ze\row::get('users_custom_data', true, $userId)) {
				unset($custom_data['user_id']);
				$user = array_merge($custom_data, $user);
			}
		}
		return $user;
	}
	
	public static function userDetailsForEmails($userId) {
		$allowedFields = \ze\user::allowedUserFieldsForEmails();
		$result = \ze\row::get('users', $allowedFields, $userId);
		
		foreach ($result as &$value) {
			if (!$value) {
				$value = '';
			}
		}
		
		return $result;
	}
	
	public static function allowedUserFieldsForEmails() {
		$allowedFields = [
			'salutation', 'first_name', 'last_name', 'identifier', 'screen_name',
			'email', 'status',
			'last_login', 'screen_name_confirmed', 'reset_password_time', 'last_profile_update_in_frontend',
			'created_date', 'modified_date', 'suspended_date', 'last_updated_timestamp'
		];
		
		return $allowedFields;
	}
	
	public static function allowedSpecialUserFieldsForEmails() {
		$allowedFields = [
			'password', 'hash', 'email_confirmation_link', 'cms_url', 'user_groups'
		];
		
		return $allowedFields;
	}

	public static function checkPassword($userId, $password) {
		//Look up some of this user's details
		if (!$user = \ze\row::get('users', ['id', 'password', 'password_salt'], (int) $userId)) {
			return false;
		}
	
		//N.b. from version 8 we are forcing everyone to encrypted their passwords,
		//you can no longer opt out of this!
		$shouldBeEncrypted = true;
	
		//The password could have been stored as either plain text, sha1 or sha2.
		if ($user['password_salt'] === null) {
			//Non-hashed passwords
			$wasEncrypted = false;
			$correct = $user['password'] === $password;
	
		} elseif (substr($user['password'], 0, 6) != 'sha256') {
			//SHA1
			$wasEncrypted = true;
			$correct = $user['password'] == \ze\user::hashPasswordSha1($user['password_salt'], $password);
	
		} else {
			//SHA2
			$wasEncrypted = true;
			$correct = $user['password'] == \ze\user::hashPasswordSha2($user['password_salt'], $password);
		}
	
		if ($correct) {
			//If the password was not stored in the form chosen in the site settings, save it in the correct form
			if ($wasEncrypted != $shouldBeEncrypted) {
				\ze\userAdm::setPassword($user['id'], $password);
			}
		
			return true;
		} else {
			return false;
		}
	}
	
	public static function isPasswordExpired($userId) {
		$sql = "
			SELECT
				(
					password_needs_changing
					AND reset_password_time <= DATE_SUB(NOW(), INTERVAL ". ((int) \ze::setting('temp_password_timeout') ?: 14). " DAY)
				) AS password_expired
			FROM " . DB_PREFIX . "users as u
			WHERE id = " . (int)$userId;
		$user = \ze\sql::fetchAssoc($sql);
		return $user && $user['password_expired'];
	}
	
	public static function getPasswordRequirements() {
		
		//If "site_settings" table doesn't exist (e.g. installing Zenario), use fallback values.
		//Check if there is a database connection.
		if (defined('DB_PREFIX')
		 && \ze::$dbL
		 && \ze::$dbL->checkTableDef(DB_PREFIX. 'site_settings', true)) {
			$minPassLength = \ze::setting('min_extranet_user_password_length');
			$minPassScore = \ze::setting('min_extranet_user_password_score');
		} else {
			$minPassLength = 10;
			$minPassScore = 2;
		}
		
		//As of 18 Oct 2021, the character type requirements are being removed.
		return $passwordRequirements = 	[
			'min_length' => $minPassLength,
			'min_score' => $minPassScore
		];
	}
	
	public static function checkNamedPermExists($perm, &$directlyAssignedToUser, &$byGroupAndCountry, &$hasRoleAtCompany, &$hasRoleAtLocation, &$hasRoleAtLocationAtCompany, &$onlyIfHasRolesAtAllAssignedLocations) {
	
		switch ($perm) {
			case 'manage.conference':
			//Permissions for changing site settings
			case 'manage.options-assetwolf':
			//Permissions for managing asset auto-register settings
			case 'manage.options-asset-auto-register':
			//Permissions for changing plugin settings in the advanced interface tools plugin
			case 'edit.pluginSetting':
			//Possible permissions for companies, locations and users
			case 'create-company.unassigned':
			case 'delete.company':
			case 'create-location.unassigned':
			case 'create-user.unassigned':
			//Export all of the asset data on a site
			case 'export.allData':
			//Recalculate all of the asset data on a site
			case 'recalculate.allData':
			//Permissions for ecommerce
			case 'view.invoice':
			case 'edit.order':
			//Documents
			case 'view.document':
			case 'manage.document':
			case 'manage.envelope':
			case 'manage.video':
				//Superusers only
				return true;
			case 'view.company':
			case 'edit.company':
			case 'create-location.company':
				return
					$hasRoleAtCompany = true;
			case 'view.country':
				return
					$byGroupAndCountry = true;
			case 'edit.location':
			case 'delete.location':
				return
					$byGroupAndCountry =
					$hasRoleAtLocation = true;
			case 'view.location':
			case 'create-user.location':
			case 'assign-user.location':
			case 'deassign-user.location':
				return
					$byGroupAndCountry =
					$hasRoleAtLocation =
					$hasRoleAtLocationAtCompany = true;
			case 'view.user':
			case 'edit.user':
				return
					$hasRoleAtLocation =
					$hasRoleAtLocationAtCompany = true;
			case 'delete.user':
				return
					$hasRoleAtLocation =
					$hasRoleAtLocationAtCompany =
					$onlyIfHasRolesAtAllAssignedLocations = true;
		
			//Possible permissions for assets
			case 'create-asset.unassigned':
			case 'create-asset.oneself':
			case 'assign.asset':
				//Superusers only
				return true;
			case 'create-asset.company':
				return
					$hasRoleAtCompany = true;
			case 'create-asset.location':
				return
					$byGroupAndCountry =
					$hasRoleAtLocation = true;
			case 'view.asset':
			case 'edit.asset':
			case 'acknowledge.asset':
			case 'enterData.asset':
			case 'enterAnyData.asset':
			case 'delete.asset':
			case 'sendCommandTo.asset':
			case 'sendSimpleCommandTo.asset':
				return
					$byGroupAndCountry =
					$hasRoleAtLocation =
					$hasRoleAtCompany =
					$hasRoleAtLocationAtCompany =
					$directlyAssignedToUser = true;
		
			//Possible permissions for other Assetwolf things
			case 'create-schema.unassigned':
			case 'create-command.unassigned':
			case 'create-dataRepeater.unassigned':
			case 'create-trigger.unassigned':
			case 'create-procedure.unassigned':
			case 'create-schedule.unassigned':
			case 'create-scheduledReport.unassigned':
			case 'create-schema.oneself':
			case 'create-command.oneself':
			case 'create-trigger.oneself':
			case 'create-procedure.oneself':
			case 'create-schedule.oneself':
			case 'design.schema':
			case 'manage.command':
				//Superusers only
				return true;
			case 'create-schema.company':
			case 'create-command.company':
			case 'create-trigger.company':
			case 'create-procedure.company':
			case 'create-schedule.company':
			case 'edit.scheduledReport':
			case 'delete.scheduledReport':
				return
					$hasRoleAtCompany = true;
			case 'view.schema':
			case 'view.command':
			case 'view.dataRepeater':
			case 'view.trigger':
			case 'view.procedure':
			case 'view.schedule':
			case 'edit.schema':
			case 'edit.command':
			case 'edit.trigger':
			case 'edit.procedure':
			case 'edit.schedule':
			case 'delete.schema':
			case 'delete.command':
			case 'delete.dataRepeater':
			case 'delete.trigger':
			case 'delete.procedure':
			case 'delete.schedule':
				return
					$hasRoleAtCompany =
					$directlyAssignedToUser = true;
			case 'view.scheduledReport':
				return 
					$hasRoleAtLocationAtCompany = true;
			//Reject any unrecognised permission
			default:
				return false;
		}
	}
	
	
	public static function permSetting($name) {
		
		return \ze\sql::fetchValue(
			"SELECT value FROM ". DB_PREFIX. "user_perm_settings WHERE name = '". \ze\escape::sql($name). "'"
		);
	}

	public static function can($action, $target = 'unassigned', $targetId = false, $multiple = false, $authenticatingUserId = -1) {
	
		//If the multiple flag is set, we'll want an array of inputs.
		//If the multiple flag isn't set, then we'll want a singular input.
		//For security reasons I expect the developer to specifically say which of these they are expecting,
		//just to avoid someone passing in an array that the caller then evalulates to true.
		if ($multiple XOR is_array($targetId)) {
			return false;
		}
		
		//"global" is an alias for "unassigned", for the purposes of this function
		if ($target === 'global') {
			$target = 'unassigned';
		}
	
	
		$awIdCol = 'id';
		$isAW =
		$awTable =
		$hasGlobal =
		$directlyAssignedToUser =
		$byGroupAndCountry =
		$hasRoleAtCompany =
		$hasRoleAtLocation =
		$hasRoleAtLocationAtCompany =
		$onlyIfHasRolesAtAllAssignedLocations =
		$ASSETWOLF_2_PREFIX =
		$ZENARIO_LOCATION_MANAGER_PREFIX =
		$ZENARIO_ORGANIZATION_MANAGER_PREFIX =
		$ZENARIO_COMPANY_LOCATIONS_MANAGER_PREFIX = false;
	
	
		//If the $authenticatingUserId is not set, default to the current user
		if ($authenticatingUserId === -1) {
			if (isset($_SESSION['extranetUserID'])) {
				$authenticatingUserId = $_SESSION['extranetUserID'];
			} else {
				$authenticatingUserId = false;
			}
		}
	
		//Convenience feature: accept either the target's type, or the target's variable's name.
		//If the name ends with "Id" we'll just strip it off.
		if (substr($target, -2) == 'Id') {
			$target = substr($target, 0, -2);
		}
	
		//The site settings use certain set patterns, e.g. perm.view.company
		$perm = $action;
		if ($target !== false) {
			$perm .= '.'. $target;
		}
	
	
		//Run some global checks that apply regardless of the $targetId
		//If the action is always allowed (or always disallowed) everywhere, then we can skip some specific logic
		do {
			$isGlobal = true;
			//Only certain combination of settings are valid, if the name of a permissions check is requested that does not exist
			//then return false.
			if (!\ze\user::checkNamedPermExists($perm, $directlyAssignedToUser, $byGroupAndCountry, $hasRoleAtCompany, $hasRoleAtLocation, $hasRoleAtLocationAtCompany, $onlyIfHasRolesAtAllAssignedLocations)) {
				$hasGlobal = false;
				break;
			}
		
			//View permissions have the option to show to everyone, or to always show to any extranet user,
			//except for "view user's details" which always needs at least an extranet login
			if ($action == 'view') {
				switch (\ze\user::permSetting($perm)) {
					case 'logged_out':
						$hasGlobal = $target != 'user';
						break 2;
					case 'logged_in':
						$hasGlobal = (bool) $authenticatingUserId;
						break 2;
				}
			}
		
			//All other options require a user to be logged in
			if (!$authenticatingUserId) {
				$hasGlobal = false;
				break;
			}
		
			//Check to see if the "by groups" option is checked.
			//(Note that every type of permission has a "by groups" option.)
			//If so, if the extranet user is in any of the listed groups then allow the action.
			if (\ze\user::permSetting($perm. '.by.group')
			 && ($groupIds = \ze\user::permSetting($perm. '.groups'))) {
			
				//Get a row from the users_custom_data table containing the groups indexed by group id
				$usersGroups = \ze\user::groups($authenticatingUserId, true);
			
				foreach (\ze\ray::explodeAndTrim($groupIds, true) as $groupId) {
					if (!empty($usersGroups[$groupId])) {
						$hasGlobal = true;
						break 2;
					}
				}
			}
		
			//If we reach this point then we can't do a global check,
			//we must run specific logic on the target id.
			$isGlobal = false;
		
		
			//If this is a check on something for assetwolf, note down which table this is for
			switch ($target) {
				case 'asset':
					$awTable = 'nodes';
					$isAW = true;
					break;
				case 'schema':
					$awTable = 'schemas';
					$isAW = true;
					break;
				case 'command':
					$awTable = 'commands';
					$isAW = true;
					break;
				case 'dataRepeater':
					$awTable = 'data_repeaters';
					$awIdCol = 'source_id';
					$isAW = true;
					break;
				case 'trigger':
					$awTable = 'triggers';
					$isAW = true;
					break;
				case 'procedure':
					$awTable = 'procedures';
					$isAW = true;
					break;
				case 'schedule':
					$awTable = 'schedules';
					$isAW = true;
					break;
			}
	
			//Only actually do each check if it's enabled in the site settings
			$byGroupAndCountry = $byGroupAndCountry && \ze\user::permSetting($perm. '.atCountry');
			$hasRoleAtLocation = $hasRoleAtLocation && \ze\user::permSetting($perm. '.atLocation');
			$hasRoleAtCompany = $hasRoleAtCompany && \ze\user::permSetting($perm. '.atCompany');
			$hasRoleAtLocationAtCompany = $hasRoleAtLocationAtCompany && \ze\user::permSetting($perm. '.atLocationAtCompany');
		
			//Look up table-prefixes if needed
			if ($isAW) {
				$ASSETWOLF_2_PREFIX = \ze\module::prefix('assetwolf_2', true);
			}
	
			if ($byGroupAndCountry) {
				$ZENARIO_LOCATION_MANAGER_PREFIX = \ze\module::prefix('zenario_location_manager', true);
			}
	
			if ($hasRoleAtCompany || $hasRoleAtLocationAtCompany || $hasRoleAtLocation) {
				$ZENARIO_ORGANIZATION_MANAGER_PREFIX = \ze\module::prefix('zenario_organization_manager', true);
			}
			if ($hasRoleAtCompany || $hasRoleAtLocationAtCompany) {
				$ZENARIO_COMPANY_LOCATIONS_MANAGER_PREFIX = \ze\module::prefix('zenario_company_locations_manager', true);
			}
	
		} while (false);
	
		//Allow multiple ids to be checked at once in an array
		if ($multiple) {
			//We need an associative array with targetIds as keys.
		
			//This code would check if we have a numeric array and convert it to an associative array.
			//foreach ($targetId as $key => $dummy) {
			//	if ($key === 0) {
			//		$targetId = \ze\ray::valuesToKeys($targetId);
			//	}
			//	break;
			//}
		
			//Loop through the array, applying the permissions check to each entry
			foreach ($targetId as $key => &$value) {
				$value = self::canInternal(
					$target, $key, $authenticatingUserId,
					$perm, $isAW, $awTable, $awIdCol, $isGlobal, $hasGlobal,
					$directlyAssignedToUser, $byGroupAndCountry, $hasRoleAtCompany, $hasRoleAtLocation, $hasRoleAtLocationAtCompany,
					$onlyIfHasRolesAtAllAssignedLocations, $ASSETWOLF_2_PREFIX,
					$ZENARIO_LOCATION_MANAGER_PREFIX, $ZENARIO_ORGANIZATION_MANAGER_PREFIX, $ZENARIO_COMPANY_LOCATIONS_MANAGER_PREFIX
				);
			}
		
			//Return the resulting array
			return $targetId;
	
		//Otherwise just return true or false
		} else {
			return self::canInternal(
				$target, $targetId, $authenticatingUserId,
				$perm, $isAW, $awTable, $awIdCol, $isGlobal, $hasGlobal,
				$directlyAssignedToUser, $byGroupAndCountry, $hasRoleAtCompany, $hasRoleAtLocation, $hasRoleAtLocationAtCompany,
				$onlyIfHasRolesAtAllAssignedLocations, $ASSETWOLF_2_PREFIX,
				$ZENARIO_LOCATION_MANAGER_PREFIX, $ZENARIO_ORGANIZATION_MANAGER_PREFIX, $ZENARIO_COMPANY_LOCATIONS_MANAGER_PREFIX
			);
		}
	}

	private static function canInternal(
		$target, $targetId, $authenticatingUserId,
		$perm, $isAW, $awTable, $awIdCol, $isGlobal, $hasGlobal,
		$directlyAssignedToUser, $byGroupAndCountry, $hasRoleAtCompany, $hasRoleAtLocation, $hasRoleAtLocationAtCompany,
		$onlyIfHasRolesAtAllAssignedLocations, $ASSETWOLF_2_PREFIX,
		$ZENARIO_LOCATION_MANAGER_PREFIX, $ZENARIO_ORGANIZATION_MANAGER_PREFIX, $ZENARIO_COMPANY_LOCATIONS_MANAGER_PREFIX
	) {
		
		$countryId =
		$companyId =
		$locationId =
		$isUserCheck = false;
	
		//Look to see what type of object this is a check for
		switch ($target) {
			//If this was a check on a country, company or location, then we can use the provided id for that variable
			case 'country':
				$countryId = $targetId;
				break;
		
			case 'company':
				$companyId = $targetId;
				break;
		
			case 'location':
				$locationId = $targetId;
				break;
		
			//Some special cases for dealing with user records
			case 'user':
				if ($targetId) {
					switch ($perm) {
						case 'view.user':
							//Users can always view their own data, this is hard-coded
							if ($targetId == $authenticatingUserId) {
								return true;
							}
							break;
		
						case 'edit.user':
							//Users can always edit their own data if the site setting to do so is enabled
							if ($targetId == $authenticatingUserId && \ze\user::permSetting('edit.oneself')) {
								return true;
							}
							break;
		
						case 'delete.user':
							//Stop users from deleting themslves
							if ($targetId == $authenticatingUserId) {
								return false;
							}
							break;
					}
				}
				$isUserCheck = true;
				break;
		}
	
		//Apart from the above special cases, some rules can be checked globally.
		//E.g. someone might be in the super-users group and can view or edit anything.
		//If so then there's no need to check anything for this specific id.
		if ($isGlobal) {
			return $hasGlobal;
	
		//The rest of the checks require the specific id of something to check,
		//return false if one wasn't provided.
		} elseif (!$targetId) {
			return false;
		}
	
		//If this is a check for something in Assetwolf, try to load the company id or location id
		//that it is assigned to.
		if ($isAW && ($ASSETWOLF_2_PREFIX = \ze\module::prefix('assetwolf_2', true))) {
			if ($row = \ze\sql::fetchRow("
				SELECT owner_type, owner_id
				FROM ". DB_PREFIX. $ASSETWOLF_2_PREFIX. $awTable. "
				WHERE ". $awIdCol. " = ". (int) $targetId
			)) {
				switch ($row[0]) {
					//Note down the company or location id
					case 'company':
						$companyId = $row[1];
						break;
					case 'location':
						$locationId = $row[1];
						break;
				
					//If the asset (or whatever) was directly assigned to a user, then we can run
					//that check now
					case 'user':
						return $directlyAssignedToUser && ($authenticatingUserId == $row[1]) && \ze\user::permSetting($perm. '.directlyAssigned');
				}
			} else {
				return false;
			}
		}
		
		if ($byGroupAndCountry) {
			if ($countryId || ($locationId && $ZENARIO_LOCATION_MANAGER_PREFIX)) {
				if ($groupIds = \ze\user::permSetting($perm. '.atCountry.groups')) {
					if (!$countryId) {
						//Try to work out what country this location is in.
						//Though note that we can save ourselves a DB request if the information
						//we need is already in the core vars.
						if (!empty(\ze::$vars['countryId'])
						 && !empty(\ze::$vars['locationId'])
						 && $locationId == \ze::$vars['locationId']) {
							$countryId = \ze::$vars['countryId'];
						} else {
							$countryId = \ze\sql::fetchValue('
								SELECT country_id
								FROM '. DB_PREFIX. $ZENARIO_LOCATION_MANAGER_PREFIX. 'locations
								WHERE id = '. (int) $locationId
							);
						}
					}
					
					if ($countryId) {
						if (\ze\sql::numRows('
							SELECT 1
							FROM '. DB_PREFIX. 'user_country_link
							WHERE country_id = \''. \ze\escape::asciiInSQL($countryId). '\'
							  AND user_id = '. (int) $authenticatingUserId
						)) {
							
							//Get a row from the users_custom_data table containing the groups indexed by group id
							$usersGroups = \ze\user::groups($authenticatingUserId, true);
			
							foreach (\ze\ray::explodeAndTrim($groupIds, true) as $groupId) {
								if (!empty($usersGroups[$groupId])) {
									return true;
								}
							}
						}
					}
				}
			}
			
			//Note: this logic doesn't cover giving access to edit user accounts
			//of other users assigned to locations in your country.
			//This isn't implemented and is not currently selectable in the User Permissions
			//site settings.
		}
	
		//The *thing* is assigned to a company, and they have [ANY/the specified] role at ANY location in that company
		if ($hasRoleAtCompany && $companyId && $ZENARIO_ORGANIZATION_MANAGER_PREFIX && $ZENARIO_COMPANY_LOCATIONS_MANAGER_PREFIX) {
			$sql = "
				SELECT 1
				FROM ". DB_PREFIX. $ZENARIO_ORGANIZATION_MANAGER_PREFIX. "user_role_location_link AS urll
				". ($onlyIfHasRolesAtAllAssignedLocations? "LEFT" : "INNER"). " JOIN ". DB_PREFIX. $ZENARIO_COMPANY_LOCATIONS_MANAGER_PREFIX. "company_location_link AS cll
				   ON cll.company_id = ". (int) $companyId. "
				  AND urll.location_id = cll.location_id
				WHERE urll.user_id = ". (int) $authenticatingUserId;
		
			if ($roleId = \ze\user::permSetting($perm. '.atCompany.role')) {
				$sql .= "
				  AND role_id = ". (int) $roleId;
			}
			$sql .= "
				LIMIT 1";
		
			if (\ze\sql::fetchRow($sql)) {
				return true;
			}
		}
	
		//The *thing* is assigned to a location at which they have [ANY/the specified] role
		if ($hasRoleAtLocation && ($locationId || $isUserCheck) && $ZENARIO_ORGANIZATION_MANAGER_PREFIX) {
		
			$roleId = \ze\user::permSetting($perm. '.atLocation.role');
		
			//Check the case where something is assigned to just one location
			if ($locationId && !$isUserCheck) {
				$sql = "
					SELECT 1
					FROM ". DB_PREFIX. $ZENARIO_ORGANIZATION_MANAGER_PREFIX. "user_role_location_link
					WHERE location_id = ". (int) $locationId. "
					  AND user_id = ". (int) $authenticatingUserId;
		
				if ($roleId) {
					$sql .= "
					  AND role_id = ". (int) $roleId;
				}
				$sql .= "
					LIMIT 1";
			
				if (\ze\sql::fetchRow($sql)) {
					return true;
				}
		
			//Handle checks on users, who can be assigned to multiple locations
			} elseif ($isUserCheck) {
				//Check if the target user is at any location that the current user is at
				//Also, if we're following ONLY logic, check that they are not at any other company
				$sql = "
					SELECT ". ($onlyIfHasRolesAtAllAssignedLocations? "cur.user_id IS NOT NULL" : "1"). "
					FROM ". DB_PREFIX. $ZENARIO_ORGANIZATION_MANAGER_PREFIX. "user_role_location_link AS tar
					". ($onlyIfHasRolesAtAllAssignedLocations? "LEFT" : "INNER"). "
					JOIN ". DB_PREFIX. $ZENARIO_ORGANIZATION_MANAGER_PREFIX. "user_role_location_link AS cur
					   ON cur.user_id = ". (int) $authenticatingUserId;
		
				if ($roleId) {
					$sql .= "
					  AND cur.role_id = ". (int) $roleId;
				}
			
				$sql .= "
					WHERE tar.user_id = ". (int) $targetId. "
					". ($onlyIfHasRolesAtAllAssignedLocations? "ORDER BY 1" : ""). "
					LIMIT 1";
			
				if (($row = \ze\sql::fetchRow($sql)) && ($row[0])) {
					return true;
				}
			}
		}
	
		//The *thing* is assigned to a location at a company, and they have [ANY/the specified] role at ANY location in that company
		if ($hasRoleAtLocationAtCompany && ($locationId || $isUserCheck) && $ZENARIO_ORGANIZATION_MANAGER_PREFIX && $ZENARIO_COMPANY_LOCATIONS_MANAGER_PREFIX) {
		
			$roleId = \ze\user::permSetting($perm. '.atLocationAtCompany.role');
		
			//Check the case where something is assigned to just one location
			if ($locationId && !$isUserCheck) {
				//Look up the company id up from the location
				$sql = "
					SELECT company_id
					FROM ". DB_PREFIX. $ZENARIO_COMPANY_LOCATIONS_MANAGER_PREFIX. "company_location_link
					WHERE location_id = ". (int) $locationId;
				if ($companyId = \ze\sql::fetchValue($sql)) {
				
					//Check if the current user is at any location in this company
					$sql = "
						SELECT 1
						FROM ". DB_PREFIX. $ZENARIO_ORGANIZATION_MANAGER_PREFIX. "user_role_location_link AS urll
						INNER JOIN ". DB_PREFIX. $ZENARIO_COMPANY_LOCATIONS_MANAGER_PREFIX. "company_location_link AS cll
						   ON cll.company_id = ". (int) $companyId. "
						  AND urll.location_id = cll.location_id
						WHERE urll.user_id = ". (int) $authenticatingUserId;
		
					if ($roleId) {
						$sql .= "
						  AND role_id = ". (int) $roleId;
					}
					$sql .= "
						LIMIT 1";
		
					if (\ze\sql::fetchRow($sql)) {
						return true;
					}
				}
		
			//Handle users, who can be assigned to multiple locations
			} elseif ($isUserCheck) {
				//Look up every company that the current user is assigned to
				$sql = "
					SELECT DISTINCT company_id
					FROM ". DB_PREFIX. $ZENARIO_ORGANIZATION_MANAGER_PREFIX. "user_role_location_link AS urll
					INNER JOIN ". DB_PREFIX. $ZENARIO_COMPANY_LOCATIONS_MANAGER_PREFIX. "company_location_link AS cll
					   ON urll.location_id = cll.location_id
					WHERE urll.user_id = ". (int) $authenticatingUserId;
		
				if ($roleId) {
					$sql .= "
					  AND urll.role_id = ". (int) $roleId;
				}
			
				if (($companyIds = \ze\sql::fetchValues($sql)) && (!empty($companyIds))) {
					//Check if the target user is at any of these companies
					//Also, if we're following ONLY logic, check that they are not at any other company
					$sql = "
						SELECT ". ($onlyIfHasRolesAtAllAssignedLocations? "cll.company_id IS NOT NULL" : "1"). "
						FROM ". DB_PREFIX. $ZENARIO_ORGANIZATION_MANAGER_PREFIX. "user_role_location_link AS urll
						". ($onlyIfHasRolesAtAllAssignedLocations? "LEFT" : "INNER"). "
						JOIN ". DB_PREFIX. $ZENARIO_COMPANY_LOCATIONS_MANAGER_PREFIX. "company_location_link AS cll
						   ON cll.company_id IN (". \ze\escape::in($companyIds, true). ")
						  AND urll.location_id = cll.location_id
						WHERE urll.user_id = ". (int) $targetId. "
						". ($onlyIfHasRolesAtAllAssignedLocations? "ORDER BY 1" : ""). "
						LIMIT 1";
			
					if (($row = \ze\sql::fetchRow($sql)) && ($row[0])) {
						return true;
					}
				}
			}
		}
	
		//If no rule matches, deny access
		return false;
	}

	//Shortcut function for creating things, which has a slightly less confusing syntax
	public static function canCreate($thingToCreate, $assignedTo = 'unassigned', $assignedToId = false, $multiple = false, $authenticatingUserId = -1) {
	
		//Convenience feature: accept either the thing's type, or the thing's variable's name.
		//If the name ends with "Id" we'll just strip it off.
		if (substr($thingToCreate, -2) == 'Id') {
			$thingToCreate = substr($thingToCreate, 0, -2);
		}
	
		return \ze\user::can('create-'. $thingToCreate, $assignedTo, $assignedToId, $multiple, $authenticatingUserId);
	}
	
	//Twig-safe versions
	const currentUserCanFromTwig = true;
	public static function currentUserCan($action, $target = 'unassigned', $targetId = false, $multiple = false) {
		return \ze\user::can($action, $target, $targetId, $multiple);
	}
	const currentUserCanCreateFromTwig = true;
	public static function currentUserCanCreate($thingToCreate, $assignedTo = 'unassigned', $assignedToId = false, $multiple = false) {
		return \ze\user::canCreate($thingToCreate, $assignedTo, $assignedToId, $multiple);
	}
	
	
	public static function canSeeCountries(&$authenticatingUserId, &$canSeeAllCountries, &$canSeeSpecificCountries) {
		
		if (!$authenticatingUserId) {
			$authenticatingUserId = \ze\user::id();
		}
		
		//Check if the current user/visitor has site-wide permissions to see every country
		$canSeeAllCountries = \ze\user::can('view', 'country', false, false, $authenticatingUserId);
		
		//Otherwise check if they are a country manager who can only see their linked countries
		if (!$canSeeAllCountries
		 && $authenticatingUserId
		 && \ze\user::permSetting('view.country.atCountry')
		 && ($countryManagerGroups = \ze\ray::explodeAndTrim(\ze\user::permSetting('view.country.atCountry.groups'), true))
		 && ($usersGroups = array_keys(\ze\user::groups($authenticatingUserId, true)))
		 && (!empty(array_intersect($countryManagerGroups, $usersGroups)))) {
			$canSeeSpecificCountries = true;
		}
		
		return $canSeeAllCountries || $canSeeSpecificCountries;
	}








	//Some password functions for users/admins

	public static function hashPassword($salt, $password) {
		if ($hash = \ze\user::hashPasswordSha2($salt, $password)) {
			return $hash;
		} else {
			return \ze\user::hashPasswordSha1($salt, $password);
		}
	}

	public static function hashPasswordSha2($salt, $password) {
		if ($hash = @hash('sha256', $salt. $password, true)) {
			return 'sha256'. base64_encode($hash);
		} else {
			return false;
		}
	}

	//Old sha1 function for passwords created before version 6.0.5. Or if sha2 is not enabled on a server.
	public static function hashPasswordSha1($salt, $password) {
		$result = \ze\sql::select(
			"SELECT SQL_NO_CACHE SHA('". \ze\escape::sql($salt. $password). "')");
		$row = \ze\sql::fetchRow($result);
		return $row[0];
	}

	//Check if a given password meets the strength requirements.
	public static function checkPasswordStrength($password, $checkIfEasilyGuessable = false) {
		$passwordRequirements = \ze\user::getPasswordRequirements();

		//Count the number of lower case, upper case, numeric and non-alphanumeric characters.
		$lower = strlen(preg_replace('#[^a-z]#', '', $password));
		$upper = strlen(preg_replace('#[^A-Z]#', '', $password));
		$numbers = strlen(preg_replace('#[^0-9]#', '', $password));
		$symbols = strlen(preg_replace('#[a-z0-9]#i', '', $password));

		//Validate password: match the min length, and follow any character requirements.
		$passwordMatchesRequirements = true;
		$passLength = strlen($password);

		if (strlen($password) < $passwordRequirements['min_length']) {
			$passwordMatchesRequirements = false;
		}

		$passwordIsTooEasyToGuess = false;
		$passwordScore = null;
		if ($passwordMatchesRequirements && $checkIfEasilyGuessable) {
			$minScore = $passwordRequirements['min_score'];

			$zxcvbn = new \ZxcvbnPhp\Zxcvbn();
			$result = $zxcvbn->passwordStrength($password);

			if ($result && isset($result['score'])) {
				$passwordScore = (int) $result['score'];
				switch ($result['score']) {
					case 4: //is very unguessable (guesses >= 10^10) and provides strong protection from offline slow-hash scenario
						//Do nothing, it's a pass
						break;
					case 3: //is safely unguessable (guesses < 10^10), offers moderate protection from offline slow-hash scenario
						if ($minScore == 4) {
							$passwordMatchesRequirements = false;
							$passwordIsTooEasyToGuess = true;
						}
						break;
					case 2: //is somewhat guessable (guesses < 10^8), provides some protection from unthrottled online attacks
						$passwordIsTooEasyToGuess = true;
						if ($minScore > 2) {
							$passwordMatchesRequirements = false;
						}
						break;
					case 1: //is still very guessable (guesses < 10^6)
						$passwordIsTooEasyToGuess = true;
						if ($minScore > 1) {
							$passwordMatchesRequirements = false;
						}
						break;
					case 0: //s extremely guessable (within 10^3 guesses)
					default:
						$passwordMatchesRequirements = false;
						$passwordIsTooEasyToGuess = true;
						break;
				}
			}
		}

		$validation = [
			'password_length' => $passLength,
			'min_length' => strlen($password) >= $passwordRequirements['min_length'],
			'lowercase' => $lower != 0,
			'uppercase' => $upper != 0,
			'numbers' => $numbers != 0,
			'symbols' => $symbols != 0,
			'password_matches_requirements' => $passwordMatchesRequirements,
			'password_is_too_easy_to_guess' => $passwordIsTooEasyToGuess
		];

		return $validation;
	}

	public static function timeZone($userId = false) {
		$timezone = false;
		if (!$userId) {
			$userId = ($_SESSION['extranetUserID'] ?? false);
		}
	
		if ($userId
		 && ($timezoneFieldId = \ze::setting('zenario_timezones__timezone_dataset_field'))
		 && ($timezoneFieldCol = \ze\row::get('custom_dataset_fields', 'db_column', $timezoneFieldId))
		 && ($timezone = \ze\row::get('users_custom_data', $timezoneFieldCol, $userId))) {
			//Use the timezone from the user's preferences, if set
	
		} elseif ($timezone = \ze::setting('zenario_timezones__default_timezone')) {
			//Use the timezone from the site settings, if set
	
		} else {
			//Otherwise use the server default if neither is set
			$timezone = false;
		}
		return $timezone;
	}
	
	public static function recordConsent($sourceName, $sourceId, $userId, $email, $firstName, $lastName, $label = '') {
		$ip = \ze\user::ip();
		if ($ip && \ze::setting('anonymize_consent_log_ip_address')) {
			$ip = \ze\user::anonymizeIP($ip);
		}
		
		\ze\row::insert(
			'consents', 
			[
				'source_name' => mb_substr($sourceName, 0, 255, 'UTF-8'),
				'source_id' => mb_substr($sourceId, 0, 255, 'UTF-8'),
				'datetime' => date('Y-m-d H:i:s'), 
				'user_id' => (int)$userId, 
				'ip_address' => mb_substr($ip, 0, 255, 'UTF-8'),
				'email' => mb_substr($email, 0, 255, 'UTF-8'),
				'first_name' => mb_substr($firstName, 0, 255, 'UTF-8'),
				'last_name' => mb_substr($lastName, 0, 255, 'UTF-8'),
				'label' => mb_substr($label, 0, 250, 'UTF-8')
			]
		);
	}
	
	public static function deleteConsent($consentId) {
		\ze\row::delete('consents', $consentId);
	}
	
	public static function formatLastUpdated($row, $relativeDate = false, $relativeDateAddFullTime = false) {
		return \ze\user::getLastEditedOrCreatedDatetimeForFrontEndOrFAB(
			false,
			$row['last_edited'],
			$row['last_edited_admin_id'],
			$row['last_edited_user_id'],
			$row['last_edited_username'],
			$row['created'],
			$row['created_admin_id'],
			$row['created_user_id'],
			$row['created_username'],
			$relativeDate, $relativeDateAddFullTime
		);
	}
	
	public static function formatUserLastUpdated($row, $relativeDate = false, $relativeDateAddFullTime = false) {
		return \ze\user::getLastEditedOrCreatedDatetimeForFrontEndOrFAB(
			false,
			$row['modified_date'],
			$row['last_edited_admin_id'],
			$row['last_edited_user_id'],
			$row['last_edited_username'],
			$row['created_date'],
			$row['created_admin_id'],
			$row['created_user_id'],
			$row['created_username'],
			$relativeDate, $relativeDateAddFullTime
		);
	}
	
	public static function getLastEditedOrCreatedDatetimeForFrontEndOrFAB(
		$adminFacing,
		$lastEdited = false,
		$lastEditedAdminId = false,
		$lastEditedUserId = false,
		$lastEditedUsername = false,
		$created = false,
		$createdAdminId = false,
		$createdUserId = false,
		$createdUsername = false,
		$relativeDate = false,
		$relativeDateAddFullTime = false
	) {
		
		//Check for backwards compatability
		if (!is_bool($adminFacing)) {
			$adminFacing = $adminFacing == 'fab';
		}
		
		$array = [];
		
		$array[] = [
			'string' => "Created [[date]] by [[user_or_admin]]",
			'editedOrCreated' => $created,
			'editedOrCreatedAdminId' => $createdAdminId,
			'editedOrCreatedUserId' => $createdUserId,
			'editedOrCreatedUsername' => $createdUsername
		];
		
		if (!empty($lastEdited) || !empty($lastEditedAdminId)) {
			$array[] = [
				'string' => "Last edited [[date]] by [[user_or_admin]]",
				'editedOrCreated' => $lastEdited,
				'editedOrCreatedAdminId' => $lastEditedAdminId,
				'editedOrCreatedUserId' => $lastEditedUserId,
				'editedOrCreatedUsername' => $lastEditedUsername
			];
		}
		
		$strings = [];
		foreach ($array as &$row) {
		
			if ($adminFacing) {
				$row['string'] = \ze\admin::phrase($row['string']);
			} else {
				$row['string'] = \ze\lang::phrase($row['string']);
			}
		
			if (!empty($row['editedOrCreatedAdminId'])) {
				if ($adminFacing) {
					if ($lastUpdatedByAdmin = \ze\row::get("admins", "id", ["id" => $row['editedOrCreatedAdminId']])) {
						//It's ok to display the admin's first name, last name and username in a FAB.
						$userOrAdmin = \ze\admin::formatName($row['editedOrCreatedAdminId']);
					} else {
						//Do not display the admin details on the front end (data protection reasons).
						$userOrAdmin = "an administrator (admin account not found)";
					}
				} else {
					$userOrAdmin = "an administrator";
				}
			} elseif (!empty($row['editedOrCreatedUserId']) && $lastUpdatedByUser = \ze\user::details($row['editedOrCreatedUserId'])) {
				if (
					!$adminFacing
					&& \ze::setting('user_use_screen_name')
					&& !empty($lastUpdatedByUser['screen_name'])
					&& $lastUpdatedByUser['screen_name_confirmed']
				) {
					$userOrAdmin = ($lastUpdatedByUser['screen_name'] ?? false) . " (user)";
				} else {
					$userOrAdmin = ($lastUpdatedByUser['identifier'] ?? false) . " (user)";
				}
			} elseif (!empty($row['editedOrCreatedUsername'])) {
				$userOrAdmin = ($row['editedOrCreatedUsername'] ?? false) . " (user account deleted)";
			} else {
				$userOrAdmin = "unknown administrator";
			}
		
			if (!empty($relativeDate)) {
				$date = \ze\date::formatRelativeDateTime($row['editedOrCreated'], 'day', ($relativeDateAddFullTime ? true : false), 'vis_date_format_short');
			} else {
				$date = \ze\date::formatDateTime($row['editedOrCreated'], 'vis_date_format_short');
			}
			
			if (!$date) {
				$date = 'date unknown';
			}
		
			\ze\lang::applyMergeFields($row['string'], ['date' => $date, 'user_or_admin' => $userOrAdmin]);
			
			$strings[] = $row['string'];
		}
		
		return implode("; ", $strings);
		
	}
	
	public static function formatLastActionedDatetime($adminFacing, $actionName, $actionCodeName, $data, $dateFormat = 'vis_date_format_short') {
		$row = [
			'string' => $actionName . " on [[date]] by [[user_or_admin]]",
			'action' => $data[$actionCodeName],
			'actionAdminId' => $data[$actionCodeName . '_admin_id'],
			'actionUserId' => $data[$actionCodeName . '_user_id'],
			'actionUsername' => $data[$actionCodeName . '_username'],
		];
		
		if ($adminFacing) {
			$row['string'] = \ze\admin::phrase($row['string']);
		} else {
			$row['string'] = \ze\lang::phrase($row['string']);
		}
	
		if (!empty($row['actionAdminId']) && ($adminDetails = \ze\admin::details($row['actionAdminId']))) {
			if ($adminFacing) {
				//It's ok to display the admin's first name, last name and username in a FAB.
				$userOrAdmin = \ze\admin::formatName($adminDetails);
			} else {
				//Do not display the admin details on the front end (data protection reasons).
				$userOrAdmin = "an administrator";
			}
		} elseif (!empty($row['actionUserId']) && ($lastUpdatedByUser = \ze\user::details($row['actionUserId']))) {
			if (
				!$adminFacing
				&& \ze::setting('user_use_screen_name')
				&& !empty($lastUpdatedByUser['screen_name'])
				&& $lastUpdatedByUser['screen_name_confirmed']
			) {
				$userOrAdmin = ($lastUpdatedByUser['screen_name'] ?? false);
			} else {
				$userOrAdmin = ($lastUpdatedByUser['identifier'] ?? false);
			}
		} elseif (!empty($row['actionUsername'])) {
			$userOrAdmin = ($row['actionUsername'] ?? false) . " (user account deleted)";
		} else {
			$userOrAdmin = "unknown";
		}
	
		$date = \ze\date::formatDateTime($row['action'], $dateFormat);
		
		if (!$date) {
			$date = 'date unknown';
		}
	
		\ze\lang::applyMergeFields($row['string'], ['date' => $date, 'user_or_admin' => $userOrAdmin]);
		
		return $row['string'];
	}
	
	
	public static function setLastUpdated(&$details, $creating) {
		
		$userId = \ze\user::id() ?? null;
		
		$userIdentifier = null;
		if ($userId) {
			$userIdentifier = \ze\user::identifier($userId) ?? null;
		}
		
		if ($creating) {
			$details['created'] = \ze\date::now();
			$details['created_admin_id'] = null;
			$details['created_user_id'] = $userId;
			$details['created_username'] = $userIdentifier;
		} else {
			$details['last_edited'] = \ze\date::now();
			$details['last_edited_admin_id'] = null;
			$details['last_edited_user_id'] = $userId;
			$details['last_edited_username'] = $userIdentifier;
		}
	}
	
	public static function setUserLastUpdated(&$details, $creating) {
		static::setLastUpdated($details, $creating);
		
		if ($creating) {
			$details['creation_method'] = 'admin';
			$details['created_date'] = $details['created'];
			unset($details['created']);
		} else {
			$details['modified_date'] = $details['last_edited'];
			unset($details['last_edited']);
		}
	}
}