<?php
class user extends model
{
    /**
     * Extends identify a user with plain password function,
     * creates an auth token for user when authenticating with password.
     *
     * @param   string $account     the account
     * @param   string $password    md5 hash of the password, or a 64 byte string of padded token.
     * @access  public
     * @return  object|bool|string  if is valid user, return the user object.
     *                              if no valid user, return false.
     *                              if user is locked, return locked status as string.
     */
    public function identify($account, $password)
    {
        if(strlen($password) == 64) return $this->identifyWithToken($account, $password);
        $originPassword = $password;

        /* Calculate hash of $password$account if not using email as username. */
        $user = $this->loadModel('user')->identify($account, $password);
        if(empty($user))
        {
            if($this->loadModel('ldap') !== false)
            {
                $ldap = $this->ldap->getConfiguration();
                if(isset($ldap->enabled) && $ldap->enabled) $user = $this->userIdentifyWithLDAP($account, $originPassword);
            }
        }
        if(is_object($user))
        {
            /* User was logon with password, generate auth token. */
            $token = $this->getAuthToken($user->id, $this->app->input['device']);
            $user->token = $token->token;
        }
        return $user;
    }

    /**
     * Auth with username and token. Token is valid for around 30 seconds.
     *
     * @param  string             $account
     * @param  string             $token
     * @param  string             $device
     * @access public
     * @return object|bool|string
     */
    public function identifyWithToken($account, $token, $device = '')
    {
        $tokenAuthWindow = (int)zget($this->config->xuanxuan, 'tokenAuthWindow', 20);
        $now = (int)round(time() / $tokenAuthWindow);
        $token = substr($token, 0, 32); // Use first 32 chars of token.
        $userTokens = $this->dao->select('t1.*, t2.device, t2.token, t2.validUntil')->from(TABLE_USER)->alias('t1')
                ->leftJoin(TABLE_IM_USERDEVICE)->alias('t2')->on('t1.id = t2.user')
                ->where('t1.account')->eq($account)
                ->andWhere('t1.deleted')->eq('0')
                ->beginIF(!empty($device))->andWhere('t2.device')->eq($device)->fi()
                ->andWhere('t2.validUntil', true)->gt(helper::now())
                ->orWhere('t2.validUntil')->eq('0000-00-00 00:00:00')->markRight(1)
                ->fetchAll();

        if(empty($userTokens)) return 'invalid_token';

        foreach($userTokens as $userToken)
        {
            $authTokens = array();
            $authTokens[] = md5($userToken->account . $userToken->token . $now);
            $authTokens[] = md5($userToken->account . $userToken->token . ($now - 1));
            $authTokens[] = md5($userToken->account . $userToken->token . ($now + 1));

            if(in_array($token, $authTokens))
            {
                if($userToken->locked != '0000-00-00 00:00:00')
                {
                    $dateDiff = (strtotime($userToken->locked) - time()) / 60;
                    if($dateDiff > 0) return 'locked';
                }

                $tokenLifetime = zget($this->config->xuanxuan, 'tokenLifetime', 30);
                $tokenLifetime *= 24 * 60 * 60;
                if(strtotime($userToken->validUntil) - time() < $tokenLifetime / 3) $userToken->tokenNeedRenew = true;

                unset($userToken->password);
                unset($userToken->device);
                unset($userToken->token);
                unset($userToken->validUntil);
                return $userToken;
            }
        }

        return 'invalid_token';
    }

