<?php
/**
 * This is the base user class. It contains functionality for access control.
 * All application-specific user methods should go in classes/model/user.php. Do not modify this class unless adding generic access-related functionality.
 */
class baseuser implements crudable {

	private static array $fields=array(
		'name'=>validator::USERNAME,
		'email'=>array(validator::REQUIRED, validator::EMAIL),
 		'fullname'=>validator::REQUIRED,
		'password'=>validator::ANY,
		'isactive'=>validator::BOOLEAN,
		'supervisorid'=>validator::INTEGER,
		'edupersonprincipalname'=>validator::EMAIL,
		'lastlogin'=>validator::DATETIME,
		'uuid'=>validator::V7_UUID_DASHED_OR_UNDASHED
	);

	private static array $helpTexts=array(
		'name'=>'A unique username identifying the user',
		'email'=>'The user\'s email address',
 		'fullname'=>'The user\'s full name, e.g., John Smith',
		'password'=>'The user\'s password for access',
		'isactive'=>'If inactive, the user cannot log in',
		'supervisorid'=>'The person supervising this user',
		'edupersonprincipalname'=>'The eduPersonPrincipalName property of the SAML user',
		'lastlogin'=>'The last login date of the user'
	);

	private static string $adminSelect='SELECT SQL_CALC_FOUND_ROWS id,name, fullname, email, isactive, supervisorid, edupersonprincipalname, lastlogin, uuid FROM user WHERE 1=1 ';
	private static string $normalSelect='SELECT SQL_CALC_FOUND_ROWS id, fullname, supervisorid FROM user WHERE 1=1 ';

	public static string $defaultSortOrder='UPPER(fullname) ASC';
	
	public static function getFieldValidations(): array {
		return self::$fields;
	}
	public static function getFieldHelpTexts(): array {
		return self::$helpTexts;
	}
	
	public static function canCreate(): bool {
		return session::isAdmin();
	}
	
	public static function canUpdate(int $id): bool {
		return session::isAdmin();
	}
	
	private static function getSelectClause(): string {
		if(session::isAdmin()){
			return self::$adminSelect;
		}
		return self::$normalSelect;
	}

	/**
	 * @param int $id
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getById(int $id): ?array {
		$sqlStatement=self::getSelectClause().' AND id=:id';
		$result=database::queryGetOne($sqlStatement, array(':id'=>$id));
		if(empty($result)){ return $result; }
		$adminIds=static::getGroupMemberUserIds(baseusergroup::ADMINISTRATORS);
		if(in_array($id, $adminIds)){
			$result['isadmin']=1;
		} else {
			$result['isadmin']=0;
		}
		return $result;
	}

	/**
	 * @param string $name
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getByName(string $name): ?array {
		$sqlStatement=self::getSelectClause().' AND name=:name';
		$result=database::queryGetOne($sqlStatement, array(':name'=>$name));
		if(empty($result)){ return $result; }
		$adminIds=static::getGroupMemberUserIds(baseusergroup::ADMINISTRATORS);
		if(in_array($result['id'], $adminIds)){
			$result['isadmin']=1;
		} else {
			$result['isadmin']=0;
		}
		return $result;
	}

	/**
	 * @param string $searchTerms
	 * @param array $request
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getByNameLike(string $searchTerms, array $request=array()): ?array {
		$sqlStatement=self::getSelectClause().' AND name LIKE :name';
		if(!session::isAdmin() || !((int)session::get('showInactiveUsers'))){ $sqlStatement.=' AND isactive=1 '; }
		$sqlStatement.=database::getOrderClause($request, 'user');
		$sqlStatement.=database::getLimitClause($request);
		$result=database::queryGetAll($sqlStatement, array(':name'=>'%'.$searchTerms.'%'));
		$adminIds=static::getGroupMemberUserIds(baseusergroup::ADMINISTRATORS);
		if(!empty($result)){
			foreach($result['rows'] as &$r){
				if(in_array($r['id'], $adminIds)){
					$r['isadmin']=1;
				} else {
					$r['isadmin']=0;
				}
			}
		}
		return $result;
	}

	/**
	 * @param string $key
	 * @param string $value
	 * @param array $request
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws ServerException
	 */
	public static function getByProperty(string $key, string $value,array $request=[]): ?array {
		//verify key in fields array
		if(!in_array($key, array_keys(self::$fields))){
			throw new BadRequestException('Property '.$key.' not recognised on user');
		}
		if(!session::isAdmin()){
			$keys=array('fullname','supervisorid');
			if(!in_array($key, $keys)){
				throw new ForbiddenException('Property '.$key.' not recognised on user');
			}
		}
		$sqlStatement=self::getSelectClause().' AND '.$key.'=:val';
		if(!session::isAdmin() || !((int)session::get('showInactiveUsers'))){ $sqlStatement.=' AND isactive=1 '; }
		$sqlStatement.=database::getOrderClause($request, 'user');
		$sqlStatement.=database::getLimitClause($request);
		$params=array(':val'=>$value);
		$result=database::queryGetAll($sqlStatement,$params);
		if(!empty($result)){
			$adminIds=static::getGroupMemberUserIds(baseusergroup::ADMINISTRATORS);
			foreach($result['rows'] as &$r){
				if(in_array($r['id'], $adminIds)){
					$r['isadmin']=1;
				} else {
					$r['isadmin']=0;
				}
			}
		}
		return $result;
	}

