<?php

/**
 *
 * For code completion we add the available scenario's here
 * Attributes
 * @property int      $tid
 * @property string   $firstname
 * @property string   $lastname
 * @property string   $email
 * @property string   $emailstatus
 * @property string   $token
 * @property string   $language
 * @property string   $blacklisted
 * @property string   $sent
 * @property string   $remindersent
 * @property int      $remindercount
 * @property string   $completed
 * @property int      $usesleft
 * @property DateTime $validfrom
 * @property DateTime $validuntil
 *
 * Relations
 * @property Survey $survey The survey this token belongs to.
 *
 * Scopes
 * @method Token incomplete() incomplete() Select only uncompleted tokens
 * @method Token usable() usable() Select usable tokens: valid daterange and usesleft > 0
 *
 */

use LimeSurvey\PluginManager\PluginEvent;

/**
 * Class Token
 *
 * @property integer $tid Token ID
 * @property string $participant_id Participant ID
 * @property string $firstname Participant's first name
 * @property string $lastname Participant's last name
 * @property string $email Participant's e-mail address
 * @property string $emailstatus Participant's e-mail address status: OK/bounced/OptOut
 * @property string $token Participant's token
 * @property string $language Participant's language eg: en
 * @property string $blacklisted Whether participant is blocklisted: (Y/N)
 * @property string $sent
 * @property string $remindersent
 * @property integer $remindercount
 * @property string $completed Participant completed status (N:Not completed; Q:Locked with quota; 'YYYY-MM-DD hh:mm': date of completion)
 * @property integer $usesleft How many uses left to fill questionnaire for this participant
 * @property string $validfrom
 * @property string $validuntil
 * @property integer $mpid //TODO Describe me!
 *
 * @property Survey $survey
 * @property SurveyLink $surveylink
 * @property Response[] $responses
 * @property CDbTableSchema $tableSchema
 */
abstract class Token extends Dynamic
{
    /** @var int Maximum token length */
    const MAX_LENGTH = 36;

    /** @var int Default token length */
    const DEFAULT_LENGTH = 15;

    /**
     * Set defaults
     * @inheritdoc
     */
    public function init()
    {
        // Set the default values
        $this->usesleft = 1;
        $this->completed = "N";
        $this->emailstatus = "OK";
    }

    /** @inheritdoc */
    public function primaryKey()
    {
        return 'tid';
    }

    /** @inheritdoc */
    public function attributeLabels()
    {
        $labels = array(
            'tid' => gT('Access code ID'),
            'partcipant_id' => gT('Participant ID'),
            'firstname' => gT('First name'),
            'lastname' => gT('Last name'),
            'email' => gT('Email address'),
            'emailstatus' => gT('Email status'),
            'token' => gT('Access code'),
            'language' => gT('Language code'),
            'blacklisted' => gT('Blocklisted'),
            'sent' => gT('Invitation sent date'),
            'remindersent' => gT('Last reminder sent date'),
            'remindercount' => gT('Total numbers of sent reminders'),
            'completed' => gT('Completed'),
            'usesleft' => gT('Uses left'),
            'validfrom' => gT('Valid from'),
            'validuntil' => gT('Valid until'),
        );
        foreach (decodeTokenAttributes($this->survey->attributedescriptions ?? '') as $key => $info) {
            $labels[$key] = !empty($info['description']) ? $info['description'] : '';
        }
        return $labels;
    }

    /** @inheritdoc
     * Delete related SurveyLink if it's deleted
     */
    public function beforeDelete()
    {
        $result = parent::beforeDelete();
        if ($result && isset($this->surveylink)) {
            if (!$this->surveylink->delete()) {
                throw new CException('Could not delete survey link. Participant was not deleted.');
            }
            return true;
        }
        return $result;
    }

