<?php
/*
** Copyright (C) 2001-2025 Zabbix SIA
**
** This program is free software: you can redistribute it and/or modify it under the terms of
** the GNU Affero General Public License as published by the Free Software Foundation, version 3.
**
** This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
** without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
** See the GNU Affero General Public License for more details.
**
** You should have received a copy of the GNU Affero General Public License along with this program.
** If not, see <https://www.gnu.org/licenses/>.
**/

use Duo\DuoUniversal\Client;
use Duo\DuoUniversal\DuoException;
use PragmaRX\Google2FA\Google2FA;
use PragmaRX\Google2FA\Support\Constants;


/**
 * Class containing methods for operations with users.
 */
class CUser extends CApiService {

	public const ACCESS_RULES = [
		'get' => ['min_user_type' => USER_TYPE_ZABBIX_USER],
		'create' => ['min_user_type' => USER_TYPE_SUPER_ADMIN],
		'update' => ['min_user_type' => USER_TYPE_ZABBIX_USER],
		'delete' => ['min_user_type' => USER_TYPE_SUPER_ADMIN],
		'checkauthentication' => [],
		'login' => [],
		'logout' => ['min_user_type' => USER_TYPE_ZABBIX_USER],
		'unblock' => ['min_user_type' => USER_TYPE_SUPER_ADMIN],
		'provision' => ['min_user_type' => USER_TYPE_SUPER_ADMIN],
		'resettotp' => ['min_user_type' => USER_TYPE_SUPER_ADMIN]
	];

	protected $tableName = 'users';
	protected $tableAlias = 'u';
	protected $sortColumns = ['userid', 'username'];

	public const OUTPUT_FIELDS = ['userid', 'username', 'name', 'surname', 'url', 'autologin', 'autologout',
		'lang', 'refresh', 'theme', 'attempt_failed', 'attempt_ip', 'attempt_clock', 'rows_per_page', 'timezone',
		'roleid', 'userdirectoryid', 'ts_provisioned', 'provisioned'
	];

	public const OWN_LIMITED_OUTPUT_FIELDS = ['userid', 'username', 'name', 'surname', 'url', 'autologin',
		'autologout', 'lang', 'refresh', 'theme', 'attempt_failed', 'attempt_ip', 'attempt_clock', 'rows_per_page',
		'timezone', 'roleid', 'provisioned'
	];

	public const LIMITED_OUTPUT_FIELDS = ['userid', 'username', 'name', 'surname'];

	public const OUTPUT_MEDIA_FIELDS = ['mediaid', 'mediatypeid', 'sendto', 'active', 'severity', 'period',
		'userdirectory_mediaid', 'provisioned'
	];

	public const LIMITED_OUTPUT_MEDIA_FIELDS = ['mediaid', 'mediatypeid', 'sendto', 'active', 'severity', 'period',
		'provisioned'
	];

	private const PROVISIONED_FIELDS = ['username', 'name', 'surname', 'usrgrps', 'medias', 'roleid'];

	public const PROVISION_STATUS_YES = 1;
	public const PROVISION_STATUS_NO = 0;

	/**
	 * Acceptable execution time of user verification process in seconds and nanoseconds.
	 *
	 * @var array
	 */
	private const ACCEPTABLE_USER_VERIFICATION_TIME = [1, 0];

	private static $user_verification_start_time;

	/**
	 * Get users data.
	 *
	 * @param array  $options
	 * @param array  $options['usrgrpids']		filter by UserGroup IDs
	 * @param array  $options['userids']		filter by User IDs
	 * @param bool   $options['type']			filter by User type [USER_TYPE_ZABBIX_USER: 1, USER_TYPE_ZABBIX_ADMIN: 2, USER_TYPE_SUPER_ADMIN: 3]
	 * @param bool   $options['selectUsrgrps']	extend with UserGroups data for each User
	 * @param bool   $options['getAccess']		extend with access data for each User
	 * @param bool   $options['count']			output only count of objects in result. (result returned in property 'rowscount')
	 * @param string $options['pattern']		filter by Host name containing only give pattern
	 * @param int    $options['limit']			output will be limited to given number
	 * @param string $options['sortfield']		output will be sorted by given property ['userid', 'username']
	 * @param string $options['sortorder']		output will be sorted in given order ['ASC', 'DESC']
	 *
	 * @return array
	 */
	public function get($options = []) {
		$result = [];

		$sqlParts = [
			'select'	=> ['users' => 'u.userid'],
			'from'		=> ['users' => 'users u'],
			'where'		=> [],
			'order'		=> [],
			'limit'		=> null
		];

		$defOptions = [
			'usrgrpids'					=> null,
			'userids'					=> null,
			'mediaids'					=> null,
			'mediatypeids'				=> null,
			// filter
			'filter'					=> null,
			'search'					=> null,
			'searchByAny'				=> null,
			'startSearch'				=> false,
			'excludeSearch'				=> false,
			'searchWildcardsEnabled'	=> null,
			// output
			'output'					=> API_OUTPUT_EXTEND,
			'editable'					=> false,
			'selectUsrgrps'				=> null,
			'selectMedias'				=> null,
			'selectMediatypes'			=> null,
			'selectRole'				=> null,
			'getAccess'					=> null,
			'countOutput'				=> false,
			'preservekeys'				=> false,
			'sortfield'					=> '',
			'sortorder'					=> '',
			'limit'						=> null
		];
		$options = zbx_array_merge($defOptions, $options);

		// permission check
		if (self::$userData['type'] != USER_TYPE_SUPER_ADMIN) {
			if (!$options['editable']) {
				$sqlParts['from']['users_groups'] = 'users_groups ug';
				$sqlParts['where']['uug'] = 'u.userid=ug.userid';
				$sqlParts['where'][] = 'ug.usrgrpid IN ('.
					' SELECT uug.usrgrpid'.
					' FROM users_groups uug'.
					' WHERE uug.userid='.self::$userData['userid'].
				')';
			}
			else {
				$sqlParts['where']['userid'] = 'u.userid='.self::$userData['userid'];
			}
		}

		// output
		if (!$options['countOutput']) {
			$user_output_fields = self::$userData['type'] == USER_TYPE_SUPER_ADMIN
				? self::OUTPUT_FIELDS
				: self::OWN_LIMITED_OUTPUT_FIELDS;

			if (is_array($options['output'])) {
				$options['output'] = array_intersect($options['output'], $user_output_fields);
			}
			elseif ($options['output'] === API_OUTPUT_EXTEND) {
				$options['output'] = $user_output_fields;
			}
		}

		// userids
		if ($options['userids'] !== null) {
			zbx_value2array($options['userids']);

			$sqlParts['where'][] = dbConditionInt('u.userid', $options['userids']);
		}

		// usrgrpids
		if ($options['usrgrpids'] !== null) {
			zbx_value2array($options['usrgrpids']);

			$sqlParts['from']['users_groups'] = 'users_groups ug';
			$sqlParts['where'][] = dbConditionInt('ug.usrgrpid', $options['usrgrpids']);
			$sqlParts['where']['uug'] = 'u.userid=ug.userid';
		}

		// mediaids
		if ($options['mediaids'] !== null) {
			zbx_value2array($options['mediaids']);

			$sqlParts['from']['media'] = 'media m';
			$sqlParts['where'][] = dbConditionInt('m.mediaid', $options['mediaids']);
			$sqlParts['where']['mu'] = 'm.userid=u.userid';

			if (self::$userData['type'] != USER_TYPE_SUPER_ADMIN) {
				$sqlParts['where']['userid'] = 'u.userid='.self::$userData['userid'];
			}
		}

		// mediatypeids
		if ($options['mediatypeids'] !== null) {
			zbx_value2array($options['mediatypeids']);

			$sqlParts['from']['media'] = 'media m';
			$sqlParts['where'][] = dbConditionInt('m.mediatypeid', $options['mediatypeids']);
			$sqlParts['where']['mu'] = 'm.userid=u.userid';

			if (self::$userData['type'] != USER_TYPE_SUPER_ADMIN) {
				$sqlParts['where']['userid'] = 'u.userid='.self::$userData['userid'];
			}
		}

		$limited_output_fields = array_flip(self::LIMITED_OUTPUT_FIELDS);

		// filter
		if (is_array($options['filter'])) {
			if (array_key_exists('userid', $options['filter']) && $options['filter']['userid'] !== null
					&& ($options['searchByAny'] === null || $options['searchByAny'] === false)) {
				zbx_value2array($options['filter']['userid']);

				$sqlParts['where'][] = dbConditionId('u.userid', $options['filter']['userid']);

				unset($options['filter']['userid']);
			}

			if (isset($options['filter']['passwd'])) {
				self::exception(ZBX_API_ERROR_PARAMETERS, _('It is not possible to filter by user password.'));
			}

			if (array_key_exists('autologout', $options['filter']) && $options['filter']['autologout'] !== null) {
				$options['filter']['autologout'] = getTimeUnitFilters($options['filter']['autologout']);
			}

			if (array_key_exists('refresh', $options['filter']) && $options['filter']['refresh'] !== null) {
				$options['filter']['refresh'] = getTimeUnitFilters($options['filter']['refresh']);
			}

			$provisioned_condition = null;

			if (array_key_exists('provisioned', $options['filter']) && $options['filter']['provisioned'] !== null) {
				$provisioned_condition = $options['filter']['provisioned'] == self::PROVISION_STATUS_YES
					? 'u.userdirectoryid IS NOT NULL'
					: 'u.userdirectoryid IS NULL';

				if ($options['searchByAny'] !== null && $options['searchByAny'] !== false
						&& self::$userData['type'] != USER_TYPE_SUPER_ADMIN) {
					$provisioned_condition =
						'(u.userid='.self::$userData['userid'].' AND '.$provisioned_condition.')';
				}

				unset($options['filter']['provisioned']);
			}

			$filter_within_own_user = false;

			if (self::$userData['type'] != USER_TYPE_SUPER_ADMIN) {
				$private_fields_filter = array_diff_key($options['filter'], $limited_output_fields);

				if ($private_fields_filter) {
					if ($options['searchByAny'] !== null && $options['searchByAny'] !== false) {
						$options['filter'] = array_intersect_key($options['filter'], $limited_output_fields);

						$this->dbFilter('users u', ['filter' => $private_fields_filter] + $options, $sqlParts);

						if (array_key_exists('filter', $sqlParts['where'])) {
							$sqlParts['where']['filter'] =
								'(u.userid='.self::$userData['userid'].' AND '.$sqlParts['where']['filter'].')';
						}
					}
					else {
						$filter_within_own_user = true;
					}
				}
			}

			$this->dbFilter('users u', $options, $sqlParts);

			if ($provisioned_condition !== null) {
				if ($options['searchByAny'] !== null && $options['searchByAny'] !== false
						&& array_key_exists('filter', $sqlParts['where'])) {
					$sqlParts['where']['filter'] =
						'('. $sqlParts['where']['filter'].' OR '.$provisioned_condition.')';
				}
				else {
					$sqlParts['where'][] = $provisioned_condition;
				}
			}

			if ($filter_within_own_user && array_key_exists('filter', $sqlParts['where'])) {
				$sqlParts['where']['userid'] = 'u.userid='.self::$userData['userid'];
			}
		}

		// search
		if (is_array($options['search'])) {
			if (isset($options['search']['passwd'])) {
				self::exception(ZBX_API_ERROR_PARAMETERS, _('It is not possible to search by user password.'));
			}

			$search_within_own_user = false;

			if (self::$userData['type'] != USER_TYPE_SUPER_ADMIN) {
				$private_fields_search = array_diff_key($options['search'], $limited_output_fields);

				if ($private_fields_search) {
					if ($options['searchByAny']) {
						$options['search'] = array_intersect_key($options['search'], $limited_output_fields);

						zbx_db_search('users u', ['search' => $private_fields_search] + $options, $sqlParts);

						if (array_key_exists('search', $sqlParts['where'])) {
							$sqlParts['where']['search'] =
								'(u.userid='.self::$userData['userid'].' AND '.$sqlParts['where']['search'].')';
						}
					}
					else {
						$search_within_own_user = true;
					}
				}
			}

			zbx_db_search('users u', $options, $sqlParts);

			if ($search_within_own_user && array_key_exists('search', $sqlParts['where'])) {
				$sqlParts['where']['userid'] = 'u.userid='.self::$userData['userid'];
			}
		}

		// limit
		if (zbx_ctype_digit($options['limit']) && $options['limit']) {
			$sqlParts['limit'] = $options['limit'];
		}

		$sqlParts = $this->applyQueryOutputOptions($this->tableName(), $this->tableAlias(), $options, $sqlParts);
		$sqlParts = $this->applyQuerySortOptions($this->tableName(), $this->tableAlias(), $options, $sqlParts);
		$res = DBselect(self::createSelectQueryFromParts($sqlParts), $sqlParts['limit']);

		if ($options['countOutput']) {
			return DBfetch($res)['rowscount'];
		}

		if (self::$userData['type'] == USER_TYPE_SUPER_ADMIN) {
			while ($user = DBfetch($res)) {
				$result[$user['userid']] = $user;
			}
		}
		else {
			while ($user = DBfetch($res)) {
				$result[$user['userid']] = bccomp($user['userid'], self::$userData['userid']) == 0
					? $user
					: array_intersect_key($user, $limited_output_fields);
			}
		}

		/*
		 * Adding objects
		 */
		if ($options['getAccess'] !== null
				&& (self::$userData['type'] == USER_TYPE_SUPER_ADMIN
					|| array_key_exists(self::$userData['userid'], $result))) {
			if (self::$userData['type'] == USER_TYPE_SUPER_ADMIN) {
				foreach ($result as $userid => $foo) {
					$result[$userid] += ['gui_access' => 0, 'debug_mode' => 0, 'users_status' => 0];
				}
				$userids = array_keys($result);
			}
			else {
				$result[self::$userData['userid']] += ['gui_access' => 0, 'debug_mode' => 0, 'users_status' => 0];
				$userids = [self::$userData['userid']];
			}

			$access = DBselect(
				'SELECT ug.userid,MAX(g.gui_access) AS gui_access,MAX(g.debug_mode) AS debug_mode,'.
					' MAX(g.users_status) AS users_status'.
				' FROM usrgrp g'.
				' JOIN users_groups ug ON g.usrgrpid=ug.usrgrpid'.
				' WHERE '.dbConditionId('ug.userid', $userids).
				' GROUP BY ug.userid'
			);

			while ($userAccess = DBfetch($access)) {
				$result[$userAccess['userid']] = zbx_array_merge($result[$userAccess['userid']], $userAccess);
			}
		}

		if ($result) {
			$result = $this->addRelatedObjects($options, $result);
			$result = $this->unsetExtraFields($result, ['roleid'], $options['output']);
		}

		// removing keys
		if (!$options['preservekeys']) {
			$result = zbx_cleanHashes($result);
		}

		return $result;
	}

	protected function applyQueryOutputOptions($table_name, $table_alias, array $options, array $sql_parts): array {
		$sql_parts = parent::applyQueryOutputOptions($table_name, $table_alias, $options, $sql_parts);

		if ($options['countOutput']) {
			return $sql_parts;
		}

		if ($options['selectRole'] !== null) {
			$sql_parts = $this->addQuerySelect($this->fieldId('roleid'), $sql_parts);
		}

		if (is_array($options['output']) && in_array('provisioned', $options['output'])) {
			$sql_parts['select'][] =
				'CASE WHEN u.userdirectoryid IS NOT NULL'.
				' THEN '.self::PROVISION_STATUS_YES.
				' ELSE '.self::PROVISION_STATUS_NO.
				' END AS provisioned';
		}

		return $sql_parts;
	}

	/**
	 * @param array $users
	 *
	 * @return array
	 */
	public function create(array $users) {
		if (self::$userData['type'] != USER_TYPE_SUPER_ADMIN) {
			self::exception(ZBX_API_ERROR_PERMISSIONS,
				_s('No permissions to call "%1$s.%2$s".', 'user', __FUNCTION__)
			);
		}

		$this->validateCreate($users);
		self::createForce($users);

		return ['userids' => array_column($users, 'userid')];
	}

	/**
	 * @param array $users
	 */
	private static function createForce(array &$users): void {
		$userids = DB::insert('users', $users);

		foreach ($users as &$user) {
			$user['userid'] = array_shift($userids);
		}
		unset($user);

		self::updateGroups($users);
		self::updateUgSets($users);
		self::updateMedias($users);

		foreach ($users as &$user) {
			unset($user['role_type']);
		}
		unset($user);

		if (array_key_exists('ts_provisioned', $users[0])) {
			self::addAuditLogByUser(null, CWebUser::getIp(), CProvisioning::AUDITLOG_USERNAME, CAudit::ACTION_ADD,
				CAudit::RESOURCE_USER, $users
			);
		}
		else {
			self::addAuditLog(CAudit::ACTION_ADD, CAudit::RESOURCE_USER, $users);
		}
	}