	/**
	 * @throws ServerException
	 * @throws BadRequestException
	 */
	public static function getByProperties(array $keyValuePairs,array $request=[]): ?array {
		//verify key in fields array
		$sqlStatement=self::getSelectClause();
		$params=[];
		$keys=['fullname','supervisorid'];
		if(session::isAdmin()){
			$keys=array_keys(self::$fields);
		}
		foreach($keyValuePairs as $key=>$value){
			if(!in_array($key, $keys)){
				throw new BadRequestException('Property '.$key.' not recognised on user');
			}
			$sqlStatement.=" AND $key=:$key ";
			$params[":$key"]=$value;
		}
		if(!session::isAdmin() || !((int)session::get('showInactiveUsers'))){ $sqlStatement.=' AND isactive=1 '; }
		$sqlStatement.=database::getOrderClause($request, 'user');
		$sqlStatement.=database::getLimitClause($request);
		$result=database::queryGetAll($sqlStatement,$params);
		if(!empty($result)){
			$adminIds=static::getGroupMemberUserIds(baseusergroup::ADMINISTRATORS);
			foreach($result['rows'] as &$r){
				if(in_array($r['id'], $adminIds)){
					$r['isadmin']=1;
				} else {
					$r['isadmin']=0;
				}
			}
		}
		return $result;
	}

    /**
     * @param array $request
     * @return array
     * @throws BadRequestException
     * @throws ServerException
     */
	public static function getAll(array $request=[]): array {
		$sqlStatement=self::getSelectClause();
		if(!session::isAdmin() || !((int)session::get('showInactiveUsers'))){ $sqlStatement.=' AND isactive=1 '; }
        $sqlStatement.=database::getFilterClause($request, 'user');
        $sqlStatement.=database::getOrderClause($request, 'user');
		$sqlStatement.=database::getLimitClause($request);
		$params=array();
		$result=database::queryGetAll($sqlStatement,$params);
		$adminIds=static::getGroupMemberUserIds(baseusergroup::ADMINISTRATORS);
		if(!empty($result)){
			foreach($result['rows'] as &$r){
				if(in_array($r['id'], $adminIds)){
					$r['isadmin']=1;
				} else {
					$r['isadmin']=0;
				}
			}
		}
		return $result;
	}