    /**
     * Auth with username and ldap password.
     * @param string $account
     * @param string $password
     * @return object|bool
     */
    public function userIdentifyWithLDAP($account, $password)
    {
        $ldapConfig = $this->loadModel('ldap')->getConfiguration();
        if(empty($ldapConfig) || empty($ldapConfig->enabled)) return false;

        $ldapConn = ldap_connect($ldapConfig->host, $ldapConfig->port);
        ldap_set_option($ldapConn, LDAP_OPT_PROTOCOL_VERSION, isset($ldapConfig->version) ? $ldapConfig->version : 3);
        ldap_set_option($ldapConn, LDAP_OPT_REFERRALS, 0);
        $ldapBind = @ldap_bind($ldapConn, $ldapConfig->admin, $ldapConfig->password);
        if(!$ldapBind)
        {
            $this->userUnbindLDAP($ldapConn, false);
            return false;
        }
        $searchList  = ldap_search($ldapConn, $ldapConfig->baseDN, "({$ldapConfig->account}=$account)");
        $infos       = ldap_get_entries($ldapConn, $searchList);
        if(!isset($infos[0]))
        {
            $this->userUnbindLDAP($ldapConn, $searchList);
            return false;
        }
        $info = $infos[0];
        if(empty($info['dn']))
        {
            $this->userUnbindLDAP($ldapConn, $searchList);
            return false;
        }
        $ldapBind = @ldap_bind($ldapConn, $info['dn'], $password);
        if(!$ldapBind)
        {
            $this->userUnbindLDAP($ldapConn, $searchList);
            return false;
        }
        $user = $this->loadModel('user')->getByAccount($account);
        if(empty($user))
        {
            if(!empty($ldapConfig->autoCreate))
            {
                $user           = new stdClass();
                $user->account  = $account;
                $user->password = $password;
                if(isset($info['mail'][0]))       $user->email    = $info['mail'][0];
                if(isset($info['mobile'][0]))     $user->mobile   = $info['mobile'][0];
                if(isset($info['name'][0]))       $user->realname = $info['name'][0];
                if(isset($info['postalcode'][0])) $user->zipcode  = $info['postalcode'][0];
                $result = $this->user->apiCreate($user, false);
                $this->userUnbindLDAP($ldapConn, $searchList);
                if($result) return $this->loadModel('user')->getByAccount($account);
                return false;
            }
            $this->userUnbindLDAP($ldapConn, $searchList);
            return false;
        }
        $this->userUnbindLDAP($ldapConn, $searchList);

        if($user->deleted == '0') return $user;
        return false;
    }

    /**
     * Free search result and unbind ldap.
     * @param \LDAP\Connection $ldapConn
     * @param \LDAP\Result|array|false $searchList
     * @return void
     */
    public function userUnbindLDAP($ldapConn, $searchList)
    {
        if(!empty($searchList)) ldap_free_result($searchList);
        if(!empty($ldapConn)) ldap_unbind($ldapConn);
    }