    /** @inheritdoc
     * Delete related SurveyLink at same time
     */
    public function deleteAllByAttributes($attributes, $condition = '', $params = array())
    {
        $builder = $this->getCommandBuilder();
        $participantCriteria = $builder->createCriteria($condition, $params);
        $participantCriteria->select = array('tid','participant_id');
        $participantCriteria->addCondition('participant_id is not null');
        $oParticipantToDelete = self::model($this->dynamicId)->findAll($participantCriteria);
        $result = parent::deleteAllByAttributes($attributes, $condition, $params);
        if ($result && !empty($oParticipantToDelete)) {
            /* Get the participant not deleted : we must not delete survey link */
            $oParticipantNotDeleted = self::model($this->dynamicId)->findAll($participantCriteria);
            $tidToDelete = array_diff(CHtml::listData($oParticipantToDelete, 'tid', 'tid'), CHtml::listData($oParticipantNotDeleted, 'tid', 'tid'));
            if (!empty($tidToDelete)) {
                SurveyLink::model()->deleteAllByAttributes(array('token_id' => $tidToDelete,'survey_id' => $this->dynamicId));
            }
        }
        return $result;
    }

    /**
     * @param integer $surveyId
     * @param array $extraFields
     * @return CDbTableSchema
     */
    public static function createTable($surveyId, array $extraFields = array())
    {
        $surveyId = intval($surveyId);

        // Specify case sensitive collations for the token
        $sCollation = '';
        if (Yii::app()->db->driverName == 'mysql' || Yii::app()->db->driverName == 'mysqli') {
            $sCollation = "COLLATE 'utf8mb4_bin'";
        }

        if (
            Yii::app()->db->driverName == 'sqlsrv'
            || Yii::app()->db->driverName == 'dblib'
            || Yii::app()->db->driverName == 'mssql'
        ) {
            $sCollation = "COLLATE SQL_Latin1_General_CP1_CS_AS";
        }
        $fields = array(
            'tid' => 'pk',
            'participant_id' => 'string(50)',
            'firstname' => 'text',
            'lastname' => 'text',
            'email' => 'text',
            'emailstatus' => 'text',
            'token' => "string(" . self::MAX_LENGTH . ") {$sCollation}",
            'language' => 'string(25)',
            'blacklisted' => 'string(17)',
            'sent' => "string(17) DEFAULT 'N'",
            'remindersent' => "string(17) DEFAULT 'N'",
            'remindercount' => 'integer DEFAULT 0',
            'completed' => "string(17) DEFAULT 'N'",
            'usesleft' => 'integer DEFAULT 1',
            'validfrom' => 'datetime',
            'validuntil' => 'datetime',
            'mpid' => 'integer'
        );

        foreach ($extraFields as $extraField) {
            $fields[$extraField] = 'text';
        }

        // create fields for the custom token attributes associated with this survey
        $oSurvey = Survey::model()->findByPk($surveyId);
        foreach ($oSurvey->tokenAttributes as $attrname => $attrdetails) {
            if (!isset($fields[$attrname])) {
                $fields[$attrname] = 'text';
            }
        }

        $db = \Yii::app()->db;
        $sTableName = $oSurvey->tokensTableName;

        try {
            $db->createCommand()->createTable($sTableName, $fields);
            /**
             * The random component in the index name is needed because Postgres is being the dorky kid and
             * complaining about duplicates when renaming the table and trying to use the same index again
             * on a new token table (for example on reactivation)
             */
            $db->createCommand()->createIndex("idx_token_token_{$surveyId}_" . rand(1, 50000), $sTableName, 'token');

            // MSSQL does not support indexes on text fields so not needed here
            switch (Yii::app()->db->driverName) {
                case 'mysql':
                case 'mysqli':
                    $db->createCommand()->createIndex('idx_email', $sTableName, 'email(30)', false);
                    break;
                case 'pgsql':
                    $db->createCommand()->createIndex('idx_email_' . $surveyId . '_' . rand(1, 50000), $sTableName, 'email', false);
                    break;
            }
        } catch (CDbException $ex) {
            //table already exists, skipping
        }

        // Refresh schema cache just in case the table existed in the past, and return if table exist
        return $db->schema->getTable($sTableName, true);
    }