	/**
	 * @param int|string $groupNameOrId
	 * @return array
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	private static function getGroupMemberUserIds(int|string $groupNameOrId): array {
		return usergroup::getGroupMemberUserIds($groupNameOrId);
	}

    /**
     * @param array $request
     * @return array
     * @throws BadRequestException
     * @throws ForbiddenException
     * @throws NotFoundException
     * @throws ServerException
     */
	public static function create(array $request=[]): array {
		if(!session::isAdmin()){
			throw new ForbiddenException('You do not have permission to create users.');
		}
		$keys=array();
		foreach (self::$fields as $fieldName=>$validations){
			if(isset($request[$fieldName])){
				validator::validate($fieldName, $request[$fieldName], $validations);
				$keys[]=$fieldName;
				if('password'==$fieldName){
					$request[$fieldName]=password_hash($request[$fieldName], PASSWORD_BCRYPT);
				}
			} else {
				validator::validate($fieldName, null, $validations);
			}
			
		}
		$fields=implode(',', $keys);
		$values=':'.implode(',:', $keys);
        /** @noinspection SqlInsertValues */
        $sqlStatement='INSERT INTO user('.$fields.') VALUES('.$values.')';
		$params=array();
		foreach($keys as $k){
			if(isset($request[$k])){
				$params[':'.$k]=$request[$k];
			}
		}
		database::query($sqlStatement,$params);
		$newUserId=database::getLastInsertId();
		$everyone=usergroup::getByName(baseusergroup::EVERYONE);
		database::query(
				'INSERT INTO groupmembership(userid,usergroupid) VALUES(:user,:grp)',
				array(':user'=>$newUserId, ':grp'=>$everyone['id'])
		);
		
		$defaultHomepageBricks=database::queryGetAll('SELECT * FROM homepagedefaultbrick');
		if(!empty($defaultHomepageBricks)){
			foreach($defaultHomepageBricks['rows'] as $d){
			    homepageuserbrick::create(array(
                    'userid'=>$newUserId,
                    'homepagebrickid'=>$d['homepagebrickid'],
                    'row'=>$d['row'],
                    'col'=>$d['col']
                ));
			}
		}
		
		return array('type'=>'user', 'created'=>self::getById($newUserId));	
	}

	/**
	 * @param int $id
	 * @param array $request
	 * @return array
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws ServerException
	 */
	public static function update(int $id, array $request=[]): array {
		if(isset($request['showInactiveUsers']) && $id==session::getUserId()){
			return session::setShowInactiveUsers($request['showInactiveUsers']);
		}

		if(!session::isAdmin()){
			//TODO Allow Supervisors to update users' supervisor ID - which should add the new supervisor to that UG.
			//TODO And MAKE CERTAIN that you don't create supervision loops.
			if($id==session::getUserId()){
				$user=user::getById($id);
				//User can only update his password, and only if the old one is supplied; or add his EPPN if it's empty (to link to a Shibboleth account)
				if(isset($request['password']) && isset($request['oldpassword'])) {
					$result = database::queryGetOne('SELECT password FROM user WHERE id=:id AND isactive=true', array(':id' => $id));
					if (!$result || !password_verify($request['oldpassword'], $result['password'])) {
						throw new ForbiddenException('User ID does not exist or old password did not match');
					}
					$sqlStatement = 'UPDATE user set password=:password WHERE id=:id';
					$params = array(
						':password' => password_hash($request['password'], PASSWORD_BCRYPT),
						':id' => $id
					);
					database::query($sqlStatement, $params);
				} else if(isset($request['edupersonprincipalname'])) {
					if(!empty($user['edupersonprincipalname'])){
						throw new ForbiddenException('eduPersonPrincipalName already set');
					}
					$sqlStatement = 'UPDATE user set edupersonprincipalname=:eppn WHERE id=:id';
					$params = array(
						':eppn' => $request['edupersonprincipalname'],
						':id' => $id
					);
					database::query($sqlStatement, $params);
					session::set('user',user::getById($id));
				} else {
					throw new ForbiddenException('You do not have permission to update users');
				}
			}

		} else {
			//Admin
			foreach($request as $k=>$v){
				//if('name'==$k){ throw new BadRequestException('Username cannot be changed'); }
				if(in_array($k, array_keys(self::$fields))){
					validator::validate($k, $v, self::$fields[$k]);
					if("password"==$k){
						$v=password_hash($v, PASSWORD_BCRYPT);
					}
					database::query('UPDATE user SET '.$k.'=:val WHERE id=:id', array(':id'=>$id, ':val'=>$v));
				}
			}
		}
		return array('updated'=>self::getById($id));
	}