    /**
     * Get a user.
     *
     * @param  int    $id
     * @access public
     * @return object
     */
    public function getByID($id = 0)
    {
		$user = $this->dao->select('id, account, realname, avatar, role, dept, clientStatus, gender, email, mobile, phone,  qq, deleted')
			->from(TABLE_USER)
			->where('id')->eq($id)
			->fetch();
        if(!$user) return (object)array();

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

    /**
     * Get user list by id or account.
     *
     * @param  string $status
     * @param  array  $characters    can be an array of uids or accounts, single type only.
     * @param  bool   $idAsKey
     * @access public
     * @return array
     */
    public function getList($status = '', $characters = array(), $idAsKey = true)
    {
        $dao = $this->dao->select('id, account, realname, avatar, role, dept, clientStatus, gender, email, mobile, phone,  qq, deleted')
            ->from(TABLE_USER)
            ->where(1)
            ->beginIF(empty($characters))
            ->andWhere('deleted')->eq('0')
            ->fi()
            ->beginIF($status && $status == 'online')->andWhere('clientStatus')->ne('offline')->fi()
            ->beginIF($status && $status != 'online')->andWhere('clientStatus')->eq($status)->fi()
            ->beginIF($characters &&  is_numeric(current($characters)))->andWhere('id')->in($characters)->fi()
            ->beginIF($characters && !is_numeric(current($characters)))->andWhere('account')->in($characters)->fi();

        $users = $idAsKey ? $dao->fetchAll('id') : $dao->fetchAll();

        return $this->format($users);
    }

    /**
     * Get user id list by dept with pager and sort rule
     *
     * @param  string   $deptID
     * @param  array    $exclude
     * @param  object   $pager
     * @param  string   $orderBy
     * @param  boolean  $onlySelf   if true, exclude subdepts' members from result
     * @access public
     * @return array
     */
    public function getIDListByDept($deptID = 0, $exclude = array(), $pager = null, $orderBy = '', $onlySelf = false)
    {
        $depts = $deptID ? ($onlySelf ? array($deptID) : $this->loadModel('tree')->getFamily($deptID, 'dept')) : 0;
        return $this->dao->select('id')
            ->from(TABLE_USER)
            ->where('deleted')->eq('0')
            ->beginIF($deptID)->andWhere('dept')->in($depts)->fi() // Fetch all members if $deptID is 0.
            ->beginIF(!$deptID && $onlySelf)->andWhere('dept')->eq(0)->fi()
            ->beginIF(!empty($exclude))->andWhere('id')->notin($exclude)->fi()
            ->orderBy($orderBy)
            ->beginIF($pager)->page($pager)->fi()
            ->fetchPairs('id');
    }

    /**
     * Get user count.
     *
     * @access public
     * @return int
     */
    public function getCount()
    {
        return $this->dao->select('COUNT(*)')->from(TABLE_USER)->where('deleted')->eq('0')->fetch('COUNT(*)');
    }

    /**
     * Update a user.
     *
     * @param  object $user
     * @access public
     * @return object
     */
    public function update($user = null)
    {
        if(empty($user->id)) return null;

        $data = array();
        foreach($this->config->im->user->canEditFields as $field)
        {
            if(!empty($user->$field)) $data[$field] = $user->$field;
        }
        if(!empty($user->account) && !empty($user->password)) $data['password'] = $user->password;
        if(empty($data)) return null;

        $data['clientLang'] = $this->session->clientLang;
        $this->dao->update(TABLE_USER)->data($data)->where('id')->eq($user->id)->exec();
        return $this->getByID($user->id);
    }

    /**
     * Format users.
     *
     * @param  mixed  $users  object | array
     * @access public
     * @return object | array
     */
    public function format($users)
    {
        $isObject = false;
        if(is_object($users))
        {
            $isObject = true;
            $users    = array($users);
        }

        foreach($users as $user)
        {
            $user->id      = (int)$user->id;
            $user->dept    = (int)$user->dept;
            $user->deleted = isset($user->deleted) ? ((bool)$user->deleted ? 1 : 0) : 0;
            $user->status  = isset($user->clientStatus) ? $user->clientStatus : 0;

            if(isset($user->avatar))  $user->avatar  = (!empty($user->avatar) && substr($user->avatar, 0, 7) !== 'http://' && substr($user->avatar, 0, 8) !== 'https://') ? $this->loadModel('im')->getServer() . $user->avatar : $user->avatar;
            if(!isset($user->signed)) $user->signed  = 0;
        }

        if($isObject) return reset($users);

        return $users;
    }

    /**
     * Reset user status.
     *
     * @param  string $status
     * @access public
     * @return bool
     */
    public function resetStatus($status = 'offline')
    {
        $this->dao->update(TABLE_USER)->set('clientStatus')->eq($status)->exec();
        return !dao::isError();
    }

    /**
     * Set user status to offline.
     *
     * @param  array $users
     * @access public
     * @return bool
     */
    public function setOffline($users = array())
    {
        if(empty($users)) return true;
        $this->dao->update(TABLE_USER)->set('clientStatus')->eq('offline')->where('id')->in($users)->exec();
        return !dao::isError();
    }

    /**
     * Get list of users / depts that were created / edited / deleted in the last polling interval.
     *
     * @param  string $type  user | dept
     * @access public
     * @return array
     */
    public function hasChanges($type = 'user')
    {
        $timeStr = isset($this->config->xuanxuan->pollingInterval)
            ? "- {$this->config->xuanxuan->pollingInterval} seconds"
            : '- 60 seconds';

        return $this->dao->select('objectID')->from(TABLE_ACTION)
            ->where('objectType')->eq($type == 'dept' ? 'deptCategory' : 'user')
            ->andWhere('action')->in('create,edit,delete')
            ->andWhere('date')->gt(date(DT_DATETIME1, strtotime($timeStr)))
            ->fetchPairs();
    }

    /**
     * Update login or logout time of a user-device pair.
     *
     * @param  int    $user    userID
     * @param  string $device  device token
     * @param  string $type    login | logout
     * @access public
     * @return bool
     */
    public function updateDevice($user, $device, $type = 'logout')
    {
        $data = new stdclass();
        $data->user          = $user;
        $data->device        = $device;
        $data->{"last$type"} = helper::now();

        $stmt = $this->dao->insert(TABLE_IM_USERDEVICE)->data($data)->get();
        $stmt .= " ON DUPLICATE KEY UPDATE `last$type` = '{$data->{"last$type"}}'";

        $this->dao->exec($stmt);
        return !dao::isError();
    }

    /**
     * Get last logout time of user (on device).
     *
     * @param  int    $user
     * @param  string $device
     * @access public
     * @return string
     */
    public function getLastLogout($user, $device = '')
    {
        return $this->dao->select('MAX(lastLogout)')->from(TABLE_IM_USERDEVICE)
            ->where('user')->eq($user)
            ->beginIF(!empty($device))->andWhere('device')->eq($device)->fi()
            ->fetch('MAX(lastLogout)');
    }

    /**
     * Regenerate pinyin for users.
     *
     * @param  array  $users
     * @access public
     * @return bool
     */
    public function reindexPinyin($users = array())
    {
        $realnames = $this->dao->select('id,realname')->from(TABLE_USER)
                    ->beginIF(!empty($users))->where('id')->in($users)->fi()
                    ->fetchPairs();
        $converted = commonModel::convert2Pinyin($realnames);

        $pinyinData = array();
        foreach($realnames as $id => $realname) $pinyinData[] = "($id,'{$converted[$realname]}')";

        $query = "INSERT INTO " . TABLE_USER . "(`id`,`pinyin`) VALUES " . join(',', $pinyinData) . " ON DUPLICATE KEY UPDATE `pinyin`=VALUES(`pinyin`)";
        $this->dao->query($query);
        return !dao::isError();
    }

    /**
     * Search for user with account / realname / pinyin in group / dept.
     *
     * @param  string  $search
     * @param  object  $options    (object)array('chat' => '', 'dept' => '', 'limit' => 51, 'exclude' => [3, 5, 7])
     * @param  boolean $returnID
     * @access public
     * @return void
     */
    public function search($search, $options = array(), $returnID = false, $pager = null)
    {
        $depts       = array();
        $chatMembers = array();
        $exclude     = array();

        if(!is_object($options) && is_array($options)) $options = (object)$options;
        if(property_exists($options, 'dept') && $options->dept != 0)
        {
            $depts = $this->loadModel('tree')->getFamily($options->dept, 'dept');
        }
        if(property_exists($options, 'chat'))
        {
            $chat = $this->loadModel('im')->chatGetByGid($options->chat, true);
            $chatMembers = $chat->members;
        }
        if(property_exists($options, 'exclude')) $exclude = $options->exclude;

        $result = $this->dao->select($returnID ? 'id' : 'id, account, realname, avatar, role, dept, clientStatus, gender, email, mobile, phone,  qq, deleted')->from(TABLE_USER)
            ->where('deleted')->eq('0')
            ->beginIF(!empty($depts))->andWhere('dept')->in($depts)->fi()
            ->beginIF(!empty($chatMembers))->andWhere('id')->in($chatMembers)->fi()
            ->beginIF(!empty($exclude))->andWhere('id')->notin($exclude)->fi()
            ->andWhere('account', $markLeft = true)->like("%$search%")
            ->orWhere('pinyin')->like("%$search%")
            ->orWhere('realname')->like("%$search%")->markRight(1)
            ->beginIF($pager)->page($pager)->fi()
            ->beginIF(!$pager)->limit(isset($options->limit) ? $options->limit : 51)->fi()
            ->fetchAll();

        return $returnID ? array_map(function($obj){return (int)$obj->id;}, $result) : $this->format($result);
    }

    /**
     * Generate a 64 byte hex string as auth token using phpaes.
     *
     * @access private
     * @return string
     */
    function generateAuthToken()
    {
        $random = $this->app->loadClass('phpaes')->randomString(32);
        return bin2hex($random);
    }

    /**
     * Get or create an auth token for user's device, binds unused token automatically if there is one.
     *
     * @param  int         $userID
     * @param  string      $deviceType
     * @param  string      $deviceID
     * @access public
     * @return object|bool
     */
    public function getAuthToken($userID, $deviceType = '', $deviceID = '')
    {
        if(!empty($deviceType))
        {
            /* Try to fetch unused token and bond token of the device. */
            $userTokens = $this->dao->select('*')->from(TABLE_IM_USERDEVICE)
                    ->where('user')->eq($userID)
                    ->fetchAll();
            $userTokens = array_filter(
                $userTokens,
                function($userToken) use ($deviceType, $deviceID)
                {
                    return ($userToken->device == '' && $userToken->deviceID == '')
                        || !($userToken->device != $deviceType || !empty($deviceID) && $userToken->deviceID != $deviceID);
                }
            );

            if(!empty($userTokens))
            {
                /* If got both an unused token and a device auth token: */
                if(count($userTokens) > 1)
                {
                    $bondTokens = array_filter(
                        $userTokens,
                        function($userToken) use ($deviceType, $deviceID)
                        {
                            return $userToken->device == $deviceType
                                && (empty($deviceID) || !empty($deviceID) && $userToken->deviceID == $deviceID);
                        }
                    );
                    $bondToken = current($bondTokens);
                    if(strtotime($bondToken->validUntil) <= time()) $bondToken = $this->renewAuthToken($userID, $deviceType, $deviceID);
                    return $bondToken;
                }

                $userToken = current($userTokens);
                if(empty($userToken->token) || (!empty($userToken->validUntil) && strtotime($userToken->validUntil) <= time()))
                {
                    return $this->renewAuthToken($userID, $deviceType, $deviceID);
                }
                if($userToken->device != '') return $userToken;

                $this->dao->update(TABLE_IM_USERDEVICE)
                    ->set('device')->eq($deviceType)
                    ->set('deviceID')->eq($deviceID)
                    ->where('id')->eq($userToken->id)
                    ->exec();
                $userToken->device   = $deviceType;
                $userToken->deviceID = $deviceID;
                return $userToken;
            }
        }

        return $this->renewAuthToken($userID, $deviceType, $deviceID);
    }

    /**
     * Renew or just generate auth token for user's device.
     *
     * @param  int         $userID
     * @param  string      $deviceType
     * @param  string      $deviceID
     * @access public
     * @return object|bool
     */
    public function renewAuthToken($userID, $deviceType = '', $deviceID = '')
    {
        $authToken = $this->generateAuthToken();
        $tokenLifetime = zget($this->config->xuanxuan, 'tokenLifetime', 30);

        $userDevice = new stdclass();
        $userDevice->device     = $deviceType;
        $userDevice->deviceID   = $deviceID;
        $userDevice->user       = $userID;
        $userDevice->token      = $authToken;
        $userDevice->validUntil = date('Y-m-d H:i:s', strtotime("+ $tokenLifetime days"));

        $stmt = $this->dao->insert(TABLE_IM_USERDEVICE)->data($userDevice)->get();
        $stmt .= " ON DUPLICATE KEY UPDATE `deviceID` = '{$userDevice->deviceID}', `token` = '{$userDevice->token}', `validUntil` = '{$userDevice->validUntil}';"; // TODO: make deviceID persistent.
        $this->dao->exec($stmt);

        if(dao::isError()) return false;
        return $userDevice;
    }

    /**
     * Revoke user's auth token for specific device.
     *
     * @param  int    $userID
     * @param  string $deviceType
     * @param  string $deviceID
     * @access public
     * @return bool
     */
    public function revokeAuthToken($userID, $deviceType, $deviceID = '')
    {
        $this->dao->update(TABLE_IM_USERDEVICE)
            ->set('validUntil')->eq(helper::now())
            ->where('user')->eq($userID)
            ->andWhere('device')->eq($deviceType)
            ->beginIF(!empty($deviceID))->andWhere('deviceID')->eq($deviceID)->fi()
            ->exec();
        return !dao::isError();
    }

    /**
     * Get list of users who changed password but did not re-login.
     *
     * @access public
     * @return array
     */
    public function getChangedPassword()
    {
        $actionObjectIDs       = array();
        $passwordChangeActions = array();
        $loginHistoryActions   = array();
        $passwordChangeActions = $this->loadModel('action')->getListSinceLastPoll('changepassword');
        $loginHistoryActions   = $this->loadModel('action')->getListSinceLastPoll('loginxuanxuan');

        foreach($passwordChangeActions as $pAction)
        {
            $loginExist = false;
            foreach($loginHistoryActions as $lAction)
            {
                if ($pAction->objectID === $lAction->objectID)
                {
                    $loginExist = true;
                    if( strtotime($lAction->date) < strtotime($pAction->date))
                    {
                        $actionObjectIDs[] = (int)$pAction->objectID;
                    }
                }
            }
            if(!$loginExist)
            {
                $actionObjectIDs[] = (int)$pAction->objectID;
            }
        }

        return $actionObjectIDs;
    }

    /**
     * Get user ID list of deleted online users.
     *
     * @access public
     * @return array
     */
    public function getOnlineDeleted()
    {
        $userIDs = $this->dao->select('id')->from(TABLE_USER)
            ->where('deleted')->eq(1)
            ->andWhere('clientStatus')->ne('offline')
            ->fetchAll('id');
        if(dao::isError()) return array();
        return array_values(array_map(function($obj){return (int)$obj->id;}, $userIDs));
    }

    /**
     * Get user ID list of forbidden online users.
     *
     * @access public
     * @return array
     */
    public function getOnlineForbidden()
    {
        $userIDs = $this->dao->select('id')->from(TABLE_USER)
            ->where('locked')->ne('0000-00-00 00:00:00')
            ->andWhere('clientStatus')->ne('offline')
            ->fetchAll('id');
        if(dao::isError()) return array();
        return array_values(array_map(function($obj){return (int)$obj->id;}, $userIDs));
    }

    /**
     * Add user action.
     *
     * @param  int|string   $user       userID or user's account.
     * @param  string       $actionType
     * @param  string       $result
     * @param  string       $comment
     * @param  bool         $common
     * @access public
     * @return void
     */
    public function addAction($user, $actionType, $result, $comment = '', $common = false)
    {
        if(!zget($this->config->xuanxuan, 'logLevel', 1) && !$common) return;

        $account = '';
        $userID  = 0;
        if(is_int($user))
        {
            $account = $this->dao->select('account')->from(TABLE_USER)->where('id')->eq($user)->fetch('account');
            $userID = $user;
        }
        if(is_string($user))
        {
            $userID = $this->dao->select('id')->from(TABLE_USER)->where('account')->eq($user)->fetch('id');
            $account = $user;
        }

        $actor   = !empty($account) ? $account : '';
        $extra   = json_encode(array('actorId' => $userID));
        $this->loadModel('action')->create('user', $userID, $actionType, $result, $comment, $extra, $actor);
    }
}