    /**
     * @param string $token
     * @return Token
     */
    public function findByToken($token)
    {
        return $this->findByAttributes(array(
            'token' => $token
        ));
    }

    /**
     * Get survey token length from survey.
     * Use default if not possible.
     */
    public function getSurveyTokenLength()
    {
        // Use default token length
        $iTokenLength = self::DEFAULT_LENGTH;

        // Use survey token length, if defined
        if (isset($this->survey) && !empty($this->survey->oOptions) && !empty($this->survey->oOptions->tokenlength) && is_numeric($this->survey->oOptions->tokenlength)) {
            $iTokenLength = $this->survey->oOptions->tokenlength;
        }

        return $iTokenLength;
    }

    /**
     * Generates a token for this object.
     * @throws CHttpException
     */
    public function generateToken($iTokenLength = null)
    {
        if (empty($iTokenLength)) {
            $iTokenLength = $this->getSurveyTokenLength();
        }

        $this->token = $this->generateRandomToken($iTokenLength);
        $counter = 0;
        while (!$this->validate(array('token'))) {
            $this->token = $this->generateRandomToken($iTokenLength);
            $counter++;
            // This is extremely unlikely.
            if ($counter > 50) {
                throw new CHttpException(500, 'Failed to create unique access code in 50 attempts.');
            }
        }
    }

    /**
     * Creates a random token string without special characters
     *
     * @param integer $iTokenLength
     * @return string
     */
    private function generateRandomToken($iTokenLength)
    {
        $token = Yii::app()->securityManager->generateRandomString($iTokenLength);
        if ($token === false) {
            throw new CHttpException(500, gT('Failed to generate random string for token. Please check your configuration and ensure that the openssl or mcrypt extension is enabled.'));
        }
        $token = str_replace(array('~', '_'), array('a', 'z'), (string) $token);
        $event = new PluginEvent('afterGenerateToken');
        $event->set('surveyId', $this->getSurveyId());
        $event->set('iTokenLength', $iTokenLength);
        $event->set('oToken', $this);
        $event->set('token', $token);
        App()->pluginManager->dispatchEvent($event);
        $token = $event->get('token');
        return $token;
    }

    /**
     * Sanitize token show to the user (replace sanitize_helper sanitize_token)
     * @param string|null $token to sanitize
     * @return string sanitized token
     */
    public static function sanitizeToken($token)
    {
        if (is_null($token)) {
            return "";
        }
        // According to Yii doc : http://www.yiiframework.com/doc/api/1.1/CSecurityManager#generateRandomString-detail
        $pattern = Yii::app()->getConfig("allowedcharacters_pattern_token");
        return preg_replace($pattern, '', $token);
    }

    /**
     * Sanitize string for any attribute
     * @param string $attribute to sanitize
     * @return string sanitized attribute
     */
    public static function sanitizeAttribute($attribute)
    {
        // TODO: Use HTML Purifier?
        return filter_var($attribute, @FILTER_SANITIZE_STRING, FILTER_FLAG_NO_ENCODE_QUOTES);
    }