	/**
	 * @param int $id
	 * @return array
	 * @throws ForbiddenException
	 */
	public static function delete(int $id): array {
		if(session::isAdmin()){
			throw new ForbiddenException('Users cannot be deleted. Make the user inactive instead.');
		}
		throw new ForbiddenException('You do not have permission to manage users');
	}

	/**
	 * @param int $id
	 * @param array $request
	 * @return array
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws ServerException
	 */
	public static function getusergroups(int $id, array $request=[]): array {
		if(session::isAdmin()){
			//Show an administrator this user's group memberships
			$sqlStatement='SELECT ug.name AS name, ug.id AS id, gm.isgroupadmin, gm.id AS groupmembershipid FROM usergroup AS ug, groupmembership AS gm
				WHERE ug.id=gm.usergroupid AND gm.userid=:userid ORDER BY ug.issystem DESC, ug.name ';
		} else if($id==session::getUserId()){
			//Show the current user only groups he is authorised to see - he may be in hidden groups, but don't tell him
			$sqlStatement="SELECT ug.name AS name, ug.id AS id, gm.isgroupadmin, gm.id AS groupmembershipid FROM usergroup AS ug, groupmembership AS gm
				WHERE ug.id=gm.usergroupid AND ug.groupvisibility IN('visible','membersonly') AND gm.userid=:userid ORDER BY ug.issystem DESC, ug.name ";
		} else {
			//If not an administrator or looking at own groups, bail
			throw new ForbiddenException('Only administrators can see this.');
		}
		$sqlStatement.=database::getLimitClause($request);
		$parameters=array(':userid'=>$id);
		return database::queryGetAll($sqlStatement, $parameters);
	}

	/**
	 * @param int $id
	 * @param array $request
	 * @return array
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws ServerException
	 */
	public static function getnonmemberusergroups(int $id, array $request=[]): array {
		if(!session::isAdmin() && $id!=session::getUserId()){
			throw new ForbiddenException('Only administrators can see this.');
		}
		$sqlStatement='SELECT ug.id AS id FROM usergroup AS ug, groupmembership AS gm
				WHERE ug.id=gm.usergroupid AND gm.userid=:userid ORDER BY ug.issystem DESC, ug.name ';
		$parameters=array(':userid'=>$id);
		$memberGroups=database::queryGetAll($sqlStatement, $parameters);
		$inClause='';
		if(!empty($memberGroups) && 0!=count($memberGroups['rows'])){
			$inClause=' AND id NOT IN('.implode(',', array_column($memberGroups['rows'], 'id')) .')';
		}
		$sqlStatement='SELECT id, name FROM usergroup WHERE 1=1 '.$inClause;
		$sqlStatement.=database::getLimitClause($request);
		return database::queryGetAll($sqlStatement);
	}

