<?php

namespace bff\modules\users;

use Dev;
use func;
use User;
use Model as BaseModel;

/**
 * @property Base|\UsersBase $controller
 */
class Model extends BaseModel implements Consts
{
    /** @var array Список шифруемых полей в таблице static::TABLE_USERS */
    public $cryptUsers = [];

    /**
     * Список полей данных о пользователе запрашиваемых для сохранения в сессии
     * @var array
     * обязательные: id, member, admin, login, email, password, password_salt, name, avatar, sex
     */
    protected $userSessionDataKeys = [
        'user_id as id',
        'member',
        'login',
        'password',
        'password_salt',
        'email',
        'name',
        'phone',
        'avatar',
        'sex',
        'activated',
        'blocked',
        'blocked_reason',
        'admin',
        'last_login',
    ];

    /**
     * User model
     * @param int|null $id
     * @param array $columns
     * @param array $with
     * @return \bff\modules\users\models\User | \modules\users\models\User | \bff\db\illuminate\Model | array
     */
    public function user($id = null, $columns = ['*'], array $with = [])
    {
        $model = $this->model('User');
        if (! empty($id)) {
            return $model->one($id, $columns, $with);
        }
        return $model;
    }

    /**
     * Формируем список пользователей
     * @param array $filter фильтр списка
     * @param array $dataKeys список требуемых полей
     * @param bool $countOnly только подсчет кол-ва
     * @param string $limit
     * @param string $orderBy
     * @return mixed
     */
    public function usersList(array $filter, array $dataKeys = [], $countOnly = false, $limit = '', $orderBy = '')
    {
        if (! empty($this->cryptUsers)) {
            foreach ($filter as $k => $v) {
                if (in_array($k, $this->cryptUsers)) {
                    unset($filter[$k]);
                    $filter[':' . $k] = ['BFF_DECRYPT(' . $this->wrapColumn($k) . ') = :' . $k, ':' . $k => $v];
                }
            }
        }
        $filter = $this->prepareFilter($filter);

        if ($countOnly) {
            if (trim($filter['where']) === '') {
                return $this->db->select_rows_count(static::TABLE_USERS);
            }
            return $this->db->one_data('SELECT COUNT(user_id) FROM ' . static::TABLE_USERS . '
                INNER JOIN ' . static::TABLE_USERS_STAT . ' USING (user_id)
                ' . $filter['where'], $filter['bind']);
        }

        if (empty($dataKeys)) {
            $dataKeys = ['*'];
            $assocKey = 'user_id';
            if (! empty($this->cryptUsers)) {
                foreach ($this->cryptUsers as $k => $v) {
                    $dataKeys[] = 'BFF_DECRYPT(' . $this->wrapColumn($k) . ') as ' . $this->wrapColumn($k);
                }
            }
        } else {
            if (! empty($this->cryptUsers)) {
                foreach ($dataKeys as $k => $v) {
                    if (in_array($v, $this->cryptUsers)) {
                        $dataKeys[$k] = 'BFF_DECRYPT(' . $this->wrapColumn($v) . ') as ' . $this->wrapColumn($v);
                    }
                }
            }
            $assocKey = (in_array('user_id', $dataKeys) ? 'user_id' : false);
        }

        if (is_numeric($limit)) {
            $limit = $this->db->prepareLimit(0, $limit);
        }

        return $this->db->select_key('SELECT ' . join(',', $this->wrapColumn($dataKeys)) . '
               FROM ' . static::TABLE_USERS . '
                    INNER JOIN ' . static::TABLE_USERS_STAT . ' USING (user_id)
               ' . $filter['where'] .
            ' GROUP BY user_id ' .
            (!empty($orderBy) ? ' ORDER BY ' . $orderBy : '')
            . $limit, $assocKey, $filter['bind']);
    }

    /**
     * Получаем данные о пользователе по фильтру
     * @param array|int $filter фильтр
     * @param mixed $dataKeys ключи необходимых данных
     * @param array $bind
     * @return array|mixed
     */
    public function userDataByFilter($filter, $dataKeys = '*', $bind = [])
    {
        if (!is_array($filter)) {
            $filter = ['user_id' => $filter];
        }
        if (! empty($this->cryptUsers)) {
            foreach ($filter as $k => $v) {
                if (in_array($k, $this->cryptUsers)) {
                    unset($filter[$k]);
                    $filter[':' . $k] = ['BFF_DECRYPT(' . $this->wrapColumn($k) . ') = :' . $k, ':' . $k => $v];
                }
            }
        }
        $filter = $this->prepareFilter($filter);

        if (empty($dataKeys)) {
            return [];
        } else {
            if ($dataKeys == '*') {
                $dataKeys = ['*'];
                if (! empty($this->cryptUsers)) {
                    foreach ($this->cryptUsers as $k => $v) {
                        $dataKeys[] = 'BFF_DECRYPT(' . $this->wrapColumn($k) . ') as ' . $this->wrapColumn($k);
                    }
                }
            } else {
                if (!is_array($dataKeys)) {
                    $dataKeys = [$dataKeys];
                }
                if (! empty($this->cryptUsers)) {
                    foreach ($dataKeys as $k => $v) {
                        if (is_string($k)) {
                            if (in_array($k, $this->cryptUsers)) {
                                $dataKeys[$k] = 'BFF_DECRYPT(' . $this->wrapColumn($k) . ') as ' . $this->wrapColumn($v);
                            }
                        } else {
                            if (in_array($v, $this->cryptUsers)) {
                                $dataKeys[$k] = 'BFF_DECRYPT(' . $this->wrapColumn($v) . ') as ' . $this->wrapColumn($v);
                            }
                        }
                    }
                }
            }
        }

        if (! empty($bind)) {
            $filter['bind'] = array_merge($bind, $filter['bind']);
        }

        $data = $this->db->one_array('SELECT ' . join(',', $this->wrapColumn($dataKeys)) . '
           FROM ' . static::TABLE_USERS . '
                INNER JOIN ' . static::TABLE_USERS_STAT . ' USING (user_id)
           ' . $filter['where'] . '
           LIMIT 1', $filter['bind']);

        if (isset($data['contacts']) && method_exists($this->controller, 'contactsToArray')) {
            $data['contacts'] = $this->controller->contactsToArray($data['contacts']);
        }
        return $data;
    }

    /**
     * Получаем данные о пользователе для сохранения в сессии
     * @param array|int $filter фильтр
     * @param bool $withGroups получать информацию о группах, в которых состоит пользователь
     * @return mixed
     */
    public function userSessionData($filter, $withGroups = false)
    {
        $data = $this->userDataByFilter($filter, $this->userSessionDataKeys);
        if (! empty($data) && $withGroups) {
            $groups = $this->userGroups($data['id'], true);
            if (empty($groups) && $data['member'] == 1) {
                $groups = [static::GROUPID_MEMBER => static::GROUPID_MEMBER];
            }
            $data['groups'] = $groups;
        }

        return $data;
    }

    /**
     * Получаем password-hash пользователя по логину|email|ID пользователя
     * @param mixed $login login|email|user_id пользователя
     * @param string $byField поле для первого параметра - login, email, user_id
     * @return mixed
     */
    public function userPassword($login, $byField = 'login')
    {
        $data = $this->userDataByFilter([$byField => $login], ['password']);

        return $data['password'] ?? '';
    }

    /**
     * Получаем текущий баланс пользователя
     * @param int $userID ID пользователя
     * @return float
     */
    public function userBalance($userID)
    {
        $data = $this->userDataByFilter($userID, ['balance']);

        return floatval($data['balance'] ?? 0);
    }

    /**
     * Обновляем счетчик пользователя
     * @param mixed $userID
     * @param string $key
     * @param int $amount
     * @param bool $incrementDecrement true +/-, false - set new amount
     * @return bool
     */
    public function userCounterSave($userID, string $key, int $amount, bool $incrementDecrement = true)
    {
        if ($amount < 0 && $incrementDecrement) {
            $update = [$key . ' = (CASE WHEN ' . $key . ' >= ' . abs($amount) . ' THEN ' . $key . ' + (' . $amount . ') ELSE ' . $key . ' END)'];
        } else {
            $update = [$key . ' = ' . ($incrementDecrement ? $key . ' + (' . $amount . ')' : $amount)];
        }

        return $this->userSave($userID, false, $update);
    }

    /**
     * Сохраняем данные пользователя
     * @param int|array $userID ID пользователя
     * @param array|bool $data данные
     * @param array $dataStat динамические данные
     * @param array $bind доп. параметры запросы для bind'a
     * @return bool
     */
    public function userSave($userID, $data, $dataStat = [], array $bind = [])
    {
        $res = true;
        $conditions = ['user_id' => $userID];
        if (! empty($data)) {
            $res = $this->db->update(static::TABLE_USERS, $data, $conditions, $bind, $this->cryptUsers);
            $res = !empty($res);
        }
        if (! empty($dataStat)) {
            $this->db->update(static::TABLE_USERS_STAT, $dataStat, $conditions, $bind, $this->cryptUsers);
        }
        $this->app->hook('users.user.save', $userID, ['data' => &$data, 'dataStat' => &$dataStat]);

        return $res;
    }

    /**
     * Создаем пользователя
     * @param array $data данные пользователя
     * @param array $groupsId ID групп, в которые входит пользователь
     * @return int ID пользователя
     */
    public function userCreate($data, array $groupsId = [])
    {
        # login is required
        $data['login'] = $data['login'] ?? $this->userLoginGenerate();

        $groupsId = $groupsId ?: [static::GROUPID_MEMBER];
        $data['member'] = (in_array(static::GROUPID_MEMBER, $groupsId) ? 1 : 0);
        $data['admin'] = ($this->group()->where(['group_id' => $groupsId, 'adminpanel' => 1])->exists() ? 1 : 0);

        $data['user_id_ex'] = func::generator(6);
        $data['created'] = $this->now();
        $data['created_ip'] = $data['created_ip'] ?? $this->request->remoteAddress(true);
        if (isset($data['contacts']) && is_array($data['contacts'])) {
            $data['contacts'] = json_encode($data['contacts']);
        }
        if (isset($data['phones']) && is_array($data['phones'])) {
            $data['phones'] = serialize($data['phones']);
        }
        $id = $this->db->insert(static::TABLE_USERS, $data, 'user_id', [], $this->cryptUsers);

        if ($id > 0) {
            if ($groupsId) {
                $this->userToGroups($id, $groupsId, false);
            }
            $this->db->insert(static::TABLE_USERS_STAT, ['user_id' => $id], false, [], $this->cryptUsers);
        } else {
            $id = 0;
        }
        if ($id > 0) {
            $this->app->hook('users.user.create', $id, ['data' => &$data, 'groups' => $groupsId]);
        }

        return $id;
    }

    /**
     * Проверяем наличие пользователя по логину
     * @param string $login логин
     * @param int $exceptUserID ID пользователя, которого следует исключить из проверки
     * @param array $reservedLogins список зарезервированных логинов
     * @return bool
     */
    public function userLoginExists($login, $exceptUserID = 0, array $reservedLogins = [])
    {
        if (! empty($reservedLogins) && in_array($login, $reservedLogins, true)) {
            return true;
        }

        $filter = ['login' => $login];
        $bind = [];
        if (! empty($exceptUserID)) {
            $filter[':userIdExcept'] = 'user_id != :userId ';
            $bind[':userId'] = $exceptUserID;
        }

        $res = $this->userDataByFilter($filter, 'user_id', $bind);

        return (!empty($res));
    }

    /**
     * Генерируем уникальный логин (с проверкой на наличие)
     * @param string $prefix префикс, по-умолчанию "u"
     * @param bool $increment
     * @return string
     */
    public function userLoginGenerate($prefix = 'u', $increment = false)
    {
        if ($increment) {
            $done = false;
            $i = 1;
            do {
                $login = $prefix . ($i > 1 ? $i : '');
                $i++;
                $res = $this->db->one_data('SELECT user_id FROM ' . static::TABLE_USERS . ' WHERE login = :login', [
                    ':login' => $login,
                ]);
                if (empty($res)) {
                    $done = true;
                } else {
                    # не нашли за 5 итераций, генерируем упрощенным вариантом
                    if ($i >= 5) {
                        $login = $this->userLoginGenerate($prefix, false);
                        $done = true;
                    }
                }
            } while (!$done);
        } else {
            $done = false;
            do {
                $login = $prefix . mt_rand(123456789, 987654321);
                $res = $this->db->one_data('SELECT user_id FROM ' . static::TABLE_USERS . ' WHERE login = :login', [
                    ':login' => $login,
                ]);
                if (empty($res)) {
                    $done = true;
                }
            } while (!$done);
        }

        return $login;
    }

    /**
     * Проверяем наличие пользователя по email-адресу
     * @param string $email email адрес
     * @param int $exceptUserID ID пользователя, которого следует исключить из проверки
     * @return bool
     */
    public function userEmailExists($email, $exceptUserID = 0)
    {
        $res = $this->userDataByEmail($email, 'user_id', $exceptUserID);

        return !empty($res);
    }

    /**
     * Данные пользователя по email-адресу
     * @param string $email email адрес
     * @param string|array $dataKeys ключи необходимых данных
     * @param int $exceptUserID ID пользователя, которого следует исключить из поиска
     * @return array|mixed данные пользователя или false
     */
    public function userDataByEmail($email, $dataKeys = '*', $exceptUserID = 0)
    {
        $bind = [];
        $filter = [];
        if ($this->userEmailCrypted()) {
            $filter[':emailCheck'] = ['BFF_DECRYPT(email) = :email', ':email' => $email];
        } else {
            $filter['email'] = $email;
        }
        if (! empty($exceptUserID)) {
            $filter[':userIdExcept'] = 'user_id != :userId';
            $bind[':userId'] = $exceptUserID;
        }

        return $this->userDataByFilter($filter, $dataKeys, $bind);
    }

    /**
     * Используется ли шифрование поля email в таблице static::TABLE_USERS
     * @return bool
     */
    public function userEmailCrypted()
    {
        return (!empty($this->cryptUsers) && in_array('email', $this->cryptUsers));
    }

    /**
     * Проверяем наличие пользователя по номеру телефона
     * @param string $phoneNumber номер телефона
     * @param int $exceptUserID ID пользователя, которого следует исключить из проверки
     * @param string $fieldName имя поля телефона в БД
     * @return bool
     */
    public function userPhoneExists($phoneNumber, $exceptUserID = 0, $fieldName = 'phone_number')
    {
        $filter = [];
        if (! empty($this->cryptUsers) && in_array($fieldName, $this->cryptUsers)) {
            $filter[':phone'] = ['BFF_DECRYPT(' . $fieldName . ') = :phone', ':phone' => $phoneNumber];
        } else {
            $filter[$fieldName] = $phoneNumber;
        }
        $bind = [];
        if (! empty($exceptUserID)) {
            $filter[':userIdExcept'] = 'user_id != :userId ';
            $bind[':userId'] = $exceptUserID;
        }

        $res = $this->userDataByFilter($filter, 'user_id', $bind);

        return (!empty($res));
    }

    // ---------------------------------------------------------------------------
    // Группы пользователей

    /**
     * Включаем пользователя в группу, по keyword'у группы
     * @param int $userID ID пользователя
     * @param string $groupKeyword keyword группы
     * @param bool $outgoinFromGroups открепить пользователя от закрепленных за ним групп
     * @return bool
     */
    public function userToGroup($userID, $groupKeyword, $outgoinFromGroups = true)
    {
        # получаем ID группы по $groupKeyword
        $groupID = (int)$this->db->one_data(
            'SELECT group_id FROM ' . static::TABLE_USERS_GROUPS . '
            WHERE keyword = :keyword LIMIT 1',
            [':keyword' => $groupKeyword]
        );
        if (! $groupID) {
            return false;
        }

        return $this->userToGroups($userID, $groupID, $outgoinFromGroups);
    }

    /**
     * Включаем пользователя в группы
     * @param int $userID ID пользователя
     * @param array|int $groupID ID группы (нескольких групп)
     * @param bool $outgoinFromGroups исключить пользователя из текущих групп перед включением в новые
     * @return bool
     */
    public function userToGroups($userID, $groupID, $outgoinFromGroups = true)
    {
        if (! is_array($groupID)) {
            $groupID = [$groupID];
        }

        # открепляем пользователя от закрепленных за ним групп
        if ($outgoinFromGroups) {
            $this->userFromGroups($userID, null);
        } else {
            # исключаем группы в которых пользователь уже состоит
            $groupsCurrent = $this->userGroups($userID);
            if (! empty($groupsCurrent)) {
                foreach ($groupsCurrent as $v) {
                    $exists = array_search($v['group_id'], $groupID);
                    if ($exists !== false) {
                        unset($groupID[$exists]);
                    }
                }
            }
        }

        $groupsIn = [];
        $now = $this->now();
        foreach ($groupID as $id) {
            $id = intval($id);
            if ($id <= 0) {
                continue;
            }
            $groupsIn[] = [
                'user_id'  => $userID,
                'group_id' => $id,
                'created'  => $now,
            ];
        }

        if (! empty($groupsIn)) {
            return $this->db->multiInsert(static::TABLE_USER_IN_GROUPS, $groupsIn);
        }

        return false;
    }

    /**
     * Исключаем пользователя из групп
     * @param int $userID ID пользователя
     * @param array|int|null $groupID ID групп или null(исключаем из всех групп)
     * @param bool $keywords true - указаны keyword'ы групп
     * @return mixed
     */
    public function userFromGroups($userID, $groupID = null, $keywords = false)
    {
        if (! empty($groupID)) {
            if (! is_array($groupID)) {
                $groupID = [$groupID];
            }

            # исключаем пользователя из указанных групп
            if ($keywords) {
                $groupID = $this->db->select_one_column('SELECT group_id
                        FROM ' . static::TABLE_USERS_GROUPS . '
                        WHERE ' . $this->db->prepareIN('keyword', $groupID, false, false, false));
                if (empty($groupID)) {
                    return 0;
                }
            }
            return $this->db->delete(static::TABLE_USER_IN_GROUPS, ['user_id' => $userID, 'group_id' => $groupID]);
        } else {
            # исключаем пользователя из всех групп
            return $this->db->delete(static::TABLE_USER_IN_GROUPS, ['user_id' => $userID]);
        }
    }

    /**
     * Исключаем нескольких пользователей из групп
     * @param array|int $usersID ID пользователей
     * @param array|null $groupID
     * @param bool $keywords true - указаны keyword'ы групп
     * @return mixed
     */
    public function usersFromGroups($usersID, $groupID = null, $keywords = false)
    {
        if (is_array($usersID)) {
            if (isset($groupID)) {
                if (!is_array($groupID)) {
                    $groupID = [$groupID];
                }

                # исключаем указанных пользователей из указанных групп
                if ($keywords) {
                    $groupID = $this->db->select_one_column('SELECT group_id
                        FROM ' . static::TABLE_USERS_GROUPS . '
                        WHERE ' . $this->db->prepareIN('keyword', $groupID, false, false, false));
                    if (empty($groupID)) {
                        return 0;
                    }
                }

                return $this->db->delete(static::TABLE_USER_IN_GROUPS, ['user_id' => $usersID, 'group_id' => $groupID]);
            } else {
                # исключаем указанных пользователей из всех групп в которые они входят
                return $this->db->delete(static::TABLE_USER_IN_GROUPS, ['user_id' => $usersID]);
            }
        }
        # исключаем одного пользователя из групп
        return $this->userFromGroups($usersID, $groupID);
    }

    /**
     * Является ли пользователь супер-администратором
     * @param int $userID ID пользователя
     * @return bool
     */
    public function userIsSuperAdmin($userID)
    {
        return ((bool)$this->db->one_data('SELECT UIG.group_id
           FROM ' . static::TABLE_USER_IN_GROUPS . ' UIG
           WHERE UIG.user_id  = :user_id AND UIG.group_id = :group_id
           LIMIT 1', [':user_id' => $userID, ':group_id' => static::GROUPID_SUPERADMIN]));
    }

    /**
     * Является ли пользователь администратором (входит ли хотя-бы в одну группу с разрешенным доступом в админ. панель)
     * @param int $userID ID пользователя
     * @return bool
     */
    public function userIsAdministrator($userID)
    {
        return ((bool)$this->db->one_data('SELECT UIG.group_id
           FROM ' . static::TABLE_USER_IN_GROUPS . ' UIG, ' . static::TABLE_USERS_GROUPS . ' G
           WHERE UIG.user_id  = :user_id AND UIG.group_id = G.group_id
             AND G.adminpanel = 1
           LIMIT 1', [':user_id' => $userID]));
    }

    /**
     * User group model
     * @param int|null $id
     * @param array $columns
     * @param array $with
     * @return \bff\modules\users\models\Group | \modules\users\models\Group | bff\db\illuminate\Model | array
     */
    public function group($id = null, $columns = ['*'], array $with = [])
    {
        $model = $this->model('Group');
        if (! empty($id)) {
            return $model->one($id, $columns, $with);
        }
        return $model;
    }

    /**
     * Получаем список групп пользователей
     * @param array|string|null $exceptGroup исключить группы с указанными keyword/id
     * @param bool $withoutAdminpanelAccess только группы без доступа в админ-панель
     * @return mixed
     */
    public function groups($exceptGroup = null, $withoutAdminpanelAccess = false)
    {
        $filter = [];
        if (! empty($exceptGroup)) {
            if (!is_array($exceptGroup)) {
                $exceptGroup = [$exceptGroup];
            }

            $exceptGroupID = [];
            foreach ($exceptGroup as $k => $group) {
                if (is_int($group)) {
                    $exceptGroupID[] = $group;
                    unset($exceptGroup[$k]);
                }
            }
            if (! empty($exceptGroupID)) {
                $filter[] = $this->db->prepareIN('G.group_id', $exceptGroupID, true, false, true);
            }
            if (! empty($exceptGroup)) {
                $filter[] = $this->db->prepareIN('G.keyword', $exceptGroup, true, false, false);
            }
        }

        if ($withoutAdminpanelAccess) {
            $filter[] = 'G.adminpanel = 0';
        }

        $data = $this->db->select('SELECT G.*
            FROM ' . static::TABLE_USERS_GROUPS . ' G
            ' . (!empty($filter) ? ' WHERE ' . join(' AND ', $filter) : '') . '
            ORDER BY G.group_id');

        if (empty($data)) {
            return [];
        }

        foreach ($data as &$v) {
            $v['title'] = json_decode($v['title'], true);
            if (! is_array($v['title'])) {
                $v['title'] = [];
            }
        } unset($v);

        return $data;
    }

    /**
     * Получаем список групп пользователя
     * @param int $userID ID пользователя
     * @param bool $onlyKeywords только keyword'ы групп
     * @param array $opts
     * @return array
     */
    public function userGroups($userID, $onlyKeywords = false, array $opts = [])
    {
        $opts = $this->defaults($opts, [
            'lang' => $this->locale->current(),
        ]);

        $data = $this->db->select('SELECT G.*
                  FROM ' . static::TABLE_USERS_GROUPS . ' G,
                       ' . static::TABLE_USER_IN_GROUPS . ' UIG
                  WHERE UIG.user_id = :id AND UIG.group_id = G.group_id
                  ORDER BY G.group_id ASC', [':id' => $userID]);
        if (empty($data)) {
            return [];
        }
        if ($onlyKeywords) {
            $keywords = [];
            foreach ($data as $group) {
                $keywords[intval($group['group_id'])] = $group['keyword'];
            }
            return $keywords;
        }
        foreach ($data as & $v) {
            $v['title'] = json_decode($v['title'], true);
            $v['title'] = $v['title'][$opts['lang']] ?? '';
        } unset($v);
        return $data;
    }

    /**
     * Получаем группы, в которых есть пользователи
     * @param bool $withAdminpanelAccess с доступом в админ-панель
     * @param string $transparentByKey ключ, по которому выполняем группировку результата
     * @return array|mixed
     */
    public function usersGroups($withAdminpanelAccess = false, $transparentByKey = 'user_id')
    {
        $lang = $this->locale->current();
        $data = $this->db->select('SELECT G.*, U.user_id
            FROM ' . static::TABLE_USERS . ' U,
                 ' . static::TABLE_USER_IN_GROUPS . ' UIG,
                 ' . static::TABLE_USERS_GROUPS . ' G
            WHERE ' . ($withAdminpanelAccess ? 'G.adminpanel=1 AND ' : '') . '
                UIG.group_id = G.group_id AND U.user_id = UIG.user_id
            ORDER BY G.group_id');

        foreach ($data as & $v) {
            $v['title'] = json_decode($v['title'], true);
            $v['title'] = $v['title'][$lang] ?? '';
        } unset($v);

        return (!empty($transparentByKey) ?
            func::array_transparent($data, $transparentByKey, false) :
            $data);
    }

    /**
     * Проверяем наличие группы по ключу
     * @param string $groupKeyword ключ группы
     * @param int $exceptGroupID ID группы, которую следует исключить из проверки
     * @return bool
     */
    public function groupKeywordExists($groupKeyword, $exceptGroupID = 0)
    {
        if (empty($groupKeyword)) {
            return true;
        }

        $bind = [':keyword' => $groupKeyword];
        if (! empty($exceptGroupID)) {
            $bind[':gid'] = $exceptGroupID;
        }

        return ((int)$this->db->one_data('SELECT group_id FROM ' . static::TABLE_USERS_GROUPS . '
               WHERE keyword = :keyword' . (!empty($exceptGroupID) ? ' AND group_id != :gid' : '') . '
               LIMIT 1', $bind) > 0);
    }

    /**
     * Создаем/обновляем группу
     * @param int $groupID ID группы или 0
     * @param array $data данные
     * @return bool|int
     */
    public function groupSave($groupID, array $data)
    {
        $data['modified'] = $this->now();
        if (is_array($data['title'])) {
            $data['title'] = json_encode($data['title'], JSON_UNESCAPED_UNICODE);
        }
        if ($groupID) {
            return $this->db->update(static::TABLE_USERS_GROUPS, $data, ['group_id' => $groupID]);
        }
        $data['created'] = $this->now();
        return $this->db->insert(static::TABLE_USERS_GROUPS, $data);
    }

    /**
     * Получаем данные о группе
     * @param int $groupID ID группы
     * @return array|mixed
     */
    public function groupData($groupID)
    {
        $data = $this->db->one_array(
            'SELECT * FROM ' . static::TABLE_USERS_GROUPS . ' WHERE group_id = :id LIMIT 1',
            [
            ':id' => $groupID,
            ]
        );

        if (! empty($data)) {
            $data['title'] = json_decode($data['title'], true);
            if (! is_array($data['title'])) {
                $data['title'] = [];
            }
        }

        return $data;
    }

    /**
     * Удаляем группу (исключаем пользователей состоящих в ней)
     * @param int $groupID ID группы
     * @return bool
     */
    public function groupDelete($groupID): bool
    {
        $res = $this->db->delete(static::TABLE_USERS_GROUPS, ['group_id' => $groupID]);
        if (! empty($res)) {
            $this->db->delete(static::TABLE_USERS_GROUPS_ACCESS, ['group_id' => $groupID]);
            $this->db->delete(static::TABLE_USER_IN_GROUPS, ['group_id' => $groupID]);
            return true;
        }
        return false;
    }

    /**
     * GroupAccess model
     * @param int|null $id
     * @param array $columns
     * @param array $with
     * @return \bff\modules\users\models\GroupAccess | \bff\db\illuminate\Model | array
     */
    public function groupAccess($id = null, $columns = ['*'], array $with = [])
    {
        $model = $this->model('GroupAccess');
        if (! empty($id)) {
            return $model->one($id, $columns, $with);
        }
        return $model;
    }

    /**
     * Groups permissions to check
     * @param int $userID
     * @param array|null $groupsID
     * @return array
     */
    public function userGroupsPermissions($userID, ?array $groupsID = null)
    {
        if (empty($userID)) {
            return [];
        }
        if (is_null($groupsID)) {
            $groupsID = $this->db->select_rows_column(static::TABLE_USER_IN_GROUPS, 'group_id', [
                'user_id' => $userID,
            ]);
        }
        if (empty($groupsID)) {
            return [];
        }

        $access = $this->groupAccess()
            ->where(['group_id' => $groupsID])
            ->get()
            ->toArray();
        if (empty($access)) {
            return [];
        }

        $result = [];
        foreach ($access as $v) {
            $result[ $v['module'] ][] = $v['scope'];
        }

        return $result;
    }

    /**
     * Сохраняем права доступа группы
     * @param int $groupID ID группы
     * @param array $permissions права доступа
     * @return void
     */
    public function groupAccessSave($groupID, $permissions)
    {
        $scope = $this->modulesScopesListing();
        $access = $this->groupAccess()
            ->where('group_id', $groupID)
            ->get()
            ->toArray();

        foreach ($permissions as $v) {
            if (empty($v['module']) || empty($v['scope'])) {
                continue;
            }
            foreach ($scope as $vv) {
                if ($vv['module'] != $v['module']) {
                    continue;
                }
                if ($vv['scope'] != $v['scope']) {
                    continue;
                }

                $exist = false;
                foreach ($access as $vvv) {
                    if ($vvv['module'] != $v['module']) {
                        continue;
                    }
                    if ($vvv['scope'] != $v['scope']) {
                        continue;
                    }
                    $exist = true;
                    break;
                }

                if ($exist) {
                    if (empty($v['allow'])) {
                        $this->groupAccess()
                            ->where([
                                'group_id' => $groupID,
                                'module' => $v['module'],
                                'scope' => $v['scope'],
                            ])
                            ->delete()
                        ;
                    }
                } else {
                    if (! empty($v['allow'])) {
                        $this->groupAccess()
                            ->fill([
                                'group_id' => $groupID,
                                'module' => $v['module'],
                                'scope' => $v['scope'],
                            ])
                            ->save();
                    }
                }
                break;
            }
        }
    }

    public function modulesScopesListing(array $filter = [])
    {
        $result = [];

        $modules = $this->app->getModulesList();

        # Init all modules + plugins (init all scopes)
        foreach ($modules as $k => $v) {
            $this->app->module($k);
        }
        $plugins = Dev::getPluginsList();

        $i = 1;
        $append = function ($pid, $scopes) use (&$result, &$i) {
            $pid['parent'] = 0;
            $p = false;
            foreach ($scopes as $v) {
                if (empty($v['id']) || mb_strpos($v['id'], ':') !== false) {
                    continue;
                }
                if ($p === false) {
                    $result[$i] = $pid;
                    $p = $i;
                    $i++;
                }
                $result[$i++] = [
                    'module' => $pid['module'],
                    'scope' => $v['id'],
                    'title' => $v['title'] ?? '',
                    'parent' => $p,
                ];
            }
        };

        foreach ($modules as $k => $v) {
            $module = $this->app->module($k);
            if (! $module) {
                continue;
            }
            $scopes = $module->accessScopes()->listing();
            if (empty($scopes)) {
                continue;
            }
            $title = $module->module_title ?? '';
            if (empty($title)) {
                $title = $k;
            }
            $append([
                'module' => $k,
                'scope' => $k,
                'title' => $title,
            ], $scopes);
        }

        foreach ($plugins as $k => $v) {
            /** @var \Plugin $v*/
            if (! $v || ! method_exists($v, 'accessScopes')) {
                continue;
            }
            $scopes = $v->accessScopes()->listing();
            if (empty($scopes)) {
                continue;
            }
            $title = $v->getTitle();
            if (empty($title)) {
                $title = $k;
            }
            $append([
                'module' => $k,
                'scope' => $k,
                'title' => $title,
            ], $scopes);
        }

        return $result;
    }

    /**
     * Ban model
     * @param int|null $id
     * @param array $columns
     * @param array $with
     * @return \bff\modules\users\models\Ban | \bff\db\illuminate\Model | array
     */
    public function ban($id = null, $columns = ['*'], array $with = [])
    {
        $model = $this->model('Ban');
        if (! empty($id)) {
            return $model->one($id, $columns, $with);
        }
        return $model;
    }

    /**
     * Создание бан-правила
     * @param string $mode Режим бана: user, ip, email
     * @param array|string $ban Бан-items
     * @param int $banPeriod Период бана или 0 - постоянный
     * @param string $banPeriodDate Период бана (дата, до которой банить)
     * @param int $exclude Исключение
     * @param string $description Описание бана
     * @param string $reason Причина бана (показываемая пользователю)
     * @return bool
     */
    public function banCreate($mode, $ban, $banPeriod, $banPeriodDate, $exclude = 0, $description = '', $reason = '')
    {
        # Удаляем просроченные баны
        $this->db->delete(static::TABLE_USERS_BANLIST, ['finished<' . time(), 'finished <> 0']);

        $ban = (!is_array($ban)) ? array_unique(explode(PHP_EOL, $ban)) : $ban;

        $currentTime = time();

        # Переводим $banEnd в unixtime. 0 - постоянный бан.
        if ($banPeriod) {
            if ($banPeriod != -1 || !$banPeriodDate) {
                $banEnd = max($currentTime, $currentTime + ($banPeriod) * 60);
            } else {
                $banPeriodDate = explode('-', $banPeriodDate);
                if (
                    sizeof($banPeriodDate) == 3 && ((int)$banPeriodDate[2] < 9999) &&
                    (strlen($banPeriodDate[2]) == 4) && (strlen($banPeriodDate[1]) == 2) && (strlen($banPeriodDate[0]) == 2)
                ) {
                    $banEnd = max($currentTime, gmmktime(0, 0, 0, (int)$banPeriodDate[1], (int)$banPeriodDate[0], (int)$banPeriodDate[2]));
                } else {
                    $this->errors->set(_t('users', 'The date must be in the format <kbd>DD-MM-YYYY</kbd>.'));
                    return false;
                }
            }
        } else {
            $banEnd = 0;
        }

        $banlistResult = [];

        switch ($mode) {
            case 'user':
            {
                $type = 'uid';

                $userLogins = [];
                foreach ($ban as $login) {
                    $login = trim($login);
                    if ($login !== '') {
                        if ($login === User::login()) {
                            $this->errors->set(_t('users', 'You cannot close access to yourself.'));
                        }
                        $userLogins[] = $login;
                    }
                }
                if (! sizeof($userLogins)) {
                    $this->errors->set(_t('users', 'Username not defined.'));
                    return false;
                }

                $result = $this->db->select_one_column('SELECT id FROM ' . static::TABLE_USERS . '
                    WHERE ' . $this->db->prepareIN('login', $userLogins, false, false) . '
                     AND id <> :userId', [':userId' => User::id()]);

                if (! empty($result)) {
                    foreach ($result as $uid) {
                        $banlistResult[] = (int)$uid;
                    }
                } else {
                    $this->errors->set(_t('users', 'The requested users do not exist.'));
                }
                unset($result);
            }
            break;
            case 'ip':
            {
                $type = 'ip';

                foreach ($ban as $banItem) {
                    if (preg_match('#^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})[ ]*\-[ ]*([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$#', trim($banItem), $ip_range_explode)) {
                        // This is an IP range
                        $ip_1_counter = $ip_range_explode[1];
                        $ip_1_end = $ip_range_explode[5];

                        while ($ip_1_counter <= $ip_1_end) {
                            $ip_2_counter = ($ip_1_counter == $ip_range_explode[1]) ? $ip_range_explode[2] : 0;
                            $ip_2_end = ($ip_1_counter < $ip_1_end) ? 254 : $ip_range_explode[6];

                            if ($ip_2_counter == 0 && $ip_2_end == 254) {
                                $ip_2_counter = 256;
                                $ip_2_fragment = 256;

                                $banlistResult[] = "$ip_1_counter.*";
                            }

                            while ($ip_2_counter <= $ip_2_end) {
                                $ip_3_counter = ($ip_2_counter == $ip_range_explode[2] && $ip_1_counter == $ip_range_explode[1]) ? $ip_range_explode[3] : 0;
                                $ip_3_end = ($ip_2_counter < $ip_2_end || $ip_1_counter < $ip_1_end) ? 254 : $ip_range_explode[7];

                                if ($ip_3_counter == 0 && $ip_3_end == 254) {
                                    $ip_3_counter = 256;
                                    $ip_3_fragment = 256;

                                    $banlistResult[] = "$ip_1_counter.$ip_2_counter.*";
                                }

                                while ($ip_3_counter <= $ip_3_end) {
                                    $ip_4_counter = ($ip_3_counter == $ip_range_explode[3] && $ip_2_counter == $ip_range_explode[2] && $ip_1_counter == $ip_range_explode[1]) ? $ip_range_explode[4] : 0;
                                    $ip_4_end = ($ip_3_counter < $ip_3_end || $ip_2_counter < $ip_2_end) ? 254 : $ip_range_explode[8];

                                    if ($ip_4_counter == 0 && $ip_4_end == 254) {
                                        $ip_4_counter = 256;
                                        $ip_4_fragment = 256;

                                        $banlistResult[] = "$ip_1_counter.$ip_2_counter.$ip_3_counter.*";
                                    }

                                    while ($ip_4_counter <= $ip_4_end) {
                                        $banlistResult[] = "$ip_1_counter.$ip_2_counter.$ip_3_counter.$ip_4_counter";
                                        $ip_4_counter++;
                                    }
                                    $ip_3_counter++;
                                }
                                $ip_2_counter++;
                            }
                            $ip_1_counter++;
                        }
                    } else {
                        if (preg_match('#^([0-9]{1,3})\.([0-9\*]{1,3})\.([0-9\*]{1,3})\.([0-9\*]{1,3})$#', trim($banItem)) || preg_match('#^[a-f0-9:]+\*?$#i', trim($banItem))) {
                            # Нормальный IP адрес
                            $banlistResult[] = trim($banItem);
                        } else {
                            if (preg_match('#^\*$#', trim($banItem))) {
                                # Баним все IP адреса
                                $banlistResult[] = '*';
                            } else {
                                if (preg_match('#^([\w\-_]\.?){2,}$#is', trim($banItem))) {
                                    # Имя хоста
                                    $ip_ary = gethostbynamel(trim($banItem));

                                    if (! empty($ip_ary)) {
                                        foreach ($ip_ary as $ip) {
                                            if ($ip) {
                                                if (mb_strlen($ip) > 40) {
                                                    continue;
                                                }

                                                $banlistResult[] = $ip;
                                            }
                                        }
                                    }
                                    $this->errors->set(_t('users', 'Could not determine the IP address of the specified host'));
                                } else {
                                    $this->errors->set(_t('users', 'No IP address or hostname specified'));
                                }
                            }
                        }
                    }
                }
            }
            break;
            case 'email':
            {
                $type = 'email';
                foreach ($ban as $email) {
                    $email = trim($email);
                    if (preg_match('#^.*?@*|(([a-z0-9\-]+\.)+([a-z]{2,3}))$#i', $email)) {
                        if (strlen($email) > 100) {
                            continue;
                        }
                        $banlistResult[] = $email;
                    }
                }
                if (sizeof($ban) == 0) {
                    $this->errors->set(_t('users', 'No valid email addresses found.'));
                    return false;
                }
            }
            break;
            default:
            {
                $this->errors->set(_t('users', 'Mode not specified.'));
                return false;
            }
        }

        # Fetch currently set bans of the specified type and exclude state. Prevent duplicate bans.
        $result = $this->db->select_one_column("SELECT $type
            FROM " . static::TABLE_USERS_BANLIST . '
            WHERE ' . ($type === 'uid' ? 'uid <> 0' : "$type <> ''") . '
                AND exclude = ' . (int)$exclude);

        if (! empty($result)) {
            $banlistResultTemp = [];
            foreach ($result as $item) {
                $banlistResultTemp[] = $item;
            }
            $banlistResult = array_unique(array_diff($banlistResult, $banlistResultTemp));
            unset($banlistResultTemp);
        }
        unset($result);

        # Есть что банить
        if (sizeof($banlistResult)) {
            $exclude = (int)$exclude;
            $banEnd = (int)$banEnd;
            $description = (string)$description;
            $reason = (string)$reason;

            $insert = [];
            foreach ($banlistResult as $banItem) {
                $insert[] = [
                    $type         => $banItem,
                    'started'     => $currentTime,
                    'finished'    => $banEnd,
                    'exclude'     => $exclude,
                    'description' => $description,
                    'reason'      => $reason,
                ];
            }

            $this->db->multiInsert(static::TABLE_USERS_BANLIST, $insert);

            return true;
        }

        return false;
    }

    /**
     * Удаляем правило бана
     * @param int|array $banID ID правила(правил)
     * @return void
     */
    public function banDelete($banID)
    {
        # удаляем просроченные баны
        $this->db->delete(static::TABLE_USERS_BANLIST, ['finished<' . time(), 'finished <> 0']);

        if (! is_array($banID)) {
            $banID = [$banID];
        }
        $banID = array_map('intval', $banID);
        if (sizeof($banID)) {
            $this->db->delete(static::TABLE_USERS_BANLIST, ['id' => $banID]);
        }
    }

    /**
     * Список правил бана
     * @return mixed
     */
    public function banListing()
    {
        return $this->db->select('
            SELECT B.*
            FROM ' . static::TABLE_USERS_BANLIST . ' B
            WHERE (B.finished >= ' . time() . ' OR B.finished = 0)
            ORDER BY B.ip, B.email');
    }
}