    /**
     * Generates a token for all token objects in this survey.
     * Syntax: Token::model(12345)->generateTokens();
     * @return integer[]
     * @throws Exception
     */
    public function generateTokens()
    {
        if ($this->scenario != '') {
            throw new \Exception("This function should only be called like: Token::model(12345)->generateTokens");
        }
        $surveyId = $this->dynamicId;
        $iTokenLength = $this->getSurveyTokenLength();

        $tkresult = Yii::app()->db->createCommand("SELECT tid FROM {{tokens_{$surveyId}}} WHERE token IS NULL OR token=''")->queryAll();
        //Exit early if there are not empty tokens
        if (count($tkresult) === 0) {
            return array(0, 0);
        }

        // Do NOT replace the following select with ActiveRecord as it uses too much memory
        $ntresult = Yii::app()->db->createCommand()->select('token')->from($this->tableName())-> where("token IS NOT NULL and token<>''")->queryColumn();
        // select all existing tokens
        foreach ($ntresult as $tkrow) {
            $existingtokens[$tkrow] = true;
        }
        $newtokencount = 0;
        $invalidtokencount = 0;
        $newtoken = null;
        foreach ($tkresult as $tkrow) {
            $bIsValidToken = false;
            while ($bIsValidToken == false && $invalidtokencount < 50) {
                $newtoken = $this->generateRandomToken($iTokenLength);
                if (!isset($existingtokens[$newtoken])) {
                    $existingtokens[$newtoken] = true;
                    $bIsValidToken = true;
                    $invalidtokencount = 0;
                } else {
                    $invalidtokencount++;
                }
            }
            if ($bIsValidToken) {
                $this->updateByPk($tkrow['tid'], array('token' => $newtoken));
                $newtokencount++;
            } else {
                break;
            }
        }

        return array($newtokencount, count($tkresult));
    }
    /**
     * @inheritdoc
     * @return Token
     */
    public static function model($className = null)
    {
        /** @var self $model */
        $model = parent::model($className);
        return $model;
    }

    /**
     * @param int $id Survey id in this class
     * @param string $scenario
     * @return Token Description
     */
    public static function create($id, $scenario = 'insert')
    {
        return parent::create($id, $scenario);
    }

    public function relations()
    {
        $result = array(
            'responses' => array(self::HAS_MANY, 'Response_' . $this->dynamicId, array('token' => 'token')),
            'survey' =>  array(self::BELONGS_TO, 'Survey', '', 'on' => "sid = {$this->dynamicId}"),
            'surveylink' => array(self::BELONGS_TO, 'SurveyLink', array('participant_id' => 'participant_id'), 'on' => "survey_id = {$this->dynamicId}")
        );
        return $result;
    }

    /** @inheritdoc */
    public function rules()
    {
        $aRules = array(
            array('token', 'unique', 'allowEmpty' => true),
            array('token', 'length', 'min' => 0, 'max' => 36),
            array('token', 'filter', 'filter' => array(self::class, 'sanitizeToken')),
            array('firstname', 'filter', 'filter' => array(self::class, 'sanitizeAttribute')),
            array('lastname', 'filter', 'filter' => array(self::class, 'sanitizeAttribute')),
            array('language', 'LSYii_Validators', 'isLanguage' => true),
            array('language','in','range' => array_merge(array($this->survey->language), explode(' ', $this->survey->additional_languages)),'allowEmpty' => true,'message' => gT('Language code is invalid in this survey')),
            array(implode(',', $this->tableSchema->columnNames), 'safe'),
            /* pseudo date : force date or specific string ? */
            array('remindersent', 'length', 'min' => 0, 'max' => 17),
            array('remindersent', 'filter', 'filter' => array(self::class, 'sanitizeAttribute')),
            array('completed', 'length', 'min' => 0, 'max' => 17),
            array('remindersent', 'filter', 'filter' => array(self::class, 'sanitizeAttribute')),
            array('remindercount', 'numerical', 'integerOnly' => true, 'allowEmpty' => true),
            array('email', 'LSYii_FilterValidator', 'filter' => 'trim', 'skipOnEmpty' => true),
            array('email', 'LSYii_EmailIDNAValidator', 'allowEmpty' => true, 'allowMultiple' => true, 'except' => 'allowinvalidemail'),
            array('emailstatus', 'default', 'value' => 'OK'),
            array('emailstatus', 'filter', 'filter' => array(self::class, 'sanitizeAttribute')),
            array('usesleft', 'numerical', 'integerOnly' => true, 'allowEmpty' => true, 'min' => -2147483647, 'max' => 2147483647),
            array('mpid', 'numerical', 'integerOnly' => true, 'allowEmpty' => true),
            array('blacklisted', 'in', 'range' => array('Y', 'N'), 'allowEmpty' => true),
            array('validfrom', 'date','format' => ['yyyy-M-d H:m:s.???','yyyy-M-d H:m:s','yyyy-M-d H:m','yyyy-M-d'],'allowEmpty' => true),
            array('validuntil','date','format' => ['yyyy-M-d H:m:s.???','yyyy-M-d H:m:s','yyyy-M-d H:m','yyyy-M-d'],'allowEmpty' => true),
        );
        foreach (decodeTokenAttributes($this->survey->attributedescriptions ?? '') as $key => $info) {
            $aRules[] = array(
                $key, 'filter',
                'filter' => array(self::class, 'sanitizeAttribute'),
                'on' => 'register'
            );
            $aRules[] = array(
                $key,
                'LSYii_Validators',
                'except' => 'finalSubmit,register'
            );
        }
        return $aRules;
    }