	/**
	 * @param int $id
	 * @param array $request
	 * @return array
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws ServerException
	 */
	public static function getprojects(int $id, array $request=[]): array {
		if(!session::isAdmin() && $id!=session::getUserId()){
			throw new ForbiddenException('Only administrators can see this.');
		}
		$projects=array();
		$showArchivedProjects=session::get('showArchivedProjects');
		
		$sqlStatement='SELECT project.name as projectname, project.id AS projectid, project.isarchived AS isarchived, 1 AS isowner 
				FROM project
				WHERE owner=:userid
		';
		if(!$showArchivedProjects){ $sqlStatement.=' AND project.isarchived=0'; }
		$sqlStatement.=database::getOrderClause(array("sortby"=>"name"),'project');
		$parameters=array(':userid'=>$id);
		$owned=database::queryGetAll($sqlStatement, $parameters);
		if(!empty($owned)){
			foreach($owned['rows'] as $row){
				if(!isset($projects[$row['projectname']])){
					$projects[$row['projectname']]=array(
							'id'=>$row['projectid'],
							'name'=>$row['projectname'],
							'isarchived'=>$row['isarchived'],
							'isowner'=>1,
							'permissions'=>array()
					);
				}
			}
		}		
		
		
		$sqlStatement='SELECT project.name as projectname, project.id AS projectid, project.isarchived AS isarchived,
					usergroup.name AS usergroupname, usergroup.id AS usergroupid,
					groupmembership.id AS groupmembershipid, permission.type AS type
				FROM groupmembership, usergroup, permission, project
				WHERE groupmembership.userid=:userid
					AND groupmembership.usergroupid=usergroup.id
					AND permission.usergroupid=usergroup.id 
					AND permission.projectid=project.id
		';
		if(!empty($owned) && count($owned['rows'])>0){
			$ownedIds=array_column($owned['rows'],'projectid');
			$sqlStatement.=' AND project.id NOT IN('.implode(',',$ownedIds).')';
		}
		if(!$showArchivedProjects){ $sqlStatement.=' AND project.isarchived=0'; }
		$sqlStatement.=database::getOrderClause(array("sortby"=>"name"),'project');
		$parameters=array(':userid'=>$id);
		$result=database::queryGetAll($sqlStatement, $parameters);
		foreach($result['rows'] as $row){
			if(!isset($projects[$row['projectname']])){
				$projects[$row['projectname']]=array(
					'id'=>$row['projectid'],
					'name'=>$row['projectname'],
					'isarchived'=>$row['isarchived'],
					'isowner'=>0,
					'permissions'=>array()
				);
			}
			if(!isset($projects[$row['projectname']]['permissions'][$row['type']])){
				$projects[$row['projectname']]['permissions'][$row['type']]=array();
			}
			$projects[$row['projectname']]['permissions'][$row['type']][]=array(
					'usergroupid'=>$row['usergroupid'],
					'usergroupname'=>$row['usergroupname']
			);
		}
		$projects=array_values($projects);
		return array('total'=>count($projects), 'rows'=>$projects);
	}

	/**
	 * Returns users below the specified user in the supervision hierarchy.
	 * If $request['allbelow'] is set, users under this one (supervisees' supervisees, etc.) will be returned in a single array.
	 * @param int $id
	 * @param array $request The parameters supplied in the request.
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getsupervisees(int $id, array $request=[]): ?array {
		$request['all']=1;
		if(!isset($request['allbelow'])){
			return database::queryGetAll(
					'SELECT id, name, fullname, supervisorid FROM user WHERE supervisorid=:userid', 
					array(':userid'=>$id)
			);
		} else {
			$ids=array();
			$supervisees=database::queryGetAll(
					'SELECT id FROM user WHERE supervisorid=:userid', 
					array(':userid'=>$id)
			);
			if(empty($supervisees)){return null; }
			while(!empty($supervisees)){
				$newIds=array_column($supervisees['rows'],'id');
				$ids=array_merge($ids,$newIds);
				$count=1;
				$placeholders=array();
				$values=array();
				foreach($newIds as $n){
					$placeholders[]=':u'.$count;
					$values[':u'.$count]=$n;
					$count++;
				}
				$supervisees=database::queryGetAll(
					'SELECT id FROM user WHERE supervisorid IN('.implode(', ',$placeholders).')',
					$values
				);	
			}
			
			$placeholders=array();
			$values=array();
			$count=0;
			foreach($ids as $n){
				$placeholders[]=':u'.$count;
				$values[':u'.$count]=$n;
				$count++;
			}
			return database::queryGetAll(
				'SELECT id, name, fullname, supervisorid FROM user WHERE id IN('.implode(', ',$placeholders).')',
				$values
			);	
			
		}
		
	}

	/**
	 * @param int $id
	 * @return bool
	 * @throws BadRequestException
	 * @throws NotFoundException
	 * @throws ServerException
	 */
	public static function isAdmin(int $id): bool {
		$adminGroup=usergroup::getByName(baseusergroup::ADMINISTRATORS);
		$membership=database::queryGetOne(
				'SELECT * FROM groupmembership WHERE userid=:uid AND usergroupid=:gid', 
				array(':uid'=>$id, ':gid'=>$adminGroup['id'])
		);
		return !empty($membership);
	}