	/**
	 * @param array $users
	 *
	 * @throws APIException if the input is invalid.
	 */
	private function validateCreate(array &$users) {
		$locales = LANG_DEFAULT.','.implode(',', array_keys(getLocales()));
		$timezones = TIMEZONE_DEFAULT.','.implode(',', array_keys(CTimezoneHelper::getList()));
		$themes = THEME_DEFAULT.','.implode(',', array_keys(APP::getThemes()));

		$api_input_rules = ['type' => API_OBJECTS, 'flags' => API_NOT_EMPTY | API_NORMALIZE, 'uniq' => [['username']], 'fields' => [
			'username' =>		['type' => API_STRING_UTF8, 'flags' => API_REQUIRED | API_NOT_EMPTY, 'length' => DB::getFieldLength('users', 'username')],
			'name' =>			['type' => API_STRING_UTF8, 'length' => DB::getFieldLength('users', 'name')],
			'surname' =>		['type' => API_STRING_UTF8, 'length' => DB::getFieldLength('users', 'surname')],
			'passwd' =>			['type' => API_STRING_UTF8, 'flags' => API_NOT_EMPTY, 'length' => 255],
			'url' =>			['type' => API_URL, 'length' => DB::getFieldLength('users', 'url')],
			'autologin' =>		['type' => API_INT32, 'in' => '0,1'],
			'autologout' =>		['type' => API_TIME_UNIT, 'flags' => API_NOT_EMPTY, 'in' => '0,90:'.SEC_PER_DAY],
			'lang' =>			['type' => API_STRING_UTF8, 'flags' => API_NOT_EMPTY, 'in' => $locales, 'length' => DB::getFieldLength('users', 'lang')],
			'refresh' =>		['type' => API_TIME_UNIT, 'flags' => API_NOT_EMPTY, 'in' => '0:'.SEC_PER_HOUR],
			'theme' =>			['type' => API_STRING_UTF8, 'in' => $themes, 'length' => DB::getFieldLength('users', 'theme')],
			'rows_per_page' =>	['type' => API_INT32, 'in' => '1:999999'],
			'timezone' =>		['type' => API_STRING_UTF8, 'in' => $timezones, 'length' => DB::getFieldLength('users', 'timezone')],
			'roleid' =>			['type' => API_ID],
			'usrgrps' =>		['type' => API_OBJECTS, 'uniq' => [['usrgrpid']], 'fields' => [
				'usrgrpid' =>		['type' => API_ID, 'flags' => API_REQUIRED]
			]],
			'medias' =>			['type' => API_OBJECTS, 'fields' => self::getMediaValidationFields()]
		]];

		if (!CApiInputValidator::validate($api_input_rules, $users, '/', $error)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, $error);
		}

		foreach ($users as $i => &$user) {
			$user = $this->checkLoginOptions($user);

			if (array_key_exists('passwd', $user)) {
				$this->checkPassword($user, '/'.($i + 1).'/passwd');
			}

			if (array_key_exists('passwd', $user)) {
				$user['passwd'] = password_hash($user['passwd'], PASSWORD_BCRYPT, ['cost' => ZBX_BCRYPT_COST]);
			}
		}
		unset($user);

		$this->checkDuplicates(array_column($users, 'username'));
		$this->checkLanguages(array_column($users, 'lang'));

		$db_roles = self::getDbRoles($users);
		self::checkRoles($users, $db_roles);
		self::addRoleType($users, $db_roles);