    /** @inheritdoc */
    public function scopes()
    {
        $now = dateShift(date("Y-m-d H:i:s"), "Y-m-d H:i:s", Yii::app()->getConfig("timeadjust"));
        return array(
            'incomplete' => array(
                'condition' => "completed = 'N'"
            ),
            'usable' => array(
                'condition' => "COALESCE(validuntil, '$now') >= '$now' AND COALESCE(validfrom, '$now') <= '$now' AND usesleft > 0"
            ),
            'editable' => array(
                'condition' => "COALESCE(validuntil, '$now') >= '$now' AND COALESCE(validfrom, '$now') <= '$now'"
            ),
            'empty' => array(
                'condition' => 'token is null or token = ""'
            )
        );
    }

    /**
     * @return CDbDataReader|mixed
     */
    public function summary()
    {
        $criteria = $this->getDbCriteria();
        $criteria->select = array(
            "COUNT(*) as count",
            "COUNT(CASE WHEN (token IS NULL OR token='') THEN 1 ELSE NULL END) as invalid",
            "COUNT(CASE WHEN (sent!='N' AND sent<>'') THEN 1 ELSE NULL END) as sent",
            "COUNT(CASE WHEN (emailstatus LIKE 'OptOut%') THEN 1 ELSE NULL END) as optout",
            "COUNT(CASE WHEN (completed!='N' and completed<>'') THEN 1 ELSE NULL END) as completed",
            "COUNT(CASE WHEN (completed='Q') THEN 1 ELSE NULL END) as screenout",
        );
        $command = $this->getCommandBuilder()->createFindCommand($this->getTableSchema(), $criteria);
        return $command->queryRow();
    }

    /** @inheritdoc */
    public function tableName()
    {
        return '{{tokens_' . $this->dynamicId . '}}';
    }

    /**
     * Get current surveyId for other model/function
     * @return int
     */
    public function getSurveyId()
    {
        return $this->getDynamicId();
    }

    public static function getDefaultEncryptionOptions()
    {
        $sEncrypted = 'N';
        return array(
                'enabled' => 'N',
                'columns' => array(
                    'firstname' =>  $sEncrypted,
                    'lastname' =>  $sEncrypted,
                    'email' =>  $sEncrypted
                )
        );
    }

    public function onBeforeSave($event)
    {
        // Mark token as "OptOut" if globally blocklisted and 'blacklistnewsurveys' is enabled
        if (Yii::app()->getConfig('blacklistnewsurveys') == "Y" && $this->getIsNewRecord()) {
            $blacklistHandler = new LimeSurvey\Models\Services\ParticipantBlacklistHandler();
            if ($blacklistHandler->isTokenBlacklisted($this)) {
                $this->emailstatus = "OptOut";
            }
        }
        return parent::onBeforeSave($event);
    }
}