    /**
     * Returns the first admin user (by ID)
     * @throws BadRequestException
     * @throws NotFoundException
     * @throws ServerException
     */
	public static function getFirstAdmin(): ?array {
		$adminGroup=usergroup::getByName(baseusergroup::ADMINISTRATORS);
		if(!$adminGroup){ throw new NotFoundException('No usergroup called '. baseusergroup::ADMINISTRATORS.' found'); }
		$members=usergroup::getGroupMemberUserIds($adminGroup['id']);
		if(!$members){ throw new NotFoundException('Administrators usergroup has no members'); }
		return user::getById($members[0]);
	}

	/**
	 * @throws ServerException
	 * @throws ForbiddenException
	 * @throws BadRequestException
	 */
	public static function setLastLogin(int $id): string {
		$currentUserId=session::getUserId();
		if($currentUserId!== $id){
			throw new ForbiddenException('Cannot set last login for another user');
		}
		$now=new DateTime();
		$now->setTimezone(new DateTimeZone('GMT'));
		$now=$now->format('Y-m-d H:i:s');
		$sqlStatement='UPDATE user SET lastlogin=:datetime WHERE id=:id';
		$params=array(
			':datetime'=>$now,
			':id'=>$id
		);
		database::query($sqlStatement, $params);
		return $now;
	}

	const SUPERVISION_LOOP_DETECTED='Supervision loop detected';

	/**
	 * Returns true if the user is in a supervision loop (e.g., Alice manages Bob manages Alice).
	 * @throws ServerException
	 * @throws BadRequestException
	 */
	public static function isInSupervisionLoop(int $userId=null): bool {
		try {
			if(!$userId){ $userId=basesession::getUserId(); }
			user::getAllSupervisors($userId);
		} catch (ServerException $e){
			$message=$e->getMessage();
			if(str_starts_with($message, static::SUPERVISION_LOOP_DETECTED)){
				return true;
			}
			throw $e;
		}
		return false;
	}
	/**
	 * Returns the specified user's direct supervisor, or null.
	 * @param $userId int|null The user ID. If null, the current logged-in user is assumed.
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getDirectSupervisor(int $userId=null): ?array {
		if(!$userId){ $userId=basesession::getUserId(); }
		$user=user::getById($userId);
		$supervisorId=$user['supervisorid'];
		if($supervisorId==$userId){
			throw new ServerException(static::SUPERVISION_LOOP_DETECTED.': User'.$userId.' ('.$user['fullname'].') is their own supervisor');
		}
		if(!$supervisorId){ return null; }
		return user::getById($supervisorId);
	}

	/**
	 * Returns the ID of the specified user's direct supervisor.
	 * @param $userId int|null The user ID. If null, the current logged-in user is assumed.
	 * @return int|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getDirectSupervisorId(int $userId=null): ?int {
		if(!$userId){ $userId=basesession::getUserId(); }
		$supervisor=static::getDirectSupervisor($userId);
		if(!$supervisor){ return null; }
		return $supervisor['id'];
	}

	/**
	 * Returns the user's supervisor, their supervisor's supervisor, etc., along with the number of users returned. The
	 * first user in the "rows" array is the user's direct supervisor.
	 * @param int|null $userId int The user ID. If null, the current logged-in user is assumed.
	 * @return array|null An array with keys "total" and "rows", the latter being an array of users.
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getAllSupervisors(int $userId=null): ?array {
		if(!$userId){ $userId=basesession::getUserId(); }
		$supervisors=[];
		$supervisorIds=[];
		$supervisor=static::getDirectSupervisor($userId);
		while(!empty($supervisor)){
			$supervisorId=$supervisor['id'];
			if(in_array($supervisorId, $supervisorIds)){
				throw new ServerException(static::SUPERVISION_LOOP_DETECTED.', user '.$supervisorId.' ('.$supervisor['fullname'].')');
			}
			$supervisors[]=$supervisor;
			$supervisorIds[]=$supervisorId;
			$supervisor=static::getDirectSupervisor($supervisor['id']);
		}
		if(!$supervisors){ return null; }
		return ['total'=>count($supervisors), 'rows'=>$supervisors];
	}

	/**
	 * Returns the user IDs of the user's supervisor, their supervisor's supervisor, etc. The first ID in the array is
	 * the user's direct supervisor.
	 * @param $userId int|null The user ID. If null, the current logged-in user is assumed.
	 * @return array|null An array of user IDs.
	 * @throws ServerException
	 * @throws BadRequestException
	 */
	public static function getAllSupervisorIds(int $userId=null): ?array {
		if(!$userId){ $userId=basesession::getUserId(); }
		$supervisors=static::getAllSupervisors($userId);
		if(empty($supervisors)){ return $supervisors; }
		return array_column($supervisors['rows'], 'id');
	}