		self::checkUserGroups($users, $db_user_groups);
		self::checkEmptyPassword($users, $db_user_groups);
		self::checkMediaTypes($users, $db_mediatypes);
		self::checkMediaRecipients($users, $db_mediatypes);
	}

	/**
	 * @param array $users
	 *
	 * @return array
	 */
	public function update(array $users) {
		$this->validateUpdate($users, $db_users);
		self::updateForce($users, $db_users);

		return ['userids' => array_column($users, 'userid')];
	}

	/**
	 * @param array $users
	 * @param array $db_users
	 *
	 * @throws APIException if the input is invalid.
	 */
	private function validateUpdate(array &$users, ?array &$db_users = null) {
		$locales = LANG_DEFAULT.','.implode(',', array_keys(getLocales()));
		$timezones = TIMEZONE_DEFAULT.','.implode(',', array_keys(CTimezoneHelper::getList()));
		$themes = THEME_DEFAULT.','.implode(',', array_keys(APP::getThemes()));

		$api_input_rules = ['type' => API_OBJECTS, 'flags' => API_NOT_EMPTY | API_NORMALIZE, 'uniq' => [['userid'], ['username']], 'fields' => [
			'userid' =>			['type' => API_ID, 'flags' => API_REQUIRED],
			'username' =>		['type' => API_STRING_UTF8, 'flags' => API_NOT_EMPTY, 'length' => DB::getFieldLength('users', 'username')],
			'name' =>			['type' => API_STRING_UTF8, 'length' => DB::getFieldLength('users', 'name')],
			'surname' =>		['type' => API_STRING_UTF8, 'length' => DB::getFieldLength('users', 'surname')],
			'current_passwd' =>	['type' => API_STRING_UTF8, 'flags' => API_NOT_EMPTY, 'length' => 255],
			'passwd' =>			['type' => API_STRING_UTF8, 'flags' => API_NOT_EMPTY, 'length' => 255],
			'url' =>			['type' => API_URL, 'length' => DB::getFieldLength('users', 'url')],
			'autologin' =>		['type' => API_INT32, 'in' => '0,1'],
			'autologout' =>		['type' => API_TIME_UNIT, 'flags' => API_NOT_EMPTY, 'in' => '0,90:'.SEC_PER_DAY],
			'lang' =>			['type' => API_STRING_UTF8, 'flags' => API_NOT_EMPTY, 'in' => $locales, 'length' => DB::getFieldLength('users', 'lang')],
			'refresh' =>		['type' => API_TIME_UNIT, 'flags' => API_NOT_EMPTY, 'in' => '0:'.SEC_PER_HOUR],
			'theme' =>			['type' => API_STRING_UTF8, 'in' => $themes, 'length' => DB::getFieldLength('users', 'theme')],
			'rows_per_page' =>	['type' => API_INT32, 'in' => '1:999999'],
			'timezone' =>		['type' => API_STRING_UTF8, 'in' => $timezones, 'length' => DB::getFieldLength('users', 'timezone')],
			'roleid' =>			['type' => API_ID],
			'usrgrps' =>		['type' => API_OBJECTS, 'uniq' => [['usrgrpid']], 'fields' => [
				'usrgrpid' =>		['type' => API_ID, 'flags' => API_REQUIRED]
			]],
			'medias' =>			['type' => API_OBJECTS, 'flags' => API_ALLOW_UNEXPECTED, 'uniq' => [['mediaid']], 'fields' => [
				'mediaid' =>		['type' => API_ID]
			]]
		]];

		if (!CApiInputValidator::validate($api_input_rules, $users, '/', $error)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, $error);
		}

		$db_users = $this->get([
			'output' => [],
			'userids' => array_column($users, 'userid'),
			'editable' => true,
			'preservekeys' => true
		]);

		// 'passwd' can't be received by the user.get method
		$db_users = DB::select('users', [
			'output' => ['userid', 'username', 'name', 'surname', 'passwd', 'url', 'autologin', 'autologout', 'lang',
				'refresh', 'theme', 'rows_per_page', 'timezone', 'roleid', 'userdirectoryid'
			],
			'userids' => array_keys($db_users),
			'preservekeys' => true
		]);

		if (array_diff_key(array_column($users, 'userid', 'userid'), $db_users)) {
			self::exception(ZBX_API_ERROR_PERMISSIONS, _('No permissions to referred object or it does not exist!'));
		}

		// Get readonly super admin role ID and name.
		[$readonly_superadmin_role] = DBfetchArray(DBselect(
			'SELECT roleid,name'.
			' FROM role'.
			' WHERE type='.USER_TYPE_SUPER_ADMIN.
				' AND readonly=1'
		));

		$superadminids_to_update = [];
		$usernames = [];

		foreach ($users as $i => &$user) {
			$db_user = $db_users[$user['userid']];

			if ($db_user['userdirectoryid'] != 0) {
				$upd_user = DB::getUpdatedValues('users',
					array_intersect_key($user, array_flip(['username', 'passwd'])), $db_users[$user['userid']]
				);

				if ($upd_user) {
					self::exception(ZBX_API_ERROR_PARAMETERS, _s('Invalid parameter "%1$s": %2$s.',
						'/'.($i + 1),
						_s('cannot update readonly parameter "%1$s" of provisioned user', key($upd_user))
					));
				}
			}

			$user = $this->checkLoginOptions($user);

			if (array_key_exists('username', $user) && $user['username'] !== $db_user['username']) {
				$usernames[] = $user['username'];
			}

			if (array_key_exists('current_passwd', $user)) {
				if (!password_verify($user['current_passwd'], $db_user['passwd'])) {
					self::exception(ZBX_API_ERROR_PARAMETERS, _('Incorrect current password.'));
				}
			}

			if (array_key_exists('passwd', $user) && $this->checkPassword($user + $db_user, '/'.($i + 1).'/passwd')) {
				if ($user['userid'] == self::$userData['userid'] && !array_key_exists('current_passwd', $user)) {
					self::exception(ZBX_API_ERROR_PARAMETERS, _('Current password is mandatory.'));
				}

				$user['passwd'] = password_hash($user['passwd'], PASSWORD_BCRYPT, ['cost' => ZBX_BCRYPT_COST]);
			}

			unset($user['current_passwd']);

			if (array_key_exists('roleid', $user) && $user['roleid'] && $user['roleid'] != $db_user['roleid']) {
				if ($db_user['roleid'] == $readonly_superadmin_role['roleid']) {
					$superadminids_to_update[] = $user['userid'];
				}
			}

			if ($db_user['username'] === ZBX_GUEST_USER) {
				// Additional validation for guest user.
				if (array_key_exists('username', $user) && $user['username'] !== $db_user['username']) {
					self::exception(ZBX_API_ERROR_PARAMETERS, _('Cannot rename guest user.'));
				}

				if (array_key_exists('lang', $user)) {
					self::exception(ZBX_API_ERROR_PARAMETERS, _('Not allowed to set language for user "guest".'));
				}

				if (array_key_exists('theme', $user)) {
					self::exception(ZBX_API_ERROR_PARAMETERS, _('Not allowed to set theme for user "guest".'));
				}

				if (array_key_exists('passwd', $user)) {
					self::exception(ZBX_API_ERROR_PARAMETERS, _('Not allowed to set password for user "guest".'));
				}
			}
		}
		unset($user);

		// Check that at least one active user will remain with readonly super admin role.
		if ($superadminids_to_update) {
			$db_superadmins = DBselect(
				'SELECT NULL'.
				' FROM users u'.
				' WHERE u.roleid='.$readonly_superadmin_role['roleid'].
					' AND '.dbConditionId('u.userid', $superadminids_to_update, true).
					' AND EXISTS('.
						'SELECT NULL'.
						' FROM usrgrp g,users_groups ug'.
						' WHERE g.usrgrpid=ug.usrgrpid'.
							' AND ug.userid=u.userid'.
						' GROUP BY ug.userid'.
						' HAVING MAX(g.gui_access)<'.GROUP_GUI_ACCESS_DISABLED.
							' AND MAX(g.users_status)='.GROUP_STATUS_ENABLED.
					')'
			, 1);

			if (!DBfetch($db_superadmins)) {
				self::exception(ZBX_API_ERROR_PARAMETERS,
					_s('At least one active user must exist with role "%1$s".', $readonly_superadmin_role['name'])
				);
			}
		}

		$db_roles = self::getDbRoles($users, $db_users);
		self::checkRoles($users, $db_roles, $db_users);
		self::addRoleType($users, $db_roles, $db_users);

		self::addAffectedObjects($users, $db_users);

		self::validateMedias($users, $db_users);

		self::checkOwnParameters($users, $db_users);

		if ($usernames) {
			$this->checkDuplicates($usernames);
		}
		$this->checkLanguages(zbx_objectValues($users, 'lang'));

		self::checkUserGroups($users, $db_user_groups, $db_users);
		self::checkEmptyPassword($users, $db_user_groups, $db_users);
		self::checkMediaTypes($users, $db_mediatypes);
		self::checkMediaRecipients($users, $db_mediatypes);
	}

	private static function getMediaValidationFields(bool $is_update = false): array {
		$api_required = $is_update ? 0 : API_REQUIRED;

		$specific_rules = $is_update
			? [
				'mediaid' =>	['type' => API_ANY]
			]
			: [];

		return $specific_rules + [
			'mediatypeid' =>	['type' => API_ID, 'flags' => $api_required],
			'sendto' =>			['type' => API_STRINGS_UTF8, 'flags' => $api_required | API_NOT_EMPTY | API_NORMALIZE],
			'active' =>			['type' => API_INT32, 'in' => implode(',', [MEDIA_STATUS_ACTIVE, MEDIA_STATUS_DISABLED])],
			'severity' =>		['type' => API_INT32, 'in' => '0:63'],
			'period' =>			['type' => API_TIME_PERIOD, 'flags' => API_ALLOW_USER_MACRO, 'length' => DB::getFieldLength('media', 'period')]
		];
	}

	private static function validateMedias(array &$users, array &$db_users): void {
		foreach ($users as $i1 => &$user) {
			if (!array_key_exists('medias', $user)) {
				continue;
			}

			$path = '/'.($i1 + 1).'/medias';
			$db_medias = $db_users[$user['userid']]['medias'];

			foreach ($user['medias'] as $i2 => &$media) {
				$is_update = array_key_exists('mediaid', $media);

				if ($is_update) {
					if (!array_key_exists($media['mediaid'], $db_users[$user['userid']]['medias'])) {
						self::exception(ZBX_API_ERROR_PARAMETERS, _s('Invalid parameter "%1$s": %2$s.',
							$path.'/'.($i2 + 1).'/mediaid', _('object does not exist or belongs to another object')
						));
					}
				}

				$api_input_rules = ['type' => API_OBJECT, 'fields' => self::getMediaValidationFields($is_update)];

				if (!CApiInputValidator::validate($api_input_rules, $media, $path.'/'.($i2 + 1), $error)) {
					self::exception(ZBX_API_ERROR_PARAMETERS, $error);
				}

				if ($is_update) {
					$db_media = $db_medias[$media['mediaid']];
					unset($db_medias[$media['mediaid']]);

					if ($db_media['userdirectory_mediaid'] != 0) {
						$_media = [];

						if (array_key_exists('mediatypeid', $media)) {
							$_media['mediatypeid'] = $media['mediatypeid'];
						}

						if (array_key_exists('sendto', $media)) {
							$_media['sendto'] = implode("\n", $media['sendto']);
						}

						$upd_media = DB::getUpdatedValues('media', $_media, $db_media);

						if ($upd_media) {
							self::exception(ZBX_API_ERROR_PARAMETERS, _s('Invalid parameter "%1$s": %2$s.',
								$path.'/'.($i2 + 1),
								_s('cannot update readonly parameter "%1$s" of provisioned user', key($upd_media))
							));
						}
					}

					if (!array_key_exists('mediatypeid', $media)) {
						$media['mediatypeid'] = $db_media['mediatypeid'];
					}
				}
			}
			unset($media);

			foreach ($db_medias as $db_media) {
				if ($db_media['userdirectory_mediaid'] != 0) {
					unset($db_users[$user['userid']]['medias'][$db_media['mediaid']]);
				}
			}
		}
		unset($user);
	}

	/**
	 * @param array $users
	 * @param array $db_users
	 */
	private static function updateForce(array $users, array $db_users): void {
		$upd_users = [];

		foreach ($users as $user) {
			$upd_user = DB::getUpdatedValues('users', $user, $db_users[$user['userid']]);

			if ($upd_user) {
				$upd_users[] = [
					'values' => $upd_user,
					'where' => ['userid' => $user['userid']]
				];
			}
		}

		if ($upd_users) {
			DB::update('users', $upd_users);
		}

		self::terminateActiveSessionsOnPasswordUpdate($users);
		self::updateGroups($users, $db_users);
		self::updateUgSets($users, $db_users);
		self::updateMedias($users, $db_users);

		foreach ($users as &$user) {
			unset($user['role_type']);
			unset($db_users[$user['userid']]['role_type']);
		}
		unset($user);

		if (array_key_exists('ts_provisioned', $users[0])) {
			self::addAuditLogByUser(null, CWebUser::getIp(), CProvisioning::AUDITLOG_USERNAME, CAudit::ACTION_UPDATE,
				CAudit::RESOURCE_USER, $users, $db_users
			);
		}
		else {
			self::addAuditLog(CAudit::ACTION_UPDATE, CAudit::RESOURCE_USER, $users, $db_users);
		}
	}

	private static function addAffectedObjects(array $users, array &$db_users): void {
		self::addAffectedUserGroups($users, $db_users);
		self::addAffectedMedias($users, $db_users);
	}

	private static function addAffectedUserGroups(array $users, array &$db_users): void {
		$userids = [];

		foreach ($users as $user) {
			if (array_key_exists('usrgrps', $user) || self::ugSetUpdateRequired($user, $db_users[$user['userid']])
					|| self::emptyPasswordCheckRequired($user, $db_users[$user['userid']])) {
				$userids[] = $user['userid'];
				$db_users[$user['userid']]['usrgrps'] = [];
			}
		}

		if (!$userids) {
			return;
		}

		$result = DBselect(
			'SELECT ug.id,ug.usrgrpid,ug.userid,g.gui_access'.
			' FROM users_groups ug,usrgrp g'.
			' WHERE ug.usrgrpid=g.usrgrpid'.
				' AND '.dbConditionId('ug.userid', $userids)
		);

		while ($db_usrgrp = DBfetch($result)) {
			$db_users[$db_usrgrp['userid']]['usrgrps'][$db_usrgrp['id']] =
				array_diff_key($db_usrgrp, array_flip(['userid']));
		}
	}

	private static function ugSetUpdateRequired(array $user, array $db_user): bool {
		if ($user['role_type'] == $db_user['role_type']) {
			return false;
		}

		if ($user['role_type'] !== null && $user['role_type'] != USER_TYPE_SUPER_ADMIN
				&& ($db_user['role_type'] === null || $db_user['role_type'] == USER_TYPE_SUPER_ADMIN)) {
			return true;
		}

		if (($user['role_type'] === null || $user['role_type'] == USER_TYPE_SUPER_ADMIN)
				&& $db_user['role_type'] !== null && $db_user['role_type'] != USER_TYPE_SUPER_ADMIN) {
			return true;
		}

		return false;
	}

	private static function emptyPasswordCheckRequired(array $user, array $db_user): bool {
		return !array_key_exists('passwd', $user) && $db_user['passwd'] === '';
	}

	private static function addAffectedMedias(array $users, array &$db_users): void {
		$userids = [];

		foreach ($users as $user) {
			if (array_key_exists('medias', $user)) {
				$userids[] = $user['userid'];
				$db_users[$user['userid']]['medias'] = [];
			}
		}

		if (!$userids) {
			return;
		}

		$options = [
			'output' => ['mediaid', 'userid', 'mediatypeid', 'sendto', 'active', 'severity', 'period',
				'userdirectory_mediaid'
			],
			'filter' => ['userid' => $userids]
		];
		$db_medias = DBselect(DB::makeSql('media', $options));

		while ($db_media = DBfetch($db_medias)) {
			$db_users[$db_media['userid']]['medias'][$db_media['mediaid']] =
				array_diff_key($db_media, array_flip(['userid']));
		}
	}

	/**
	 * Check whether current user is allowed to change specific own parameters.
	 *
	 * @param array $users
	 * @param array $db_users
	 *
	 * @throws APIException
	 */
	private static function checkOwnParameters(array $users, array $db_users): void {
		$user = self::getOwnUser($users);

		if ($user === null) {
			return;
		}

		$db_user = $db_users[$user['userid']];

		self::checkOwnUsername($user, $db_user);
		self::checkOwnRole($user, $db_user);
		self::checkOwnUserGroups($user, $db_user);
	}

	private static function getOwnUser(array $users): ?array {
		if (self::$userData['type'] == USER_TYPE_SUPER_ADMIN) {
			foreach ($users as $user) {
				if (bccomp($user['userid'], self::$userData['userid']) == 0) {
					return $user;
				}
			}

			return null;
		}

		return reset($users);
	}

	private static function checkOwnUsername(array $user, array $db_user): void {
		if (self::$userData['type'] == USER_TYPE_SUPER_ADMIN) {
			return;
		}

		if (array_key_exists('username', $user) && $user['username'] !== $db_user['username']) {
			self::exception(ZBX_API_ERROR_PARAMETERS,
				_s('Only Super admin users can update "%1$s" parameter.', 'username')
			);
		}
	}

	private static function checkOwnRole(array $user, array $db_user): void {
		if (array_key_exists('roleid', $user) && bccomp($user['roleid'], $db_user['roleid']) != 0) {
			self::exception(ZBX_API_ERROR_PARAMETERS, _('User cannot change own role.'));
		}
	}

	private static function checkOwnUserGroups(array $user, array $db_user): void {
		if (!array_key_exists('usrgrps', $user)) {
			return;
		}

		$usrgrpids = array_column($user['usrgrps'], 'usrgrpid');

		if (self::$userData['type'] == USER_TYPE_SUPER_ADMIN) {
			$disabled_user_group = DBfetch(DBselect(
				'SELECT NULL'.
				' FROM usrgrp'.
				' WHERE '.dbConditionId('usrgrpid', $usrgrpids).
					' AND ('.
						'gui_access='.GROUP_GUI_ACCESS_DISABLED.
						' OR users_status='.GROUP_STATUS_DISABLED.
					')',
				1
			));

			if ($disabled_user_group) {
				self::exception(ZBX_API_ERROR_PARAMETERS,
					_('User cannot add oneself to a disabled group or a group with disabled GUI access.')
				);
			}
		}
		else {
			$db_usrgrpids = array_column($db_user['usrgrps'], 'usrgrpid');

			if (array_diff($usrgrpids, $db_usrgrpids) || array_diff($db_usrgrpids, $usrgrpids)) {
				self::exception(ZBX_API_ERROR_PARAMETERS,
					_s('Only Super admin users can update "%1$s" parameter.', 'usrgrps')
				);
			}
		}
	}

	/**
	 * Check for duplicated users.
	 *
	 * @param array $usernames
	 *
	 * @throws APIException  if user already exists.
	 */
	private function checkDuplicates(array $usernames) {
		$db_users = DB::select('users', [
			'output' => ['username'],
			'filter' => ['username' => $usernames],
			'limit' => 1
		]);

		if ($db_users) {
			self::exception(ZBX_API_ERROR_PARAMETERS,
				_s('User with username "%1$s" already exists.', $db_users[0]['username'])
			);
		}
	}

	private static function checkUserGroups(array $users, ?array &$db_user_groups = null,
			?array $db_users = null): void {
		$user_group_indexes = [];

		foreach ($users as $i1 => $user) {
			if (!array_key_exists('usrgrps', $user)) {
				continue;
			}

			if ($db_users !== null && $db_users[$user['userid']]['userdirectoryid'] != 0
					&& self::userGroupsChanged($user, $db_users[$user['userid']])) {
				self::exception(ZBX_API_ERROR_PARAMETERS, _s('Invalid parameter "%1$s": %2$s.',
					'/'.($i1 + 1),
					_s('cannot update readonly parameter "%1$s" of provisioned user', 'usrgrps')
				));
			}

			foreach ($user['usrgrps'] as $i2 => $user_group) {
				$user_group_indexes[$user_group['usrgrpid']][$i1] = $i2;
			}
		}

		if (!$user_group_indexes) {
			return;
		}

		$db_user_groups = DB::select('usrgrp', [
			'output' => ['gui_access'],
			'usrgrpids' => array_keys($user_group_indexes),
			'preservekeys' => true
		]);

		foreach ($user_group_indexes as $usrgrpid => $indexes) {
			if (!array_key_exists($usrgrpid, $db_user_groups)) {
				$i1 = key($indexes);
				$i2 = $indexes[$i1];

				self::exception(ZBX_API_ERROR_PARAMETERS,
					_s('Invalid parameter "%1$s": %2$s.', '/'.($i1 + 1).'/usrgrps/'.($i2 + 1),
						_('object does not exist')
					)
				);
			}
		}
	}

	private static function checkEmptyPassword(array $users, ?array $db_user_groups, ?array $db_users = null): void {
		foreach ($users as $i => $user) {
			$check = false;

			if ($db_users === null) {
				$check = !array_key_exists('passwd', $user);
			}
			else {
				$db_user = $db_users[$user['userid']];

				if (!array_key_exists('passwd', $user) && $db_user['passwd'] === ''
						&& $db_user['userdirectoryid'] == 0) {
					$user_groups_changed = array_key_exists('usrgrps', $user)
						&& self::userGroupsChanged($user, $db_user);

					$user_groups_empty = array_key_exists('usrgrps', $user) ? !$user['usrgrps'] : !$db_user['usrgrps'];

					if ($user_groups_changed || $user_groups_empty) {
						$check = true;
					}
				}
			}

			if (!$check) {
				unset($users[$i]);
			}
		}

		if (!$users) {
			return;
		}

		foreach ($users as $i => $user) {
			$gui_access = GROUP_GUI_ACCESS_SYSTEM;

			if (array_key_exists('usrgrps', $user)) {
				foreach ($user['usrgrps'] as $user_group) {
					if ($db_user_groups[$user_group['usrgrpid']]['gui_access'] > $gui_access) {
						$gui_access = $db_user_groups[$user_group['usrgrpid']]['gui_access'];
					}
				}
			}
			elseif ($db_users !== null) {
				foreach ($db_users[$user['userid']]['usrgrps'] as $db_user_group) {
					if ($db_user_group['gui_access'] > $gui_access) {
						$gui_access = $db_user_group['gui_access'];
					}
				}
			}

			if ($gui_access != GROUP_GUI_ACCESS_DISABLED
					&& self::getAuthTypeByGuiAccess($gui_access) == ZBX_AUTH_INTERNAL) {
				if ($db_users === null) {
					$username = $user['username'];
				}
				else {
					$username = array_key_exists('username', $user)
						? $user['username']
						: $db_users[$user['userid']]['username'];
				}

				self::exception(ZBX_API_ERROR_PARAMETERS,
					_s('User "%1$s" must have a password, because internal authentication is in effect.', $username)
				);
			}
		}
	}

	private static function userGroupsChanged(array $user, array $db_user): bool {
		$usrgrpids = array_column($user['usrgrps'], 'usrgrpid');
		$db_usrgrpids = array_column($db_user['usrgrps'], 'usrgrpid');

		return array_diff($usrgrpids, $db_usrgrpids) || array_diff($db_usrgrpids, $usrgrpids);
	}

	/**
	 * Check if 'mediatypeid' parameter of the given users with medias is valid.
	 *
	 * @param array      $users
	 * @param array|null $db_media_types
	 *
	 * @throws APIException
	 */
	private static function checkMediaTypes(array $users, ?array &$db_media_types = null): void {
		$media_indexes = [];

		foreach ($users as $i1 => &$user) {
			if (!array_key_exists('medias', $user)) {
				continue;
			}

			foreach ($user['medias'] as $i2 => &$media) {
				$media_indexes[$media['mediatypeid']][$i1][] = $i2;
			}
			unset($media);
		}
		unset($user);

		if (!$media_indexes) {
			return;
		}

		$db_media_types = DB::select('media_type', [
			'output' => ['type'],
			'mediatypeids' => array_keys($media_indexes),
			'preservekeys' => true
		]);

		foreach ($media_indexes as $mediatypeid => $indexes) {
			if (!array_key_exists($mediatypeid, $db_media_types)) {
				$i1 = key($indexes);
				$i2 = reset($indexes[$i1]);

				self::exception(ZBX_API_ERROR_PARAMETERS, _s('Invalid parameter "%1$s": %2$s.',
					'/'.($i1 + 1).'/medias/'.($i2 + 1).'/mediatypeid', _('object does not exist')
				));
			}
		}
	}

	/**
	 * Check if 'sendto' parameter value of the given users with medias is valid.
	 *
	 * @param array      $users
	 * @param array|null $db_media_types
	 *
	 * @throws APIException
	 */
	private static function checkMediaRecipients(array $users, ?array $db_media_types): void {
		if (!$db_media_types) {
			return;
		}

		$email_validator = new CEmailValidator();
		$length = DB::getFieldLength('media', 'sendto');

		foreach ($users as $i1 => $user) {
			if (!array_key_exists('medias', $user)) {
				continue;
			}

			foreach ($user['medias'] as $i2 => $media) {
				if (!array_key_exists('sendto', $media)) {
					continue;
				}

				if ($db_media_types[$media['mediatypeid']]['type'] != MEDIA_TYPE_EMAIL && count($media['sendto']) > 1) {
					self::exception(ZBX_API_ERROR_PARAMETERS, _s('Invalid parameter "%1$s": %2$s.',
						'/'.($i1 + 1).'/medias/'.($i2 + 1).'/sendto', _('a character string is expected')
					));
				}

				if ($db_media_types[$media['mediatypeid']]['type'] == MEDIA_TYPE_EMAIL) {
					foreach ($media['sendto'] as $i3 => $email) {
						if ($email === '') {
							self::exception(ZBX_API_ERROR_PARAMETERS, _s('Invalid parameter "%1$s": %2$s.',
								'/'.($i1 + 1).'/medias/'.($i2 + 1).'/sendto/'.($i3 + 1), _('cannot be empty')
							));
						}

						if (!$email_validator->validate($email)) {
							self::exception(ZBX_API_ERROR_PARAMETERS, _s('Invalid parameter "%1$s": %2$s.',
								'/'.($i1 + 1).'/medias/'.($i2 + 1).'/sendto/'.($i3 + 1),
								_('an email address is expected')
							));
						}
					}
				}

				if (mb_strlen(implode("\n", $media['sendto'])) > $length) {
					self::exception(ZBX_API_ERROR_PARAMETERS, _s('Invalid parameter "%1$s": %2$s.',
						'/'.($i1 + 1).'/medias/'.($i2 + 1).'/sendto', _('value is too long')
					));
				}
			}
		}
	}

	/**
	 * Check if specified language has dependent locale installed.
	 *
	 * @param array $languages
	 *
	 * @throws APIException if language locale is not installed.
	 */
	private function checkLanguages(array $languages) {
		foreach ($languages as $lang) {
			if ($lang !== LANG_DEFAULT && !setlocale(LC_MONETARY, zbx_locale_variants($lang))) {
				self::exception(ZBX_API_ERROR_PARAMETERS, _s('Language "%1$s" is not supported.', $lang));
			}
		}
	}

	/**
	 * @param array      $users
	 * @param array|null $db_users
	 */
	private static function getDbRoles(array $users, ?array $db_users = null): array {
		$roleids = [];

		foreach ($users as $user) {
			if (array_key_exists('roleid', $user) && $user['roleid'] != 0) {
				$roleids[$user['roleid']] = true;
			}

			if ($db_users !== null && $db_users[$user['userid']]['roleid'] != 0) {
				$roleids[$db_users[$user['userid']]['roleid']] = true;
			}
		}

		if (!$roleids) {
			return [];
		}

		return DB::select('role', [
			'output' => ['type'],
			'roleids' => array_keys($roleids),
			'preservekeys' => true
		]);
	}

	/**
	 * Check for valid user roles.
	 *
	 * @param array      $users
	 * @param array      $db_roles
	 * @param array|null $db_users
	 *
	 * @throws APIException
	 */
	private static function checkRoles(array $users, array $db_roles, ?array $db_users = null): void {
		foreach ($users as $i => $user) {
			if (!array_key_exists('roleid', $user)) {
				continue;
			}

			if ($db_users !== null && $db_users[$user['userid']]['userdirectoryid'] != 0
					&& bccomp($user['roleid'], $db_users[$user['userid']]['roleid']) != 0) {
				self::exception(ZBX_API_ERROR_PARAMETERS, _s('Invalid parameter "%1$s": %2$s.', '/'.($i + 1),
					_s('cannot update readonly parameter "%1$s" of provisioned user', 'roleid')
				));
			}

			if ($user['roleid'] != 0 && !array_key_exists($user['roleid'], $db_roles)) {
				self::exception(ZBX_API_ERROR_PARAMETERS, _s('Invalid parameter "%1$s": %2$s.', '/'.($i + 1).'/roleid',
					_('object does not exist')
				));
			}
		}
	}

	/**
	 * @param array      $users
	 * @param array      $db_roles
	 * @param array|null $db_users
	 */
	private static function addRoleType(array &$users, array $db_roles, ?array &$db_users = null): void {
		foreach ($users as &$user) {
			$user['role_type'] = null;

			if ($db_users !== null) {
				$db_users[$user['userid']]['role_type'] = null;
			}

			if (array_key_exists('roleid', $user) && $user['roleid'] != 0) {
				$user['role_type'] = $db_roles[$user['roleid']]['type'];
			}

			if ($db_users !== null && $db_users[$user['userid']]['roleid'] != 0) {
				if (!array_key_exists('roleid', $user)) {
					$user['role_type'] = $db_roles[$db_users[$user['userid']]['roleid']]['type'];
				}

				$db_users[$user['userid']]['role_type'] = $db_roles[$db_users[$user['userid']]['roleid']]['type'];
			}
		}
		unset($user);
	}

	/**
	 * Additional check to exclude an opportunity to enable auto-login and auto-logout options together..
	 *
	 * @param array  $user
	 * @param int    $user[]['autologin']   (optional)
	 * @param string $user[]['autologout']  (optional)
	 *
	 * @throws APIException
	 */
	private function checkLoginOptions(array $user) {
		if (!array_key_exists('autologout', $user) && array_key_exists('autologin', $user) && $user['autologin'] != 0) {
			$user['autologout'] = '0';
		}

		if (!array_key_exists('autologin', $user) && array_key_exists('autologout', $user)
				&& timeUnitToSeconds($user['autologout']) != 0) {
			$user['autologin'] = 0;
		}

		if (array_key_exists('autologin', $user) && array_key_exists('autologout', $user)
				&& $user['autologin'] != 0 && timeUnitToSeconds($user['autologout']) != 0) {
			self::exception(ZBX_API_ERROR_PARAMETERS,
				_('Auto-login and auto-logout options cannot be enabled together.')
			);
		}

		return $user;
	}

	/**
	 * Terminate all active sessions for user whose password was successfully updated.
	 *
	 * @param array      $users
	 */
	private static function terminateActiveSessionsOnPasswordUpdate(array $users): void {
		foreach ($users as $user) {
			if (array_key_exists('passwd', $user)) {
				DB::update('sessions', [
					'values' => ['status' => ZBX_SESSION_PASSIVE],
					'where' => ['userid' => $user['userid']]
				]);
			}
		}
	}

	/**
	 * @param array      $users
	 * @param null|array $db_users
	 */
	private static function updateGroups(array &$users, ?array $db_users = null): void {
		$ins_groups = [];
		$del_groupids = [];

		foreach ($users as &$user) {
			if (!array_key_exists('usrgrps', $user)) {
				continue;
			}

			$db_groups = $db_users !== null
				? array_column($db_users[$user['userid']]['usrgrps'], null, 'usrgrpid')
				: [];

			foreach ($user['usrgrps'] as &$group) {
				if (array_key_exists($group['usrgrpid'], $db_groups)) {
					$group['id'] = $db_groups[$group['usrgrpid']]['id'];
					unset($db_groups[$group['usrgrpid']]);
				}
				else {
					$ins_groups[] = [
						'userid' => $user['userid'],
						'usrgrpid' => $group['usrgrpid']
					];
				}
			}
			unset($group);

			$del_groupids = array_merge($del_groupids, array_column($db_groups, 'id'));
		}
		unset($user);

		if ($del_groupids) {
			DB::delete('users_groups', ['id' => $del_groupids]);
		}

		if ($ins_groups) {
			$groupids = DB::insert('users_groups', $ins_groups);
		}

		foreach ($users as &$user) {
			if (!array_key_exists('usrgrps', $user)) {
				continue;
			}

			foreach ($user['usrgrps'] as &$group) {
				if (!array_key_exists('id', $group)) {
					$group['id'] = array_shift($groupids);
				}
			}
			unset($group);
		}
		unset($user);
	}

	private static function updateUgSets(array $users, ?array $db_users = null): void {
		$ugsets = [];

		foreach ($users as &$user) {
			$usrgrpids = null;

			if ($user['role_type'] !== null && $user['role_type'] != USER_TYPE_SUPER_ADMIN) {
				if ($db_users === null) {
					if (array_key_exists('usrgrps', $user) && $user['usrgrps']) {
						$usrgrpids = array_column($user['usrgrps'], 'usrgrpid');
					}
				}
				elseif ($db_users[$user['userid']]['role_type'] !== null
						&& $db_users[$user['userid']]['role_type'] != USER_TYPE_SUPER_ADMIN) {
					if (array_key_exists('usrgrps', $user)) {
						$_usrgrpids = array_column($user['usrgrps'], 'usrgrpid');
						$_db_usrgrpids = array_column($db_users[$user['userid']]['usrgrps'], 'usrgrpid');

						if (array_diff($_usrgrpids, $_db_usrgrpids) || array_diff($_db_usrgrpids, $_usrgrpids)) {
							$usrgrpids = $_usrgrpids;
						}
					}
				}
				else {
					$_usrgrpids = array_key_exists('usrgrps', $user)
						? array_column($user['usrgrps'], 'usrgrpid')
						: array_column($db_users[$user['userid']]['usrgrps'], 'usrgrpid');

					if ($_usrgrpids) {
						$usrgrpids = $_usrgrpids;
					}
				}
			}
			elseif ($db_users !== null
					&& $db_users[$user['userid']]['role_type'] !== null
					&& $db_users[$user['userid']]['role_type'] != USER_TYPE_SUPER_ADMIN) {
				$usrgrpids = [];
			}

			if ($usrgrpids !== null) {
				$ugset_hash = self::getUgSetHash($usrgrpids);

				$ugsets[$ugset_hash]['hash'] = $ugset_hash;
				$ugsets[$ugset_hash]['usrgrpids'] = $usrgrpids;
				$ugsets[$ugset_hash]['userids'][] = $user['userid'];
			}
		}
		unset($user);

		if ($ugsets) {
			if ($db_users === null) {
				self::createUserUgSets($ugsets);
			}
			else {
				self::updateUserUgSets($ugsets);
			}
		}
	}

	private static function getUgSetHash(array $usrgrpids): string {
		usort($usrgrpids, 'bccomp');

		return hash('sha256', implode('|', $usrgrpids));
	}

	private static function createUserUgSets(array $ugsets): void {
		$ins_user_ugsets = [];

		$options = [
			'output' => ['ugsetid', 'hash'],
			'filter' => ['hash' => array_keys($ugsets)]
		];
		$result = DBselect(DB::makeSql('ugset', $options));

		while ($row = DBfetch($result)) {
			foreach ($ugsets[$row['hash']]['userids'] as $userid) {
				$ins_user_ugsets[] = [
					'userid' => $userid,
					'ugsetid' => $row['ugsetid']
				];
			}

			unset($ugsets[$row['hash']]);
		}

		if ($ugsets) {
			self::createUgSets($ugsets);

			foreach ($ugsets as $ugset) {
				foreach ($ugset['userids'] as $userid) {
					$ins_user_ugsets[] = [
						'userid' => $userid,
						'ugsetid' => $ugset['ugsetid']
					];
				}
			}
		}

		DB::insert('user_ugset', $ins_user_ugsets, false);
	}

	private static function updateUserUgSets(array $ugsets): void {
		$ins_user_ugsets = [];
		$upd_user_ugsets = [];

		$db_user_ugsetids = self::getDbUserUgSetIds($ugsets);
		$db_ugsetids = array_flip($db_user_ugsetids);

		$empty_ugset_hash = self::getUgSetHash([]);

		if (array_key_exists($empty_ugset_hash, $ugsets)) {
			DB::delete('user_ugset', ['userid' => $ugsets[$empty_ugset_hash]['userids']]);
			unset($ugsets[$empty_ugset_hash]);
		}

		if ($ugsets) {
			$options = [
				'output' => ['ugsetid', 'hash'],
				'filter' => ['hash' => array_keys($ugsets)]
			];
			$result = DBselect(DB::makeSql('ugset', $options));

			while ($row = DBfetch($result)) {
				$upd_userids = [];

				foreach ($ugsets[$row['hash']]['userids'] as $userid) {
					if (array_key_exists($userid, $db_user_ugsetids)) {
						$upd_userids[] = $userid;
						unset($db_user_ugsetids[$userid]);
					}
					else {
						$ins_user_ugsets[] = [
							'userid' => $userid,
							'ugsetid' => $row['ugsetid']
						];
					}
				}

				if ($upd_userids) {
					$upd_user_ugsets[] = [
						'values' => ['ugsetid' => $row['ugsetid']],
						'where' => ['userid' => $upd_userids]
					];

					if (array_key_exists($row['ugsetid'], $db_ugsetids)) {
						unset($db_ugsetids[$row['ugsetid']]);
					}
				}

				unset($ugsets[$row['hash']]);
			}

			if ($ugsets) {
				self::createUgSets($ugsets);

				foreach ($ugsets as $ugset) {
					$upd_userids = [];

					foreach ($ugset['userids'] as $userid) {
						if (array_key_exists($userid, $db_user_ugsetids)) {
							$upd_userids[] = $userid;
							unset($db_user_ugsetids[$userid]);
						}
						else {
							$ins_user_ugsets[] = [
								'userid' => $userid,
								'ugsetid' => $ugset['ugsetid']
							];
						}
					}

					if ($upd_userids) {
						$upd_user_ugsets[] = [
							'values' => ['ugsetid' => $ugset['ugsetid']],
							'where' => ['userid' => $upd_userids]
						];
					}
				}
			}

			if ($upd_user_ugsets) {
				DB::update('user_ugset', $upd_user_ugsets);
			}

			if ($ins_user_ugsets) {
				DB::insert('user_ugset', $ins_user_ugsets, false);
			}
		}

		if ($db_ugsetids) {
			self::deleteUnusedUgSets(array_keys($db_ugsetids));
		}
	}

	private static function getDbUserUgSetIds(array $ugsets): array {
		$userids = [];

		foreach ($ugsets as $ugset) {
			$userids = array_merge($userids, $ugset['userids']);
		}

		$options = [
			'output' => ['userid', 'ugsetid'],
			'userids' => $userids
		];
		$result = DBselect(DB::makeSql('user_ugset', $options));

		$db_user_ugsetids = [];

		while ($row = DBfetch($result)) {
			$db_user_ugsetids[$row['userid']] = $row['ugsetid'];
		}

		return $db_user_ugsetids;
	}

	private static function createUgSets(array &$ugsets): void {
		$ugsetids = DB::insert('ugset', $ugsets);

		foreach ($ugsets as &$ugset) {
			$ugset['ugsetid'] = array_shift($ugsetids);
		}
		unset($ugset);

		self::createUgSetGroups($ugsets);

		self::addUgSetPermissions($ugsets);
		self::createPermissions($ugsets);
	}

	private static function createUgSetGroups(array $ugsets): void {
		$ins_ugset_groups = [];

		foreach ($ugsets as $ugset) {
			foreach ($ugset['usrgrpids'] as $usrgrpid) {
				$ins_ugset_groups[] = ['ugsetid' => $ugset['ugsetid'], 'usrgrpid' => $usrgrpid];
			}
		}

		DB::insert('ugset_group', $ins_ugset_groups, false);
	}

	private static function addUgSetPermissions(array &$ugsets): void {
		$ugset_indexes = [];

		foreach ($ugsets as $i => &$ugset) {
			$ugset['permissions'] = [];

			foreach ($ugset['usrgrpids'] as $usrgrpid) {
				$ugset_indexes[$usrgrpid][] = $i;
			}
		}
		unset($ugset);

		if (!$ugset_indexes) {
			return;
		}

		$options = [
			'output' => ['groupid', 'id', 'permission'],
			'filter' => ['groupid' => array_keys($ugset_indexes)]
		];
		$result = DBselect(DB::makeSql('rights', $options));

		while ($row = DBfetch($result)) {
			foreach ($ugset_indexes[$row['groupid']] as $i) {
				if (!array_key_exists($row['id'], $ugsets[$i]['permissions'])
						|| ($ugsets[$i]['permissions'][$row['id']] != PERM_DENY
							&& ($row['permission'] == PERM_DENY
								|| $row['permission'] > $ugsets[$i]['permissions'][$row['id']]))) {
					$ugsets[$i]['permissions'][$row['id']] = $row['permission'];
				}
			}
		}
	}

	/**
	 * @param array $ugsets
	 */
	private static function createPermissions(array $ugsets): void {
		$ins_permissions = [];

		$hgset_groupids = self::getHgSetGroupIds($ugsets);

		foreach ($ugsets as $ugset) {
			foreach ($hgset_groupids as $hgsetid => $groupids) {
				if (!array_intersect(array_keys($ugset['permissions']), $groupids)) {
					continue;
				}

				$max_permission = null;

				foreach ($ugset['permissions'] as $groupid => $permission) {
					if (!in_array($groupid, $groupids)) {
						continue;
					}

					if ($max_permission === null
							|| ($max_permission != PERM_DENY
								&& ($permission == PERM_DENY || $permission > $max_permission))) {
						$max_permission = $permission;
					}
				}

				if ($max_permission != PERM_DENY) {
					$ins_permissions[] = [
						'ugsetid' => $ugset['ugsetid'],
						'hgsetid' => $hgsetid,
						'permission' => $max_permission
					];
				}
			}
		}

		if ($ins_permissions) {
			DB::insert('permission', $ins_permissions, false);
		}
	}

	private static function getHgSetGroupIds(array $ugsets): array {
		$groupids = [];

		foreach ($ugsets as $ugset) {
			foreach ($ugset['permissions'] as $groupid => $permission) {
				$groupids[$groupid] = true;
			}
		}

		$result = DBselect(
			'SELECT hgg.hgsetid,hgg.groupid'.
			' FROM hgset_group hgg'.
			' WHERE hgg.hgsetid IN('.
				'SELECT DISTINCT t1.hgsetid'.
				' FROM hgset_group t1'.
				' WHERE '.dbConditionId('t1.groupid', array_keys($groupids)).
			')'
		);

		$hgset_groupids = [];

		while ($row = DBfetch($result)) {
			$hgset_groupids[$row['hgsetid']][] = $row['groupid'];
		}

		return $hgset_groupids;
	}

	private static function deleteUnusedUgSets(array $db_ugsetids): void {
		$del_ugsetids = DBfetchColumn(DBselect(
			'SELECT u.ugsetid'.
			' FROM ugset u'.
			' LEFT JOIN user_ugset uu ON u.ugsetid=uu.ugsetid'.
			' WHERE '.dbConditionId('u.ugsetid', $db_ugsetids).
				' AND uu.userid IS NULL'
		), 'ugsetid');

		if ($del_ugsetids) {
			DB::delete('permission', ['ugsetid' => $del_ugsetids]);
			DB::delete('ugset_group', ['ugsetid' => $del_ugsetids]);
			DB::delete('ugset', ['ugsetid' => $del_ugsetids]);
		}
	}

	/**
	 * @param array      $users
	 * @param null|array $db_users
	 */
	private static function updateMedias(array &$users, ?array $db_users = null): void {
		$ins_medias = [];
		$upd_medias = [];
		$del_mediaids = [];

		foreach ($users as &$user) {
			if (!array_key_exists('medias', $user)) {
				continue;
			}

			$db_medias = $db_users !== null ? $db_users[$user['userid']]['medias'] : [];

			foreach ($user['medias'] as &$media) {
				if (array_key_exists('sendto', $media)) {
					$media['sendto'] = implode("\n", $media['sendto']);
				}

				if (array_key_exists('mediaid', $media)) {
					$db_media = $db_medias[$media['mediaid']];
					$upd_media = DB::getUpdatedValues('media', $media, $db_media);

					if ($upd_media) {
						$upd_medias[] = [
							'values' => $upd_media,
							'where' => ['mediaid' => $db_media['mediaid']]
						];
					}

					unset($db_medias[$media['mediaid']]);
				}
				else {
					$ins_medias[] = ['userid' => $user['userid']] + $media;
				}
			}
			unset($media);

			$del_mediaids = array_merge($del_mediaids, array_keys($db_medias));
		}
		unset($user);

		if ($del_mediaids) {
			DB::delete('media', ['mediaid' => $del_mediaids]);
		}

		if ($upd_medias) {
			DB::update('media', $upd_medias);
		}

		if ($ins_medias) {
			$mediaids = DB::insert('media', $ins_medias);
		}

		foreach ($users as &$user) {
			if (!array_key_exists('medias', $user)) {
				continue;
			}

			foreach ($user['medias'] as &$media) {
				if (!array_key_exists('mediaid', $media)) {
					$media['mediaid'] = array_shift($mediaids);
				}
			}
			unset($media);
		}
		unset($user);
	}

	/**
	 * @param array $userids
	 *
	 * @return array
	 */
	public function delete(array $userids) {
		$this->validateDelete($userids, $db_users);

		DB::delete('media', ['userid' => $userids]);
		DB::delete('profiles', ['userid' => $userids]);

		self::deleteUgSets($db_users);
		DB::delete('users_groups', ['userid' => $userids]);
		DB::delete('mfa_totp_secret', ['userid' => $userids]);
		DB::update('token', [
			'values' => ['creator_userid' => null],
			'where' => ['creator_userid' => $userids]
		]);
		DB::update('event_suppress', [
			'values' => ['userid' => null],
			'where' => ['userid' => $userids]
		]);

		$tokenids = DB::select('token', [
			'output' => [],
			'filter' => ['userid' => $userids],
			'preservekeys' => true
		]);
		CToken::deleteForce(array_keys($tokenids), false);

		DB::delete('users', ['userid' => $userids]);

		self::addAuditLog(CAudit::ACTION_DELETE, CAudit::RESOURCE_USER, $db_users);

		return ['userids' => $userids];
	}

	private static function deleteUgSets(array $db_users): void {
		$ugsets = [];
		$ugset_hash = self::getUgSetHash([]);

		foreach ($db_users as $db_user) {
			if ($db_user['role'] && $db_user['role']['type'] != USER_TYPE_SUPER_ADMIN
					&& $db_user['usrgrps']) {
				$ugsets[$ugset_hash]['hash'] = $ugset_hash;
				$ugsets[$ugset_hash]['usrgrpids'] = [];
				$ugsets[$ugset_hash]['userids'][] = $db_user['userid'];
			}
		}

		if ($ugsets) {
			self::updateUserUgSets($ugsets);
		}
	}

	/**
	 * @param array $userids
	 * @param array $db_users
	 *
	 * @throws APIException if the input is invalid.
	 */
	private function validateDelete(array &$userids, ?array &$db_users = null) {
		$api_input_rules = ['type' => API_IDS, 'flags' => API_NOT_EMPTY, 'uniq' => true];
		if (!CApiInputValidator::validate($api_input_rules, $userids, '/', $error)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, $error);
		}

		$db_users = $this->get([
			'output' => ['userid', 'username', 'roleid'],
			'selectRole' => ['type'],
			'selectUsrgrps' => ['usrgrpid'],
			'userids' => $userids,
			'editable' => true,
			'preservekeys' => true
		]);

		// Get readonly super admin role ID and name.
		$db_roles = DBfetchArray(DBselect(
			'SELECT roleid,name'.
			' FROM role'.
			' WHERE type='.USER_TYPE_SUPER_ADMIN.
				' AND readonly=1'
		));
		$readonly_superadmin_role = $db_roles[0];

		$superadminids_to_delete = [];

		foreach ($userids as $userid) {
			if (!array_key_exists($userid, $db_users)) {
				self::exception(ZBX_API_ERROR_PERMISSIONS,
					_('No permissions to referred object or it does not exist!')
				);
			}

			$db_user = $db_users[$userid];

			if (bccomp($userid, self::$userData['userid']) == 0) {
				self::exception(ZBX_API_ERROR_PARAMETERS, _('User is not allowed to delete oneself.'));
			}

			if ($db_user['username'] == ZBX_GUEST_USER) {
				self::exception(ZBX_API_ERROR_PARAMETERS,
					_s('Cannot delete Zabbix internal user "%1$s", try disabling that user.', ZBX_GUEST_USER)
				);
			}

			if ($db_user['roleid'] == $readonly_superadmin_role['roleid']) {
				$superadminids_to_delete[] = $userid;
			}
		}

		// Check that at least one user will remain with readonly super admin role.
		if ($superadminids_to_delete) {
			$db_superadmins = DBselect(
				'SELECT NULL'.
				' FROM users u'.
				' WHERE u.roleid='.$readonly_superadmin_role['roleid'].
					' AND '.dbConditionId('u.userid', $superadminids_to_delete, true).
					' AND EXISTS('.
						'SELECT NULL'.
						' FROM usrgrp g,users_groups ug'.
						' WHERE g.usrgrpid=ug.usrgrpid'.
							' AND ug.userid=u.userid'.
						' GROUP BY ug.userid'.
						' HAVING MAX(g.gui_access)<'.GROUP_GUI_ACCESS_DISABLED.
							' AND MAX(g.users_status)='.GROUP_STATUS_ENABLED.
					')'
			, 1);

			if (!DBfetch($db_superadmins)) {
				self::exception(ZBX_API_ERROR_PARAMETERS,
					_s('At least one active user must exist with role "%1$s".', $readonly_superadmin_role['name'])
				);
			}
		}

		// Check if deleted users used in actions.
		$db_actions = DBselect(
			'SELECT a.name,om.userid'.
			' FROM opmessage_usr om,operations o,actions a'.
			' WHERE om.operationid=o.operationid'.
				' AND o.actionid=a.actionid'.
				' AND '.dbConditionInt('om.userid', $userids),
			1
		);

		if ($db_action = DBfetch($db_actions)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, _s('User "%1$s" is used in "%2$s" action.',
				$db_users[$db_action['userid']]['username'], $db_action['name']
			));
		}

		// Check if deleted users have a map.
		$db_maps = API::Map()->get([
			'output' => ['name', 'userid'],
			'userids' => $userids,
			'limit' => 1
		]);

		if ($db_maps) {
			self::exception(ZBX_API_ERROR_PARAMETERS,
				_s('User "%1$s" is map "%2$s" owner.', $db_users[$db_maps[0]['userid']]['username'],
					$db_maps[0]['name']
				)
			);
		}

		// Check if deleted users have dashboards.
		$db_dashboards = API::Dashboard()->get([
			'output' => ['name', 'userid'],
			'filter' => ['userid' => $userids],
			'limit' => 1
		]);

		if ($db_dashboards) {
			self::exception(ZBX_API_ERROR_PARAMETERS,
				_s('User "%1$s" is dashboard "%2$s" owner.', $db_users[$db_dashboards[0]['userid']]['username'],
					$db_dashboards[0]['name']
				)
			);
		}

		// Check if deleted users used in scheduled reports.
		$db_reports = DBselect(
			'SELECT r.name,r.userid,ru.userid AS recipientid,ru.access_userid AS user_creatorid,'.
					'rug.access_userid AS usrgrp_creatorid'.
			' FROM report r'.
				' LEFT JOIN report_user ru ON r.reportid=ru.reportid'.
				' LEFT JOIN report_usrgrp rug ON r.reportid=rug.reportid'.
			' WHERE '.dbConditionInt('r.userid', $userids).
				' OR '.dbConditionInt('ru.userid', $userids).
				' OR '.dbConditionInt('ru.access_userid', $userids).
				' OR '.dbConditionInt('rug.access_userid', $userids),
			1
		);

		if ($db_report = DBfetch($db_reports)) {
			if (array_key_exists($db_report['userid'], $db_users)) {
				self::exception(ZBX_API_ERROR_PARAMETERS,
					_s('User "%1$s" is report "%2$s" owner.', $db_users[$db_report['userid']]['username'],
						$db_report['name']
					)
				);
			}

			if (array_key_exists($db_report['recipientid'], $db_users)) {
				self::exception(ZBX_API_ERROR_PARAMETERS,
					_s('User "%1$s" is report "%2$s" recipient.', $db_users[$db_report['recipientid']]['username'],
						$db_report['name']
					)
				);
			}

			if (array_key_exists($db_report['user_creatorid'], $db_users)
					|| array_key_exists($db_report['usrgrp_creatorid'], $db_users)) {
				$creator = array_key_exists($db_report['user_creatorid'], $db_users)
					? $db_users[$db_report['user_creatorid']]
					: $db_users[$db_report['usrgrp_creatorid']];

				self::exception(ZBX_API_ERROR_PARAMETERS,
					_s('User "%1$s" is user on whose behalf report "%2$s" is created.', $creator['username'],
						$db_report['name']
					)
				);
			}
		}
	}

	public static function updateFromUserGroup(array $users, array $del_user_usrgrpids): void {
		$db_users = DB::select('users', [
			'output' => ['userid', 'username', 'roleid'],
			'userids' => array_keys($users),
			'preservekeys' => true
		]);

		$db_roles = self::getDbRoles($users, $db_users);
		self::addRoleType($users, $db_roles, $db_users);

		self::addAffectedUserGroups($users, $db_users);
		self::addUnchangedUserGroups($users, $db_users, $del_user_usrgrpids);

		self::updateForce(array_values($users), $db_users);
	}

	private static function addUnchangedUserGroups(array &$users, array $db_users, array $del_user_usrgrpids): void {
		foreach ($users as &$user) {
			$usrgrpids = array_column($user['usrgrps'], 'usrgrpid');

			foreach ($db_users[$user['userid']]['usrgrps'] as $db_group) {
				if (!in_array($db_group['usrgrpid'], $usrgrpids)
						&& (!array_key_exists($user['userid'], $del_user_usrgrpids)
							|| !in_array($db_group['usrgrpid'], $del_user_usrgrpids[$user['userid']]))) {
					$user['usrgrps'][] = ['usrgrpid' => $db_group['usrgrpid']];
				}
			}
		}
		unset($user);
	}

	public static function updateFromRole(array $users, array $db_users): void {
		self::addAffectedUserGroups($users, $db_users);

		self::updateForce($users, $db_users);
	}

	public function logout($user) {
		$api_input_rules = ['type' => API_OBJECT, 'fields' => []];
		if (!CApiInputValidator::validate($api_input_rules, $user, '/', $error)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, $error);
		}

		$sessionid = self::$userData['sessionid'];

		$db_sessions = DB::select('sessions', [
			'output' => ['userid'],
			'filter' => [
				'sessionid' => $sessionid,
				'status' => ZBX_SESSION_ACTIVE
			],
			'limit' => 1
		]);

		if (!$db_sessions) {
			self::exception(ZBX_API_ERROR_PARAMETERS, _('Cannot log out.'));
		}

		if (CAuthenticationHelper::isLdapProvisionEnabled(self::$userData['userdirectoryid'])) {
			$this->provisionLdapUser(self::$userData);
		}

		DB::delete('sessions', [
			'status' => ZBX_SESSION_PASSIVE,
			'userid' => $db_sessions[0]['userid']
		]);
		DB::update('sessions', [
			'values' => ['status' => ZBX_SESSION_PASSIVE],
			'where' => ['sessionid' => $sessionid]
		]);

		self::addAuditLog(CAudit::ACTION_LOGOUT, CAudit::RESOURCE_USER);

		self::$userData = null;

		return true;
	}

	/**
	 * @param array $data
	 *
	 * @return string|array
	 */
	public function login(array $data) {
		self::$user_verification_start_time = hrtime();

		$api_input_rules = ['type' => API_OBJECT, 'fields' => [
			'username' =>	['type' => API_STRING_UTF8, 'flags' => API_REQUIRED, 'length' => 255],
			'password' =>	['type' => API_STRING_UTF8, 'flags' => API_REQUIRED, 'length' => 255],
			'userData' =>	['type' => API_FLAG]
		]];

		if (!CApiInputValidator::validate($api_input_rules, $data, '/', $error)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, $error);
		}

		$db_users = self::findLoginUsersByUsername($data['username']);

		$created = !$db_users && $data['username'] !== ZBX_GUEST_USER
			? $this->tryToCreateLdapProvisionedUser($data, $db_users)
			: false;

		try {
			self::checkSingleUserExists($data['username'], $db_users);

			$db_user = $db_users[0];

			if (!$created && $db_user['userdirectoryid'] != 0) {
				self::checkUserProvisionedByLdap($db_user);
			}

			self::addUserGroupFields($db_user, $group_status, $group_auth_type, $group_userdirectoryid);

			$db_user['auth_type'] = $db_user['userdirectoryid'] == 0 ? $group_auth_type : ZBX_AUTH_LDAP;

			if (!$created) {
				self::checkLoginTemporarilyBlocked($db_user);

				if ($db_user['auth_type'] == ZBX_AUTH_LDAP) {
					self::checkLdapAuthenticationEnabled($db_user);

					$idp_user_data = self::verifyLdapCredentials($data, $db_user, $group_userdirectoryid);
				}
				else {
					self::verifyPassword($data, $db_user);
				}
			}
		}
		catch (APIException $e) {
			self::equalizeUserVerificationTime();

			throw $e;
		}

		if (!$created && $db_user['auth_type'] == ZBX_AUTH_LDAP) {
			$this->tryToUpdateLdapProvisionedUser($db_user, $group_status, $idp_user_data);
		}

		self::checkGroupStatus($db_user, $group_status);
		self::checkRole($db_user);

		self::addAdditionalFields($db_user);
		self::setTimezone($db_user['timezone']);
		self::createSession($db_user, $db_user['mfaid'] == 0 ? ZBX_SESSION_ACTIVE : ZBX_SESSION_CONFIRMATION_REQUIRED);
		unset($db_user['ugsetid']);

		if ($db_user['attempt_failed'] != 0 && $db_user['mfaid'] == 0) {
			self::resetFailedLoginAttempts($db_user);
		}

		if ($db_user['mfaid'] == 0) {
			self::addAuditLog(CAudit::ACTION_LOGIN_SUCCESS, CAudit::RESOURCE_USER);
		}

		return array_key_exists('userData', $data) && $data['userData'] ? $db_user : $db_user['sessionid'];
	}

	public static function loginByUsername(string $username, bool $case_sensitive): array {
		$db_users = self::findUsersByUsername($username, $case_sensitive);

		self::checkSingleUserExists($username, $db_users);

		$db_user = $db_users[0];

		self::addUserGroupFields($db_user, $group_status, $group_auth_type);

		self::checkGroupStatus($db_user, $group_status);
		self::checkRole($db_user);

		$db_user['auth_type'] =
			$db_user['userdirectoryid'] == 0 || !self::isLdapUserDirectory($db_user['userdirectoryid'])
				? $group_auth_type
				: ZBX_AUTH_LDAP;

		self::addAdditionalFields($db_user);
		self::setTimezone($db_user['timezone']);
		self::createSession($db_user, ZBX_SESSION_ACTIVE);
		unset($db_user['ugsetid']);

		self::addAuditLog(CAudit::ACTION_LOGIN_SUCCESS, CAudit::RESOURCE_USER);

		return $db_user;
	}

	private static function findLoginUsersByUsername(string $username): array {
		$case_sensitive =
			CAuthenticationHelper::get(CAuthenticationHelper::LDAP_CASE_SENSITIVE) == ZBX_AUTH_CASE_SENSITIVE;

		$db_users = self::findUsersByUsername($username, $case_sensitive);

		if ($db_users && !$case_sensitive) {
			self::unsetCaseInsensitiveUsersOfInternalAuthType($db_users, $username);
		}

		return $db_users;
	}

	public static function findUsersByUsername(string $username, bool $case_sensitive = true): array {
		$db_users = [];

		$fields = ['userid', 'username', 'name', 'surname', 'url', 'autologin', 'autologout', 'lang', 'refresh',
			'theme', 'attempt_failed', 'attempt_ip', 'attempt_clock', 'rows_per_page', 'timezone', 'roleid',
			'userdirectoryid', 'ts_provisioned'
		];

		if ($case_sensitive) {
			$db_users = DB::select('users', [
				'output' => $fields,
				'filter' => ['username' => $username]
			]);
		}
		else {
			$db_users = DBfetchArray(DBselect(
				'SELECT '.implode(',', $fields).
				' FROM users'.
				' WHERE LOWER(username)='.zbx_dbstr(mb_strtolower($username))
			));
		}

		return $db_users;
	}

	/**
	 * Leave only the user with ZBX_AUTH_INTERNAL authentication type, whose username strictly matches the given
	 * username, in the given users array.
	 * The given users array is cleared if users are only of ZBX_AUTH_INTERNAL authentication type, but none of them
	 * matched by the given username.
	 *
	 * Otherwise the users array will remain unchanged for further LDAP case insensitive authentication attempt.
	 *
	 * @param array  $db_users
	 * @param string $username
	 */
	private static function unsetCaseInsensitiveUsersOfInternalAuthType(array &$db_users, string $username): void {
		$gui_accesses = array_column(DBfetchArray(DBselect(
			'SELECT ug.userid,MAX(g.gui_access) AS gui_access'.
			' FROM users_groups ug,usrgrp g'.
			' WHERE ug.usrgrpid=g.usrgrpid'.
				' AND '.dbConditionId('ug.userid', array_column($db_users, 'userid')).
			' GROUP BY ug.userid'
		)), 'gui_access', 'userid');

		$auth_types = [];

		foreach ($db_users as $i => $db_user) {
			$gui_access = array_key_exists($db_user['userid'], $gui_accesses)
				? $gui_accesses[$db_user['userid']]
				: GROUP_GUI_ACCESS_SYSTEM;

			$auth_type = self::getAuthTypeByGuiAccess($gui_access);

			if ($auth_type == ZBX_AUTH_INTERNAL && $db_user['username'] === $username) {
				$db_users = array_intersect_key($db_users, array_flip([$i]));
				$auth_types = [];
				break;
			}

			$auth_types[$auth_type] = true;
		}

		if (count($auth_types) == 1 && array_key_exists(ZBX_AUTH_INTERNAL, $auth_types)) {
			$db_users = [];
		}

		$db_users = array_values($db_users);
	}

	/**
	 * Get the actual authentication type for the given GUI access value.
	 * The authentication type for GROUP_GUI_ACCESS_DISABLED should be the same as for GROUP_GUI_ACCESS_SYSTEM, because
	 * even if frontend access is disabled, this shouldn't disable the login into API.
	 *
	 * @param int $gui_access
	 *
	 * @return int
	 */
	private static function getAuthTypeByGuiAccess(int $gui_access): int {
		if ($gui_access == GROUP_GUI_ACCESS_INTERNAL) {
			return ZBX_AUTH_INTERNAL;
		}
		elseif ($gui_access == GROUP_GUI_ACCESS_LDAP) {
			return ZBX_AUTH_LDAP;
		}

		return CAuthenticationHelper::getPublic(CAuthenticationHelper::AUTHENTICATION_TYPE);
	}

	private function tryToCreateLdapProvisionedUser(array $data, array &$db_users): bool {
		$ldap_userdirectoryid = CAuthenticationHelper::get(CAuthenticationHelper::LDAP_USERDIRECTORYID);

		if (CAuthenticationHelper::get(CAuthenticationHelper::AUTHENTICATION_TYPE) == ZBX_AUTH_LDAP
				&& CAuthenticationHelper::isLdapProvisionEnabled($ldap_userdirectoryid)) {
			$idp_user_data = API::UserDirectory()->test([
				'userdirectoryid' => $ldap_userdirectoryid,
				'test_username' => $data['username'],
				'test_password' => $data['password']
			]);

			if ($idp_user_data['usrgrps']) {
				$user = $this->createProvisionedUser($idp_user_data);
				$db_users = self::findUsersByUsername($user['username']);

				return true;
			}
		}

		return false;
	}

	private static function checkSingleUserExists(string $username, array $db_users): void {
		if (!$db_users) {
			self::loginException(null, $username, ZBX_API_ERROR_PERMISSIONS,
				_('Incorrect user name or password or account is temporarily blocked.')
			);
		}

		if (count($db_users) > 1) {
			self::loginException(null, $username, ZBX_API_ERROR_PERMISSIONS,
				_s('Authentication failed: %1$s.', _('supplied credentials are not unique'))
			);
		}
	}

	private static function checkUserProvisionedByLdap(array $db_user) {
		if (!self::isLdapUserDirectory($db_user['userdirectoryid'])) {
			self::loginException($db_user['userid'], $db_user['username'], ZBX_API_ERROR_PERMISSIONS,
				_('Incorrect user name or password or account is temporarily blocked.')
			);
		}
	}

	private static function isLdapUserDirectory(string $userdirectoryid): bool {
		return (bool) DB::select('userdirectory', [
			'output' => [],
			'userdirectoryids' => $userdirectoryid,
			'filter' => ['idp_type' => ZBX_AUTH_LDAP]
		]);
	}

	/**
	 * Add user group data fields to the given user and populates the given $group_status and $group_userdirectoryid.
	 * Note: user without groups is able to log in with default user group field values.
	 */
	public static function addUserGroupFields(array &$db_user, ?int &$group_status = null,
			?int &$group_auth_type = null, ?string &$group_userdirectoryid = null): void {
		$db_user['debug_mode'] = GROUP_DEBUG_MODE_DISABLED;
		$db_user['deprovisioned'] = false;
		$db_user['gui_access'] = GROUP_GUI_ACCESS_SYSTEM;
		$db_user['mfaid'] = 0;

		$group_auth_type = self::getAuthTypeByGuiAccess($db_user['gui_access']);
		$group_status = GROUP_STATUS_ENABLED;
		$group_userdirectoryid = 0;

		$result = DBselect(
			'SELECT g.usrgrpid,g.debug_mode,g.users_status,g.gui_access,g.userdirectoryid,g.mfa_status,g.mfaid'.
			' FROM users_groups ug,usrgrp g'.
			' WHERE ug.usrgrpid=g.usrgrpid'.
				' AND '.dbConditionId('ug.userid', [$db_user['userid']])
		);

		$deprovision_groupid = CAuthenticationHelper::getPublic(CAuthenticationHelper::DISABLED_USER_GROUPID);
		$mfa_status = CAuthenticationHelper::getPublic(CAuthenticationHelper::MFA_STATUS);
		$default_mfaid = CAuthenticationHelper::getPublic(CAuthenticationHelper::MFAID);

		$userdirectoryids = [];
		$mfaids = [];

		while ($row = DBfetch($result)) {
			if ($row['debug_mode'] == GROUP_DEBUG_MODE_ENABLED) {
				$db_user['debug_mode'] = $row['debug_mode'];
			}

			if (bccomp($row['usrgrpid'], $deprovision_groupid) == 0 && $db_user['userdirectoryid'] != 0) {
				$db_user['deprovisioned'] = true;
			}

			if ($row['gui_access'] > $db_user['gui_access']) {
				$db_user['gui_access'] = $row['gui_access'];
				$group_auth_type = self::getAuthTypeByGuiAccess($row['gui_access']);
			}

			if ($row['users_status'] == GROUP_STATUS_DISABLED) {
				$group_status = $row['users_status'];
			}

			if ($group_auth_type == ZBX_AUTH_LDAP) {
				$userdirectoryids[$row['userdirectoryid']] = true;
			}

			if ($mfa_status == MFA_ENABLED && $row['mfa_status'] == GROUP_MFA_ENABLED) {
				if ($row['mfaid'] == 0) {
					$db_user['mfaid'] = $default_mfaid;
				}
				else {
					$mfaids[$row['mfaid']] = true;
				}
			}
		}

		if ($group_auth_type == ZBX_AUTH_LDAP) {
			if (array_key_exists(0, $userdirectoryids)) {
				unset($userdirectoryids[0]);

				if (CAuthenticationHelper::getPublic(CAuthenticationHelper::LDAP_USERDIRECTORYID) != 0) {
					$userdirectoryids[CAuthenticationHelper::getPublic(CAuthenticationHelper::LDAP_USERDIRECTORYID)] =
						true;
				}
			}

			if (count($userdirectoryids) > 1) {
				$db_userdirectories = DB::select('userdirectory', [
					'output' => [],
					'userdirectoryids' => array_keys($userdirectoryids),
					'sortfield' => ['name'],
					'limit' => 1,
					'preservekeys' => true
				]);

				$group_userdirectoryid = key($db_userdirectories);
			}
			elseif ($userdirectoryids) {
				$group_userdirectoryid = key($userdirectoryids);
			}
		}

		/*
		 * The default MFA has the highest priority.
		 * If user's groups only have exact MFA IDs, we select first of them by name.
		 */
		if ($mfa_status == MFA_ENABLED && $db_user['mfaid'] == 0 && $mfaids) {
			$db_mfas = DB::select('mfa', [
				'output' => [],
				'mfaids' => array_keys($mfaids),
				'sortfield' => ['name'],
				'limit' => 1,
				'preservekeys' => true
			]);

			$db_user['mfaid'] = key($db_mfas);
		}
	}

	private static function checkLoginTemporarilyBlocked(array $db_user): void {
		if ($db_user['attempt_failed'] < CSettingsHelper::getPublic(CSettingsHelper::LOGIN_ATTEMPTS)) {
			return;
		}

		$blocked_duration = time() - $db_user['attempt_clock'];

		if ($blocked_duration < timeUnitToSeconds(CSettingsHelper::getPublic(CSettingsHelper::LOGIN_BLOCK))) {
			self::loginException($db_user['userid'], $db_user['username'], ZBX_API_ERROR_PERMISSIONS,
				_('Incorrect user name or password or account is temporarily blocked.')
			);
		}
	}

	private static function checkLdapAuthenticationEnabled(array $db_user): void {
		if (CAuthenticationHelper::get(CAuthenticationHelper::LDAP_AUTH_ENABLED) == ZBX_AUTH_LDAP_DISABLED) {
			self::loginException($db_user['userid'], $db_user['username'], ZBX_API_ERROR_PERMISSIONS,
				_('Incorrect user name or password or account is temporarily blocked.')
			);
		}
	}

	private static function verifyLdapCredentials(array $data, array $db_user, string $group_userdirectoryid): array {
		try {
			return API::UserDirectory()->test([
				'userdirectoryid' => $db_user['userdirectoryid'] != 0
					? $db_user['userdirectoryid']
					: $group_userdirectoryid,
				'test_username' => $data['username'],
				'test_password' => $data['password']
			]);
		}
		catch (APIException $e) {
			if ($e->getCode() == ZBX_API_ERROR_PERMISSIONS) {
				self::increaseFailedLoginAttempts($db_user);

				self::loginException($db_user['userid'], $db_user['username'], ZBX_API_ERROR_PERMISSIONS,
					_('Incorrect user name or password or account is temporarily blocked.')
				);
			}

			self::exception(ZBX_API_ERROR_PERMISSIONS,
				_('Incorrect user name or password or account is temporarily blocked.')
			);
		}
	}

	private function tryToUpdateLdapProvisionedUser(array &$db_user, int &$group_status, array $idp_user_data): void {
		if (CAuthenticationHelper::isLdapProvisionEnabled($db_user['userdirectoryid'])) {
			$idp_user_data['userid'] = $db_user['userid'];

			if ($this->updateProvisionedUser($idp_user_data)) {
				$db_user = self::findUsersByUsername($db_user['username'])[0];
			}

			self::addUserGroupFields($db_user, $group_status);
			$db_user['auth_type'] = ZBX_AUTH_LDAP;
		}
	}

	private static function verifyPassword(array $data, array &$db_user): void {
		$options = [
			'output' => ['passwd'],
			'userids' => $db_user['userid']
		];
		[$db_passwd] = DBfetchColumn(DBselect(DB::makeSql('users', $options)), 'passwd');

		if (!password_verify($data['password'], $db_passwd)) {
			self::increaseFailedLoginAttempts($db_user);

			self::loginException($db_user['userid'], $db_user['username'], ZBX_API_ERROR_PERMISSIONS,
				_('Incorrect user name or password or account is temporarily blocked.')
			);
		}
	}

	private static function increaseFailedLoginAttempts(array &$db_user): void {
		$attempt_failed = $db_user['attempt_failed'] + 1;

		$upd_user = [
			'attempt_failed' => $attempt_failed,
			'attempt_clock' => time(),
			'attempt_ip' => substr(CWebUser::getIp(), 0, 39)
		];

		DB::update('users', [
			'values' => $upd_user,
			'where' => ['userid' => $db_user['userid']]
		]);

		$users = [['userid' => $db_user['userid'], 'attempt_failed' => $attempt_failed]];
		$db_users = [$db_user['userid'] => $db_user];

		self::addAuditLogByUser($db_user['userid'], CWebUser::getIp(), $db_user['username'],
			CAudit::ACTION_UPDATE, CAudit::RESOURCE_USER, $users, $db_users
		);

		$db_user = $upd_user + $db_user;
	}

	private static function checkGroupStatus(array $db_user, int $group_status): void {
		if ($group_status == GROUP_STATUS_DISABLED) {
			self::loginException($db_user['userid'], $db_user['username'], ZBX_API_ERROR_PARAMETERS,
				_('No permissions for system access.')
			);
		}
	}

	private static function checkRole(array $db_user): void {
		if ($db_user['roleid'] == 0) {
			self::loginException($db_user['userid'], $db_user['username'], ZBX_API_ERROR_PARAMETERS,
				_('No permissions for system access.')
			);
		}
	}

	/**
	 * Equalizes user verification time to mitigate timing attacks.
	 */
	private static function equalizeUserVerificationTime(): void {
		[$start_sec, $start_nsec] = self::$user_verification_start_time;
		[$end_sec, $end_nsec] = hrtime();

		$actual_time_sec = $end_sec - $start_sec;
		$actual_time_nsec = $end_nsec - $start_nsec;

		if ($actual_time_nsec < 0) {
			$actual_time_sec -= 1;
			$actual_time_nsec += 10**9;
		}

		[$acceptable_time_sec, $acceptable_time_nsec] = self::ACCEPTABLE_USER_VERIFICATION_TIME;

		if ($actual_time_sec < $acceptable_time_sec
				|| ($actual_time_sec == $acceptable_time_sec && $actual_time_nsec < $acceptable_time_nsec)) {
			$delay_time_sec = $acceptable_time_sec - $actual_time_sec;
			$delay_time_nsec = $acceptable_time_nsec - $actual_time_nsec;

			if ($delay_time_nsec < 0) {
				$delay_time_sec -= 1;
				$delay_time_nsec += 10**9;
			}

			time_nanosleep($delay_time_sec, $delay_time_nsec);
		}
	}

	private static function loginException(?string $userid, string $username, int $code, string $error): void {
		self::addAuditLogByUser($userid, CWebUser::getIp(), $username, CAudit::ACTION_LOGIN_FAILED,
			CAudit::RESOURCE_USER
		);

		self::exception($code, $error);
	}

	private static function addAdditionalFields(array &$db_user): void {
		$db_user['type'] = self::getUserType($db_user['roleid']);
		$db_user['ugsetid'] = self::getUgSetId($db_user);
		$db_user['userip'] = CWebUser::getIp();

		if ($db_user['lang'] === LANG_DEFAULT) {
			$db_user['lang'] = CSettingsHelper::getPublic(CSettingsHelper::DEFAULT_LANG);
		}

		if ($db_user['timezone'] === TIMEZONE_DEFAULT) {
			$db_user['timezone'] = CSettingsHelper::getPublic(CSettingsHelper::DEFAULT_TIMEZONE);
		}
	}

	private static function resetFailedLoginAttempts(array $db_user): void {
		$upd_user = ['attempt_failed' => 0];

		DB::update('users', [
			'values' => $upd_user,
			'where' => ['userid' => $db_user['userid']]
		]);

		$users = [$upd_user + ['userid' => $db_user['userid']]];
		$db_users = [$db_user['userid'] => $db_user];

		self::addAuditLog(CAudit::ACTION_UPDATE, CAudit::RESOURCE_USER, $users, $db_users);
	}


	private static function setTimezone(?string $timezone): void {
		if ($timezone !== null && $timezone !== ZBX_DEFAULT_TIMEZONE) {
			date_default_timezone_set($timezone);
		}
	}

	private static function createSession(array &$db_user, int $session_status): void {
		$db_user['sessionid'] = CEncryptHelper::generateKey();
		$db_user['secret'] = CEncryptHelper::generateKey();

		DB::insert('sessions', [[
			'sessionid' => $db_user['sessionid'],
			'userid' => $db_user['userid'],
			'lastaccess' => time(),
			'status' => $session_status,
			'secret' => $db_user['secret']
		]], false);

		self::$userData = $db_user;
	}

	/**
	 * Checks if user is authenticated by session ID or by API token.
	 *
	 * @param array  $session
	 * @param string $session[]['sessionid']  Session ID to be checked.
	 * @param string $session[]['token']      API token to be checked.
	 * @param bool   $session[]['extend']     Optional. Used with 'sessionid' to extend the user session which updates
	 *                                        'lastaccess' time.
	 *
	 * @throws APIException
	 *
	 * @return array
	 */
	public function checkAuthentication(array $session): array {
		$api_input_rules = ['type' => API_OBJECT, 'fields' => [
			'sessionid' => ['type' => API_STRING_UTF8],
			'extend' => ['type' => API_MULTIPLE, 'rules' => [
				['if' => function (array $data): bool {
					return !array_key_exists('token', $data);
				}, 'type' => API_BOOLEAN, 'default' => true],
				['else' => true, 'type' => API_UNEXPECTED]
			]],
			'token' => ['type' => API_STRING_UTF8]
		]];

		if (!CApiInputValidator::validate($api_input_rules, $session, '/', $error)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, $error);
		}

		$session += ['sessionid' => null, 'token' => null];

		if (($session['sessionid'] === null && $session['token'] === null)
				|| ($session['sessionid'] !== null && $session['token'] !== null)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, _('Session ID or token is expected.'));
		}

		$time = time();

		$auth_method = $session['sessionid'] !== null ? 'sessionid' : 'token';

		// Access DB only once per page load.
		if (self::$userData !== null && array_key_exists($auth_method, self::$userData)
				&& self::$userData[$auth_method] === $session[$auth_method]) {
			return array_diff_key(self::$userData, array_flip(['token']));
		}

		if ($session['sessionid'] !== null) {
			$db_session = self::sessionidAuthentication($session['sessionid']);
			$userid = $db_session['userid'];
		}
		else {
			$db_token = self::tokenAuthentication($session['token'], $time);
			$userid = $db_token['userid'];
		}

		$fields = ['userid', 'username', 'name', 'surname', 'url', 'autologin', 'autologout', 'lang', 'refresh',
			'theme', 'attempt_failed', 'attempt_ip', 'attempt_clock', 'rows_per_page', 'timezone', 'roleid',
			'userdirectoryid', 'ts_provisioned'
		];

		[$db_user] = DB::select('users', ['output' => $fields, 'userids' => $userid]);

		self::addUserGroupFields($db_user, $group_status, $group_auth_type);

		if (!$db_user['deprovisioned'] && CAuthenticationHelper::isTimeToProvision($db_user['ts_provisioned'])
				&& CAuthenticationHelper::isLdapProvisionEnabled($db_user['userdirectoryid'])
				&& !$this->provisionLdapUser($db_user)) {
			[$db_user] = DB::select('users', ['output' => $fields, 'userids' => $userid]);

			self::addUserGroupFields($db_user, $group_status, $group_auth_type);
		}

		$db_user['auth_type'] =
			$db_user['userdirectoryid'] == 0 || !self::isLdapUserDirectory($db_user['userdirectoryid'])
				? $group_auth_type
				: ZBX_AUTH_LDAP;

		self::addAdditionalFields($db_user);
		self::setTimezone($db_user['timezone']);

		if ($session['sessionid'] !== null) {
			$autologout = timeUnitToSeconds($db_user['autologout']);

			if (($autologout != 0 && $db_session['lastaccess'] + $autologout <= $time)
					|| $group_status == GROUP_STATUS_DISABLED) {
				DB::delete('sessions', [
					'status' => ZBX_SESSION_PASSIVE,
					'userid' => $db_user['userid']
				]);
				DB::update('sessions', [
					'values' => ['status' => ZBX_SESSION_PASSIVE],
					'where' => ['sessionid' => $session['sessionid']]
				]);

				self::exception(ZBX_API_ERROR_PARAMETERS, _('Session terminated, re-login, please.'));
			}

			if ($session['extend'] && $time != $db_session['lastaccess']) {
				DB::update('sessions', [
					'values' => ['lastaccess' => $time],
					'where' => ['sessionid' => $session['sessionid']]
				]);
			}

			self::$userData = $db_user + ['sessionid' => $session['sessionid']];

			$db_user['sessionid'] = $session['sessionid'];
			$db_user['secret'] = $db_session['secret'];
		}
		else {
			if ($group_status == GROUP_STATUS_DISABLED) {
				self::exception(ZBX_API_ERROR_NO_AUTH, _('Not authorized.'));
			}

			DB::update('token', [
				'values' => ['lastaccess' => $time],
				'where' => ['tokenid' => $db_token['tokenid']]
			]);

			self::$userData = $db_user + ['token' => $session['token']];

		}

		unset($db_user['ugsetid']);

		return $db_user;
	}

	/**
	 * Authenticates user based on API token.
	 *
	 * @param string $auth_token API token.
	 * @param int    $time       Current time unix timestamp.
	 *
	 * @throws APIException
	 *
	 * @return array
	 */
	private static function tokenAuthentication(string $auth_token, int $time): array {
		$db_tokens = DB::select('token', [
			'output' => ['userid', 'expires_at', 'tokenid'],
			'filter' => ['token' => hash('sha512', $auth_token), 'status' => ZBX_AUTH_TOKEN_ENABLED]
		]);

		if (!$db_tokens) {
			usleep(10000);
			self::exception(ZBX_API_ERROR_NO_AUTH, _('Not authorized.'));
		}

		$db_token = $db_tokens[0];

		if ($db_token['expires_at'] != 0 && $db_token['expires_at'] < $time) {
			self::exception(ZBX_API_ERROR_PERMISSIONS, _('API token expired.'));
		}

		return $db_token;
	}

	/**
	 * Authenticates user based on session ID.
	 *
	 * @param string $sessionid Session ID.
	 *
	 * @throws APIException
	 *
	 * @return array
	 */
	private static function sessionidAuthentication(string $sessionid): array {
		$db_sessions = DB::select('sessions', [
			'output' => ['userid', 'lastaccess', 'secret'],
			'sessionids' => $sessionid,
			'filter' => ['status' => ZBX_SESSION_ACTIVE]
		]);

		if (!$db_sessions) {
			self::exception(ZBX_API_ERROR_PARAMETERS, _('Session terminated, re-login, please.'));
		}

		return $db_sessions[0];
	}

	/**
	 * Unblock user account.
	 *
	 * @param array $userids
	 *
	 * @throws APIException
	 *
	 * @return array
	 */
	public function unblock(array $userids): array {
		$api_input_rules = ['type' => API_IDS, 'flags' => API_NOT_EMPTY, 'uniq' => true];
		if (!CApiInputValidator::validate($api_input_rules, $userids, '/', $error)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, $error);
		}

		$db_users = $this->get([
			'output' => ['userid', 'username', 'attempt_failed'],
			'userids' => $userids,
			'editable' => true,
			'preservekeys' => true
		]);

		if (count($db_users) != count($userids)) {
			self::exception(ZBX_API_ERROR_PERMISSIONS, _('No permissions to referred object or it does not exist!'));
		}

		$users = [];
		$upd_users = [];

		foreach ($userids as $userid) {
			$upd_user = DB::getUpdatedValues('users', ['userid' => $userid, 'attempt_failed' => 0], $db_users[$userid]);

			if ($upd_user) {
				$upd_users[] = [
					'values' => $upd_user,
					'where' => ['userid' => $userid]
				];

				$users[] = $upd_user + ['userid' => $userid];
			}
			else {
				unset($db_users[$userid]);
			}
		}

		if ($upd_users) {
			DB::update('users', $upd_users);
			self::addAuditLog(CAudit::ACTION_UPDATE, CAudit::RESOURCE_USER, $users, $db_users);
		}

		return ['userids' => $userids];
	}


	/**
	 * Provision users. Only users with IDP_TYPE_LDAP userdirectory will be provisioned.
	 *
	 * @param array $userids
	 */
	public function provision(array $userids): array {
		$api_input_rules = ['type' => API_IDS, 'flags' => API_NOT_EMPTY, 'uniq' => true];
		if (!CApiInputValidator::validate($api_input_rules, $userids, '/', $error)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, $error);
		}

		$db_users = API::User()->get([
			'output' => ['userid', 'username', 'userdirectoryid'],
			'userids' => $userids,
			'preservekeys' => true
		]);
		$userdirectoryids = array_column($db_users, 'userdirectoryid', 'userdirectoryid');
		unset($userdirectoryids[0]);
		$provisionedids = [];
		$db_userdirectoryids = [];

		if ($userdirectoryids) {
			$db_userdirectoryids = array_column(API::UserDirectory()->get([
				'output' => ['userdirectoryid', 'idp_type'],
				'userdirectoryids' => $userdirectoryids,
				'filter' => ['provision_status' => JIT_PROVISIONING_ENABLED]
			]), 'idp_type', 'userdirectoryid');

			if (array_diff_key($userdirectoryids, $db_userdirectoryids)) {
				self::exception(ZBX_API_ERROR_PERMISSIONS,
					_('No permissions to referred object or it does not exist!')
				);
			}
		}

		if ($db_userdirectoryids) {
			$db_user_userdirectory = array_column($db_users, 'userdirectoryid', 'userid');

			foreach (array_keys($db_userdirectoryids, IDP_TYPE_LDAP) as $db_userdirectoryid) {
				$provisioning = CProvisioning::forUserDirectoryId($db_userdirectoryid);
				$provision_users = array_keys($db_user_userdirectory, $db_userdirectoryid);
				$provision_users = array_intersect_key($db_users, array_flip($provision_users));
				$config = $provisioning->getIdpConfig();
				$ldap = new CLdap($config);

				if ($ldap->bind_type == CLdap::BIND_DNSTRING) {
					continue;
				}

				foreach ($provision_users as $provision_user) {
					$user = array_merge(
						$provision_user,
						$ldap->getProvisionedData($provisioning, $provision_user['username'])
					);
					$this->updateProvisionedUser($user);
					$provisionedids[] = $provision_user['userid'];
				}
			}
		}

		return ['userids' => $provisionedids];
	}

	/**
	 * Create user in database from provision data.
	 * User media are sanitized removing media with malformed or empty 'sendto'.
	 *
	 * @param array  $idp_user_data
	 * @param string $idp_user_data['username']
	 * @param string $idp_user_data['name']
	 * @param string $idp_user_data['surname']
	 * @param int    $idp_user_data['roleid']
	 * @param array  $idp_user_data['media']                   Required to be set, can be empty array.
	 * @param int    $idp_user_data['media'][]['mediatypeid']
	 * @param array  $idp_user_data['media'][]['sendto']
	 * @param string $idp_user_data['media'][]['sendto'][]
	 * @param array  $idp_user_data['usrgrps']                 Required to be set, can be empty array.
	 * @param int    $idp_user_data['usrgrps'][]['usrgrpid']
	 * @param int    $idp_user_data['userdirectoryid']
	 *
	 * @return array of created user data in database.
	 */
	public function createProvisionedUser(array $idp_user_data): array {
		$attrs = array_flip(array_merge(self::PROVISIONED_FIELDS, ['userdirectoryid']));
		unset($attrs['passwd']);
		$user = array_intersect_key($idp_user_data, $attrs);
		$user['medias'] = $this->sanitizeUserMedia($user['medias']);

		$api_input_rules = ['type' => API_OBJECT, 'flags' => API_ALLOW_UNEXPECTED, 'fields' => [
			'username' =>	['type' => API_STRING_UTF8, 'flags' => API_REQUIRED | API_NOT_EMPTY, 'length' => DB::getFieldLength('users', 'username')],
			'name' =>		['type' => API_STRING_UTF8, 'length' => DB::getFieldLength('users', 'name')],
			'surname' =>	['type' => API_STRING_UTF8, 'length' => DB::getFieldLength('users', 'surname')]
		]];

		if (!CApiInputValidator::validate($api_input_rules, $user, '/', $error)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, $error);
		}

		$users = [$user];

		$db_roles = self::getDbRoles($users);
		self::addRoleType($users, $db_roles);

		$users[0]['ts_provisioned'] = time();
		self::createForce($users);

		return reset($users);
	}

	/**
	 * Update provisioned user in database. Return empty array when user is deprovisioned.
	 *
	 * @param int   $db_userid
	 * @param array $idp_user_data
	 * @param array $idp_user_data['userid']
	 * @param array $idp_user_data['usrgrps']  (optional) Array of user matched groups.
	 * @param array $idp_user_data['medias']   (optional) Array of user matched medias.
	 *
	 * @return array
	 */
	public function updateProvisionedUser(array $idp_user_data): array {
		$attrs = array_flip(array_merge(
			array_diff(self::PROVISIONED_FIELDS, ['username', 'passwd']), ['userdirectoryid', 'userid']
		));
		$user = array_intersect_key($idp_user_data, $attrs);

		$api_input_rules = ['type' => API_OBJECT, 'flags' => API_ALLOW_UNEXPECTED, 'fields' => [
			'username' =>	['type' => API_STRING_UTF8, 'flags' => API_NOT_EMPTY, 'length' => DB::getFieldLength('users', 'username')],
			'name' =>		['type' => API_STRING_UTF8, 'length' => DB::getFieldLength('users', 'name')],
			'surname' =>	['type' => API_STRING_UTF8, 'length' => DB::getFieldLength('users', 'surname')]
		]];

		if (!CApiInputValidator::validate($api_input_rules, $user, '/', $error)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, $error);
		}

		$userid = $user['userid'];
		$db_users = DB::select('users', [
			'output' => ['userid', 'username', 'name', 'surname', 'roleid', 'userdirectoryid', 'ts_provisioned'],
			'userids' => [$userid],
			'preservekeys' => true
		]);
		$user['ts_provisioned'] = time();
		$users = [$userid => $user];
		$user += ['username' => $db_users[$userid]['username']];

		$db_roles = self::getDbRoles($users, $db_users);
		self::addRoleType($users, $db_roles, $db_users);

		if (array_key_exists('medias', $user)) {
			$idp_medias = $this->sanitizeUserMedia($user['medias']);
			$idp_medias = array_column($idp_medias, null, 'userdirectory_mediaid');
			$db_users[$userid]['medias'] = DB::select('media', [
				'output' => ['mediatypeid', 'mediaid', 'sendto', 'userdirectory_mediaid'],
				'filter' => ['userid' => $userid],
				'preservekeys' => true
			]);
			$users[$userid]['medias'] = [];

			foreach ($db_users[$userid]['medias'] as $db_media) {
				if ($db_media['userdirectory_mediaid'] == 0) {
					$users[$userid]['medias'][] = ['mediaid' => $db_media['mediaid']];
				}
				else if (array_key_exists($db_media['userdirectory_mediaid'], $idp_medias)) {
					$users[$userid]['medias'][] = [
						'mediatypeid' => $idp_medias[$db_media['userdirectory_mediaid']]['mediatypeid'],
						'sendto' => $idp_medias[$db_media['userdirectory_mediaid']]['sendto']
					] + $db_media;

					unset($idp_medias[$db_media['userdirectory_mediaid']]);
				}
			}

			$users[$userid]['medias'] = array_merge($users[$userid]['medias'], $idp_medias);
		}

		if (array_key_exists('usrgrps', $user)) {
			$db_users[$userid]['usrgrps'] = DB::select('users_groups', [
				'output' => ['usrgrpid', 'id'],
				'filter' => ['userid' => $userid]
			]);

			if (!$user['usrgrps']) {
				$users[$userid]['usrgrps'] = [[
					'usrgrpid' => CAuthenticationHelper::get(CAuthenticationHelper::DISABLED_USER_GROUPID)
				]];
				$users[$userid]['roleid'] = 0;
				$user = [];
			}
		}

		self::updateForce(array_values($users), $db_users);

		return $user;
	}

	/**
	 * Returns user type.
	 *
	 * @param string $roleid
	 *
	 * @return int
	 */
	private static function getUserType(string $roleid): int {
		if (!$roleid) {
			return USER_TYPE_ZABBIX_USER;
		}

		return DBfetchColumn(DBselect('SELECT type FROM role WHERE roleid='.zbx_dbstr($roleid)), 'type')[0];
	}

	private static function getUgSetId(array $db_user): ?string {
		if ($db_user['roleid'] != 0 && $db_user['type'] != USER_TYPE_SUPER_ADMIN) {
			$options = [
				'output' => ['ugsetid'],
				'userids' => $db_user['userid']
			];
			$row = DBfetch(DBselect(DB::makeSql('user_ugset', $options)));

			if ($row) {
				return $row['ugsetid'];
			}
		}

		return 0;
	}

	protected function addRelatedObjects(array $options, array $result) {
		$result = parent::addRelatedObjects($options, $result);

		$userIds = zbx_objectValues($result, 'userid');

		// adding usergroups
		if ($options['selectUsrgrps'] !== null && $options['selectUsrgrps'] != API_OUTPUT_COUNT) {
			$relationMap = $this->createRelationMap($result, 'userid', 'usrgrpid', 'users_groups');

			$dbUserGroups = API::UserGroup()->get([
				'output' => $options['selectUsrgrps'],
				'usrgrpids' => $relationMap->getRelatedIds(),
				'preservekeys' => true
			]);

			$result = $relationMap->mapMany($result, $dbUserGroups, 'usrgrps');
		}

		// adding medias
		if ($options['selectMedias'] !== null && $options['selectMedias'] != API_OUTPUT_COUNT
				&& (self::$userData['type'] == USER_TYPE_SUPER_ADMIN
					|| array_key_exists(self::$userData['userid'], $result))) {
			$media_output_fields = self::$userData['type'] == USER_TYPE_SUPER_ADMIN
				? self::OUTPUT_MEDIA_FIELDS
				: self::LIMITED_OUTPUT_MEDIA_FIELDS;

			if (is_array($options['selectMedias'])) {
				$options['selectMedias'] = array_intersect($options['selectMedias'], $media_output_fields);
			}
			elseif ($options['selectMedias'] == API_OUTPUT_EXTEND) {
				$options['selectMedias'] = $media_output_fields;
			}
			else {
				$options['selectMedias'] = ['mediaid'];
			}

			$media_userids = self::$userData['type'] == USER_TYPE_SUPER_ADMIN
				? $userIds
				: [self::$userData['userid']];

			$db_medias = API::getApiService()->select('media', [
				'output' => $this->outputExtend($options['selectMedias'],
					['userid', 'mediaid', 'mediatypeid', 'userdirectory_mediaid']),
				'filter' => ['userid' => $media_userids],
				'preservekeys' => true
			]);

			// 'sendto' parameter in media types with 'type' == MEDIA_TYPE_EMAIL are returned as array.
			if (in_array('sendto', $options['selectMedias']) && $db_medias) {
				$db_email_medias = DB::select('media_type', [
					'output' => [],
					'filter' => [
						'mediatypeid' => zbx_objectValues($db_medias, 'mediatypeid'),
						'type' => MEDIA_TYPE_EMAIL
					],
					'preservekeys' => true
				]);

				foreach ($db_medias as &$db_media) {
					if (array_key_exists($db_media['mediatypeid'], $db_email_medias)) {
						$db_media['sendto'] = explode("\n", $db_media['sendto']);
					}
				}
				unset($db_media);
			}

			// 'provisioned' parameter is returned if requested.
			if ($this->outputIsRequested('provisioned', $options['selectMedias']) && $db_medias) {
				foreach ($db_medias as &$db_media) {
					$db_media['provisioned'] = $db_media['userdirectory_mediaid'] == 0
						? (string) self::PROVISION_STATUS_NO
						: (string) self::PROVISION_STATUS_YES;
				}
				unset($db_media);
			}

			if (self::$userData['type'] == USER_TYPE_SUPER_ADMIN) {
				$relation_map = $this->createRelationMap($db_medias, 'userid', 'mediaid');

				$db_medias = $this->unsetExtraFields($db_medias,
					['userid', 'mediaid', 'mediatypeid', 'userdirectory_mediaid'],
					$options['selectMedias']
				);

				$result = $relation_map->mapMany($result, $db_medias, 'medias');
			}
			else {
				$db_medias = $this->unsetExtraFields($db_medias,
					['userid', 'mediaid', 'mediatypeid', 'userdirectory_mediaid'],
					$options['selectMedias']
				);

				$result[self::$userData['userid']]['medias'] = array_values($db_medias);
			}
		}

		// adding media types
		if ($options['selectMediatypes'] !== null && $options['selectMediatypes'] != API_OUTPUT_COUNT
				&& (self::$userData['type'] == USER_TYPE_SUPER_ADMIN
					|| array_key_exists(self::$userData['userid'], $result))) {
			if (self::$userData['type'] == USER_TYPE_SUPER_ADMIN) {
				$relationMap = $this->createRelationMap($result, 'userid', 'mediatypeid', 'media');

				$media_types = API::Mediatype()->get([
					'output' => $options['selectMediatypes'],
					'mediatypeids' => $relationMap->getRelatedIds(),
					'preservekeys' => true
				]);

				$result = $relationMap->mapMany($result, $media_types, 'mediatypes');
			}
			else {
				$media_types = API::Mediatype()->get([
					'output' => $options['selectMediatypes'],
					'userids' => self::$userData['userid'],
					'preservekeys' => true
				]);

				$result[self::$userData['userid']]['mediatypes'] = [];

				foreach ($media_types as $media_type) {
					$result[self::$userData['userid']]['mediatypes'][] = $media_type;
				}
			}
		}

		$this->addRelatedRole($options, $result);

		return $result;
	}

	private function addRelatedRole(array $options, array &$result): void {
		if ($options['selectRole'] === null
				|| (self::$userData['type'] != USER_TYPE_SUPER_ADMIN
					&& !array_key_exists(self::$userData['userid'], $result))) {
			return;
		}

		if (self::$userData['type'] == USER_TYPE_SUPER_ADMIN) {
			$relation_map = $this->createRelationMap($result, 'userid', 'roleid');

			$db_roles = API::Role()->get([
				'output' => $options['selectRole'] === API_OUTPUT_EXTEND
					? CRole::OUTPUT_FIELDS
					: $options['selectRole'],
				'roleids' => $relation_map->getRelatedIds(),
				'preservekeys' => true
			]);

			$result = $relation_map->mapOne($result, $db_roles, 'role');
		}
		else {
			$db_roles = API::Role()->get([
				'output' => $options['selectRole'] === API_OUTPUT_EXTEND
					? CRole::OUTPUT_FIELDS
					: $options['selectRole'],
				'roleids' => $result[self::$userData['userid']]['roleid'],
				'preservekeys' => true
			]);

			$result[self::$userData['userid']]['role'] = reset($db_roles);
		}
	}

	/**
	 * Function to validate if password meets password policy requirements.
	 *
	 * @param array  $user
	 * @param string $user['username']  (optional)
	 * @param string $user['name']      (optional)
	 * @param string $user['surname']   (optional)
	 * @param string $user['passwd']
	 * @param string $path              Password field path to display error message.
	 *
	 * @throws APIException if the input is invalid.
	 *
	 * @return bool
	 */
	private function checkPassword(array $user, string $path): bool {
		$context_data = array_filter(array_intersect_key($user, array_flip(['username', 'name', 'surname'])));
		$passwd_validator = new CPasswordComplexityValidator([
			'passwd_min_length' => CAuthenticationHelper::get(CAuthenticationHelper::PASSWD_MIN_LENGTH),
			'passwd_check_rules' => CAuthenticationHelper::get(CAuthenticationHelper::PASSWD_CHECK_RULES)
		]);
		$passwd_validator->setContextData($context_data);

		if ($passwd_validator->validate($user['passwd']) === false) {
			self::exception(ZBX_API_ERROR_PARAMETERS,
				_s('Incorrect value for field "%1$s": %2$s.', $path, $passwd_validator->getError())
			);
		}

		return true;
	}

	/**
	 * For user provisioned by IDP_TYPE_LDAP update user provisioned attributes. Will return empty array
	 * when user is deprovisioned.
	 *
	 * @param array  $user_data
	 * @param int    $user_data['userid']
	 * @param string $user_data['username']
	 * @return array
	 */
	protected function provisionLdapUser(array $user_data): array {
		$provisioning = CProvisioning::forUserDirectoryId($user_data['userdirectoryid']);
		$config = $provisioning->getIdpConfig();
		$ldap = new CLdap($config);

		if ($ldap->bind_type != CLdap::BIND_ANONYMOUS && $ldap->bind_type != CLdap::BIND_CONFIG_CREDENTIALS) {
			return $user_data;
		}

		$user = $ldap->getProvisionedData($provisioning, $user_data['username']);
		$user['username'] = $user_data['username'];
		$user['userid'] = $user_data['userid'];

		return $this->updateProvisionedUser($user);
	}

	/**
	 * Remove invalid medias.
	 *
	 * @param array  $medias
	 * @param string $medias[]['name']
	 * @param string $medias[]['mediatypeid']
	 * @param array  $medias[]['sendto']
	 * @param string $medias[]['active']
	 * @param string $medias[]['severity']
	 * @param string $medias[]['period']
	 * @param string $medias[]['userdirectory_mediaid']
	 *
	 * @return array
	 */
	protected function sanitizeUserMedia(array $medias): array {
		if (!$medias) {
			return $medias;
		}

		$email_mediatypeids = [];
		$mediatypeids = array_unique(array_column($medias, 'mediatypeid'));

		if ($mediatypeids) {
			$email_mediatypeids = DB::select('media_type', [
				'output' => [],
				'filter' => ['type' => MEDIA_TYPE_EMAIL],
				'mediatypeids' => $mediatypeids,
				'preservekeys' => true
			]);
		}

		$user_medias = [];

		$email_validator = new CEmailValidator();
		$max_length = DB::getFieldLength('media', 'sendto');
		$fields = array_flip(['mediatypeid', 'sendto', 'active', 'severity', 'period', 'userdirectory_mediaid']);

		foreach ($medias as $media) {
			$sendto = array_filter($media['sendto'], 'strlen');

			if (array_key_exists($media['mediatypeid'], $email_mediatypeids)) {
				$sendto = array_filter($media['sendto'], [$email_validator, 'validate']);

				while (mb_strlen(implode("\n", $sendto)) > $max_length && count($sendto) > 0) {
					array_pop($sendto);
				}
			}

			if ($sendto) {
				$media['sendto'] = $sendto;
				$user_medias[] = array_intersect_key($media, $fields);
			}
		}

		return $user_medias;
	}

	/**
	 * Returns data necessary for user.confirm method.
	 *
	 * @param array  $session_data
	 *
	 * @return array  data['mfa']
	 * @return string data['userid]
	 * @return string data['totp_secret']  If MFA_TYPE_TOTP and user has no totp_secret.
	 * @return string data['qr_code_url']  If MFA_TYPE_TOTP and user has no totp_secret.
	 * @return string data['username']     If MFA_TYPE_DUO.
	 * @return string data['state']        If MFA_TYPE_DUO.
	 * @return string data['prompt_uri']   If MFA_TYPE_DUO.
	 */
	public static function getConfirmData(array $session_data): array {
		$db_sessions = DB::select('sessions', [
			'output' => ['userid'],
			'sessionids' => $session_data['sessionid']
		]);

		if (!$db_sessions) {
			self::exception(ZBX_API_ERROR_PERMISSIONS, _('You must login to view this page.'));
		}

		$db_user = DB::select('users', [
			'output' => ['userid', 'userdirectoryid', 'username'],
			'userids' => $db_sessions[0]['userid']
		])[0];

		self::addUserGroupFields($db_user, $group_status);
		self::checkGroupStatus($db_user, $group_status);

		if ($db_user['mfaid'] == 0) {
			self::exception(ZBX_API_ERROR_PERMISSIONS, _('You must login to view this page.'));
		}

		$db_mfas = DB::select('mfa', [
			'output' => ['mfaid', 'type', 'name', 'hash_function', 'code_length', 'api_hostname', 'clientid',
				'client_secret'
			],
			'mfaids' => $db_user['mfaid']
		]);
		$mfa = $db_mfas[0];

		$data = [
			'sessionid' => $session_data['sessionid'],
			'mfa' => $mfa,
			'userid' => $db_user['userid']
		];

		if ($mfa['type'] == MFA_TYPE_TOTP) {
			$user_totp_secret = DB::select('mfa_totp_secret', [
				'output' => ['mfa_totp_secretid', 'totp_secret', 'status'],
				'filter' => ['mfaid' => $data['mfa']['mfaid'], 'userid' => $db_user['userid']]
			]);

			// Delete previously saved totp_secret for this specific user which are not related to current MFA method.
			DBexecute(
				'DELETE FROM mfa_totp_secret'.
					' WHERE '.dbConditionId('userid', [$db_user['userid']]).
						' AND '.dbConditionId('mfaid', [$mfa['mfaid']], true)
			);

			if (!$user_totp_secret || $user_totp_secret[0]['status'] == TOTP_SECRET_CONFIRMATION_REQUIRED) {
				$totp_generator = self::createTotpGenerator($data['mfa']);
				$data['totp_secret'] = $totp_generator->generateSecretKey(TOTP_SECRET_LENGTH_32);
				$data['qr_code_url'] = $totp_generator->getQRCodeUrl($data['mfa']['name'], $db_user['username'],
					$data['totp_secret']
				);

				if (!$user_totp_secret) {
					DB::insert('mfa_totp_secret', [[
						'mfaid' => $data['mfa']['mfaid'],
						'userid' => $data['userid'],
						'totp_secret' => $data['totp_secret'],
						'status' => TOTP_SECRET_CONFIRMATION_REQUIRED
					]]);
				}
				else {
					DB::update('mfa_totp_secret', [
						'values' => ['totp_secret' => $data['totp_secret']],
						'where' => ['mfa_totp_secretid' => $user_totp_secret[0]['mfa_totp_secretid']]
					]);
				}
			}
		}

		if ($mfa['type'] == MFA_TYPE_DUO) {
			try {
				$duo_client = new Client($data['mfa']['clientid'], $data['mfa']['client_secret'],
					$data['mfa']['api_hostname'], $session_data['redirect_uri']
				);

				$duo_client->healthCheck();
			}
			catch (DuoException $e) {
				self::exception(ZBX_API_ERROR_PARAMETERS,
					'Verify the values in Duo Universal Prompt MFA method are correct.'. $e->getMessage()
				);
			}

			$data['username'] = $db_user['username'];
			$data['state'] = $duo_client->generateState();
			$data['prompt_uri'] = $duo_client->createAuthUrl($data['username'], $data['state']);
		}

		return $data;
	}

	/**
	 * Check MFA method authentication for the user based on provided data.
	 * Returns 'sessionid' and 'mfa' object, in case MFA authentication was successful.
	 *
	 * @param array  $data
	 * @param string $data['sessionid']                               User's sessionid passed in session data.
	 * @param string $data['redirect_uri']                            Redirect uri that will be used for Duo MFA.
	 * @param array  $data['mfa_response_data']                       Array with data for MFA response confirmation.
	 * @param string $data['mfa_response_data']['verification_code']  TOTP MFA verification code.
	 * @param string $data['mfa_response_data']['totp_secret']        TOTP MFA secret at initial registration.
	 * @param string $data['mfa_response_data']['duo_code']           DUO MFA response code.
	 * @param string $data['mfa_response_data']['duo_state']          DUO MFA response state.
	 * @param string $data['mfa_response_data']['state']              DUO MFA state from session.
	 * @param string $data['mfa_response_data']['username']           DUO MFA username from session.
	 *
	 * @return array
	 *
	 * @throws Exception
	 */
	public static function confirm(array $data): array {
		$db_sessions = DB::select('sessions', [
			'output' => ['userid'],
			'sessionids' => $data['sessionid']
		]);

		if (!$db_sessions) {
			self::exception(ZBX_API_ERROR_PERMISSIONS, _('You must login to view this page.'));
		}

		$db_user = DB::select('users', [
			'output' => ['userid', 'userdirectoryid', 'username', 'attempt_failed', 'attempt_clock'],
			'userids' => $db_sessions[0]['userid']
		])[0];

		self::addUserGroupFields($db_user, $group_status);
		self::checkGroupStatus($db_user, $group_status);

		if ($db_user['mfaid'] == 0) {
			self::exception(ZBX_API_ERROR_PERMISSIONS, _('You must login to view this page.'));
		}

		$db_mfas = DB::select('mfa', [
			'output' => ['mfaid', 'type', 'name', 'hash_function', 'code_length', 'api_hostname', 'clientid',
				'client_secret'
			],
			'mfaids' => $db_user['mfaid']
		]);
		$mfa = $db_mfas[0];

		$mfa_response = $data['mfa_response_data'];

		if ($mfa['type'] == MFA_TYPE_TOTP) {
			$enrollment_filter = $mfa_response['totp_secret'] != null
				? ['totp_secret' => $mfa_response['totp_secret'], 'status' => TOTP_SECRET_CONFIRMATION_REQUIRED]
				: [];

			$db_user_secrets = DB::select('mfa_totp_secret', [
				'output' => ['mfa_totp_secretid', 'totp_secret', 'status', 'used_codes'],
				'filter' => ['mfaid' => $mfa['mfaid'], 'userid' => $db_user['userid']] + $enrollment_filter
			]);

			if (!$db_user_secrets) {
				self::exception(ZBX_API_ERROR_PERMISSIONS, _('You must login to view this page.'));
			}

			$db_user_secret = $db_user_secrets[0];
			$used_codes = explode(',', $db_user_secret['used_codes']);

			$valid_code = (self::createTotpGenerator($mfa))
				->verifyKey($db_user_secret['totp_secret'], $mfa_response['verification_code']);

			if ($valid_code) {
				$valid_code = !array_key_exists($mfa_response['verification_code'], array_flip($used_codes));
			}

			if ($valid_code) {
				$used_codes = array_slice(
					array_merge($used_codes, [$mfa_response['verification_code']]), -TOTP_MAX_USED_CODES
				);

				$upd_totp_secret = [
					'values' => ['used_codes' => implode(',', $used_codes)],
					'where' => ['mfa_totp_secretid' => $db_user_secret['mfa_totp_secretid']]
				];

				if ($mfa_response['totp_secret'] != null) {
					$upd_totp_secret['values']['status'] = TOTP_SECRET_CONFIRMED;
				}

				DB::update('mfa_totp_secret', [$upd_totp_secret]);
			}
			else {
				self::increaseFailedLoginAttempts($db_user);

				try {
					self::checkLoginTemporarilyBlocked($db_user);
				}
				catch (Exception $e) {
					DB::delete('sessions', ['sessionid' => $data['sessionid']]);

					throw $e;
				}

				self::loginException($db_user['userid'], $db_user['username'], ZBX_API_ERROR_PARAMETERS,
					_('The verification code was incorrect, please try again.')
				);
			}
		}

		if ($mfa['type'] == MFA_TYPE_DUO) {
			if (!array_key_exists('state', $mfa_response) || !array_key_exists('username', $mfa_response)) {
				self::exception(ZBX_API_ERROR_PERMISSIONS, _('No saved state, please login again.'));
			}

			if ($mfa_response['duo_state'] != $mfa_response['state']) {
				self::exception(ZBX_API_ERROR_PERMISSIONS, _('Duo state does not match saved state.'));
			}

			try {
				$duo_client = new Client($mfa['clientid'], $mfa['client_secret'],
					$mfa['api_hostname'], $data['redirect_uri']
				);

				$duo_client->exchangeAuthorizationCodeFor2FAResult($mfa_response['duo_code'],
					$mfa_response['username']
				);
			} catch (DuoException $e) {
				self::loginException($db_user['userid'], $db_user['username'], ZBX_API_ERROR_PERMISSIONS,
					_('Error decoding Duo result.')
				);
			}
		}

		DB::update('sessions', [
			'values' => ['status' => ZBX_SESSION_ACTIVE],
			'where' => ['sessionid' => $data['sessionid']]
		]);

		$outdated = strtotime('-5 minutes');

		DBexecute(
			'DELETE FROM sessions'.
				' WHERE '.dbConditionId('userid', [$db_user['userid']]).
					' AND '.dbConditionInt('status', [ZBX_SESSION_CONFIRMATION_REQUIRED]).
					' AND lastaccess<'.zbx_dbstr($outdated)
		);

		self::$userData = $db_user + ['userip' => CWebUser::getIp()];

		self::resetFailedLoginAttempts($db_user);
		self::addAuditLog(CAudit::ACTION_LOGIN_SUCCESS, CAudit::RESOURCE_USER);

		return [
			'sessionid' => $data['sessionid'],
			'mfa' => $mfa
		];
	}

	/**
	 * Returns Google2FA library instance for TOTP secret creation and code verification.
	 *
	 * @param $data
	 * @return Google2FA
	 */
	private static function createTotpGenerator($data): Google2FA {
		$totp_generator = new Google2FA();

		switch ($data['hash_function']) {
			case TOTP_HASH_SHA256:
				$totp_generator->setAlgorithm(Constants::SHA256);
				break;

			case TOTP_HASH_SHA512:
				$totp_generator->setAlgorithm(Constants::SHA512);
				break;

			default:
				$totp_generator->setAlgorithm(Constants::SHA1);
		}

		$totp_generator->setWindow(TOTP_VERIFICATION_DELAY_WINDOW);

		switch ($data['code_length']) {
			case TOTP_CODE_LENGTH_6:
				$totp_generator->setOneTimePasswordLength(TOTP_CODE_LENGTH_6);
				break;

			case TOTP_CODE_LENGTH_8:
				$totp_generator->setOneTimePasswordLength(TOTP_CODE_LENGTH_8);
				break;
		}

		return $totp_generator;
	}

	public static function terminateActiveSessions(array $userids): void {
		DB::update('sessions', [
			'values' => ['status' => ZBX_SESSION_PASSIVE],
			'where' => ['userid' => $userids]
		]);
	}

	/**
	 * Reset TOTP secret of provided users and terminate active session.
	 *
	 * @param array $userids
	 */
	public function resetTotp(array $userids): array {
		$api_input_rules = ['type' => API_IDS, 'flags' => API_NOT_EMPTY, 'uniq' => true];
		if (!CApiInputValidator::validate($api_input_rules, $userids, '/', $error)) {
			self::exception(ZBX_API_ERROR_PARAMETERS, $error);
		}

		$db_users_secrets = DB::select('mfa_totp_secret', [
			'output' => ['userid'],
			'filter' => ['userid' => $userids],
			'preservekeys' => true
		]);

		if ($db_users_secrets) {
			DB::delete('mfa_totp_secret', ['mfa_totp_secretid' => array_keys($db_users_secrets)]);

			self::terminateActiveSessions(array_filter($userids,
				static fn (string $userid): bool => bccomp($userid, self::$userData['userid']) != 0
			));
		}

		return ['userids' => $userids];
	}
}