	/**
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws ServerException
	 */
	public static function getDirectSupervisees(int $userId=null): ?array {
		if(!$userId){ $userId=basesession::getUserId(); }
		$supervisees=user::getByProperty('supervisorid', $userId, ['all'=>1]);
		if(!empty($supervisees)){
			$ids=array_column($supervisees['rows'],'id');
			if(in_array($userId, $ids)){
				$user=user::getById($userId);
				throw new ServerException(static::SUPERVISION_LOOP_DETECTED.': User '.$userId.' ('.$user['fullname'].') is their own supervisor');
			}
		}
		return $supervisees;
	}

	/**
	 * @param int|null $userId
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws ServerException
	 */
	public static function getDirectSuperviseeIds(int $userId=null): ?array {
		if(!$userId){ $userId=basesession::getUserId(); }
		$supervisees=static::getDirectSupervisees($userId);
		if(!$supervisees){ return $supervisees; }
		return array_column($supervisees['rows'],'id');
	}

	/**
	 * @param int|null $userId
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws ServerException
	 */
	public static function getAllSupervisees(int $userId=null): ?array {
		$user=static::getSuperviseeChain($userId, false);
		if(isset($user['supervisees'])) {
			return $user['supervisees'];
		}
		return null;
	}

	/**
	 * @param int|null $userId
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws ServerException
	 */
	public static function getAllSuperviseeIds(int $userId=null): ?array {
		$chain=static::getSuperviseeChain($userId, true);
		if(empty($chain)){ return null; }
		return $chain;
	}

	/**
	 * @throws BadRequestException
	 * @throws ServerException
	 * @throws ForbiddenException
	 */
	private static function getSuperviseeChain(int $userId, bool $idsOnly, array &$foundUserIds=[]): array {
		if(!$userId){ $userId=session::getUserId(); }
		$user=user::getById($userId);
		$directSuperviseeIds=user::getDirectSuperviseeIds($userId);
		if($directSuperviseeIds){
			$user['supervisees']=['total'=>0,'rows'=>[]];
			foreach($directSuperviseeIds as $directSuperviseeId	){
				if(in_array($directSuperviseeId, $foundUserIds)){
					$directSupervisee=user::getById($directSuperviseeId);
					throw new ServerException(
						static::SUPERVISION_LOOP_DETECTED.': User '.$directSuperviseeId.' ('.
						$directSupervisee['fullname'].') is in their own supervision hierarchy'
					);
				}
				$foundUserIds[]=$directSuperviseeId;
				$user['supervisees']['total']++;
				$user['supervisees']['rows'][]=user::getSuperviseeChain($directSuperviseeId, $idsOnly, $foundUserIds);
			}
		}
		if($idsOnly){ return $foundUserIds; }
		return $user;
	}

	public static function getUuid(int $userId): string {
		session::becomeAdmin();
		$user=baseuser::getById($userId);
		session::revertAdmin();
		if(!$user){ throw new NotFoundException('No user with ID '.$userId); }
		if($user['uuid']){ return $user['uuid']; }
		$uuid=baseobject::generateUuid();
		session::becomeAdmin();
		baseuser::update($userId, ['uuid'=>$uuid]);
		session::revertAdmin();
		return $uuid;
}

}