<?php
/**
 * Created by PhpStorm.
 * User: crose
 * Date: 3/13/17
 * Time: 9:29 PM
 */

namespace IMATHUZH\Qfq\Core\Form;

use IMATHUZH\Qfq\Core\Database\Database;
use IMATHUZH\Qfq\Core\Helper\KeyValueStringParser;
use IMATHUZH\Qfq\Core\Helper\OnArray;
use IMATHUZH\Qfq\Core\Helper\Support;
use IMATHUZH\Qfq\Core\Store\Client;
use IMATHUZH\Qfq\Core\Store\Session;
use IMATHUZH\Qfq\Core\Store\Sip;
use IMATHUZH\Qfq\Core\Store\Store;

/**
 * Class Dirty
 * Process Record locking in mode DIRTY_MODE_ADVISORY, DIRTY_MODE_EXCLUSIVE or DIRTY_MODE_NONE.
 * Two entry points: process() and checkDirtyAndRelease().
 * Check doc/diagram/*.png for detailed workflow.
 *
 * @package qfq
 */
class Dirty {

    /**
     * @var Database[] - Array of Database instantiated class
     */
    protected $dbArray = null;

    private $dbIndexData = false;
    private $dbIndexQfq = false;

    /**
     * @var array
     */
    protected $client = array();

    /**
     * @var Session
     */
    private $session = null;

    /**
     * @var Store
     */
    private $store = null;

    /**
     * Init class
     * @param bool $phpUnit
     * @param bool $dbIndexData
     * @param bool $dbIndexQfq
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function __construct($phpUnit = false, $dbIndexData = false, $dbIndexQfq = false) {

        #TODO: rewrite $phpUnit to: "if (!defined('PHPUNIT_QFQ')) {...}"
        $this->session = Session::getInstance($phpUnit);
        $this->client = Client::getParam();
        if (!isset($this->client[DIRTY_RECORD_HASH_MD5])) {
            $this->client[DIRTY_RECORD_HASH_MD5] = '';
        }
        $this->doDbArray($dbIndexData, $dbIndexQfq);
        $this->store = Store::getInstance();
    }

    /**
     * @param $dbIndexData
     * @param $dbIndexQfq
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function doDbArray($dbIndexData, $dbIndexQfq) {

        if ($dbIndexData !== false) {
            $this->dbArray[$dbIndexData] = new Database($dbIndexData);
            $this->dbIndexData = $dbIndexData;
        }

        if ($dbIndexQfq !== false) {
            if ($dbIndexQfq != $dbIndexData) {
                $this->dbArray[$dbIndexQfq] = new Database($dbIndexQfq);
            }
            $this->dbIndexQfq = $dbIndexQfq;
        }

    }

    /**
     * Handle any lock requests submitted via api/dirty.php.
     *
     * @return array|int
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function process() {

        $sipClass = new Sip();

        $sipVars = $sipClass->getVarsFromSip($this->client[SIP_SIP] ?? '');

        if (empty($sipVars[SIP_FORM])) {
            throw new \CodeException("Missing 'form' in SIP. There might be something broken.", ERROR_DIRTY_MISSING_FORM_IN_SIP);
        }

        $recordId = empty($sipVars[SIP_RECORD_ID]) ? 0 : $sipVars[SIP_RECORD_ID];
        if ($recordId == 0) {
            // For r=0 (new) , 'dirty' will always succeed.
            return [API_STATUS => API_ANSWER_STATUS_SUCCESS, API_MESSAGE => ''];
        }

        $this->dbIndexQfq = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM);

        $this->dbIndexData = empty($sipVars[PARAM_DB_INDEX_DATA]) ? $this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM) : $sipVars[PARAM_DB_INDEX_DATA];
        $this->doDbArray($this->dbIndexData, $this->dbIndexQfq);

        $tableVars = $this->dbArray[$this->dbIndexQfq]->sql("SELECT `tableName`, `primaryKey`, `dirtyMode`, `recordLockTimeoutSeconds`, `parameter` FROM `Form` WHERE `name`=?", ROW_EXPECT_1, [$sipVars[SIP_FORM]], "Form not found: '" . $sipVars[SIP_FORM] . "'");
        if (empty($tableVars[F_PRIMARY_KEY])) {
            $tableVars[F_PRIMARY_KEY] = F_PRIMARY_KEY_DEFAULT;
        }

        // If the SIP doesn't provide a dbIndex, check the form's parameter field for a custom dbIndex.
        if (empty($sipVars[PARAM_DB_INDEX_DATA]) && !empty($tableVars[F_PARAMETER])) {
            $formParams = KeyValueStringParser::parse($tableVars[F_PARAMETER], '=', "\n");
            if (!empty($formParams[F_DB_INDEX])) {
                $this->dbIndexData = $formParams[F_DB_INDEX];
                $this->doDbArray($this->dbIndexData, $this->dbIndexQfq);
            }
        }

        switch ($this->client[API_LOCK_ACTION]) {
            case API_LOCK_ACTION_LOCK:
            case API_LOCK_ACTION_EXTEND:
                $answer = $this->acquireDirty($recordId, $tableVars, $this->client[DIRTY_RECORD_HASH_MD5], $this->client[TAB_UNIQ_ID]);
                break;
            case API_LOCK_ACTION_RELEASE:
                $answer = $this->checkDirtyAndRelease(FORM_SAVE, $tableVars[F_RECORD_LOCK_TIMEOUT_SECONDS], $tableVars[F_DIRTY_MODE], $tableVars[F_TABLE_NAME], $tableVars[F_PRIMARY_KEY], $recordId);
                break;
            case API_LOCK_ACTION_CHECK:
                $rcRecordDirty = array();
                $rcMsg = '';
                $rc = $this->getCheckDirty($tableVars[F_TABLE_NAME], $recordId, $rcRecordDirty, $rcMsg);
                $answer = ($rc === LOCK_FOUND_CONFLICT) ? [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => $rcMsg] : [API_STATUS => API_ANSWER_STATUS_SUCCESS, API_MESSAGE => ''];
                $answer = ($rc === LOCK_FOUND_OWNER) ? [API_STATUS => API_ANSWER_STATUS_SUCCESS, API_MESSAGE => $rcMsg, DIRTY_API_SAME_USER => true] : $answer;
                break;
            default;
                throw new \CodeException("Unknown action: " . $this->client[API_LOCK_ACTION], ERROR_DIRTY_UNKNOWN_ACTION);
        }

        return $answer;
    }

    /**
     * Tries to get a lock ('dirty record'). Returns an array (becomes JSON) about success or failure.
     *
     * @param int $recordId
     * @param array $tableVars
     * @param string $recordHashMd5
     *
     * @param $tabUniqId
     * @return array
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function acquireDirty($recordId, array $tableVars, $recordHashMd5, $tabUniqId) {

        $tableName = $tableVars[F_TABLE_NAME];
        $primaryKey = $tableVars[F_PRIMARY_KEY];
        $formDirtyMode = $tableVars[F_DIRTY_MODE];
        $rcMd5 = '';

        // Check for changed record. Compute $rcMd5 (skip for dirtyMode 'none' as it queries the data DB).
        if ($formDirtyMode != DIRTY_MODE_NONE) {
            $flagModified = $this->isRecordModified($tableName, $primaryKey, $recordId, $recordHashMd5, $rcMd5);
            if (($recordHashMd5 != '') && $flagModified) {
                return [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => 'The record has been modified in the meantime. Please reload the form, edit and save again. [1]'];
            }
        }

        $feUser = $this->session->get(SESSION_FE_USER);

        // Look for already existing dirty record.
        $recordDirty = $this->getRecordDirty($tableName, $recordId);

        if (count($recordDirty) == 0) {
            if ($formDirtyMode == DIRTY_MODE_NONE) {
                $answer = [API_STATUS => API_ANSWER_STATUS_SUCCESS, API_MESSAGE => ''];
            } else {
                // No dirty record found: create lock
                $answer = $this->writeDirty($this->client[SIP_SIP], $recordId, $tableVars, $feUser, $rcMd5, $tabUniqId);
            }
        } else {
            if ($tabUniqId == $recordDirty[TAB_UNIQ_ID]) {
                // In case it's the same tab (page reload): OK
                $answer = [API_STATUS => API_ANSWER_STATUS_SUCCESS, API_MESSAGE => '', API_LOCK_TIMEOUT => $tableVars[F_RECORD_LOCK_TIMEOUT_SECONDS]];
            } else {
                // Here is probably a conflict.
                $answer = $this->conflict($recordDirty, $formDirtyMode, $primaryKey);
            }
        }

        return $answer;
    }


    /**
     * Load (if exist) a DirtyRecord (lock).
     *
     * @param string $tableName
     * @param int $recordId
     *
     * @return array   DirtyRecord or empty array.
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function getRecordDirty($tableName, $recordId) {

        // There might be stale locks exist (no clean shutdown): just respect the latest lock.
        $recordDirty = $this->dbArray[$this->dbIndexQfq]->sql("SELECT * FROM " . TABLE_NAME_DIRTY . " AS d WHERE `d`.`tableName` LIKE ? AND `recordId`=? ORDER BY id DESC LIMIT 1",
            ROW_EXPECT_0_1, [$tableName, $recordId]);

        // Check if the record is timed out - owner doesn't matter.
        if (isset($recordDirty[DIRTY_EXPIRE]) && $recordDirty[DIRTY_EXPIRE] < date('Y-m-d H:i:s')) {
            $this->deleteDirtyRecord($recordDirty[COLUMN_ID]);
            $recordDirty = array();
        }

        return $recordDirty;
    }

    /**
     * Aquire lock conflict detected
     *
     * @param array $recordDirty
     * @param string $currentFormDirtyMode
     *
     * @param $primaryKey
     * @return array
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function conflict(array $recordDirty, $currentFormDirtyMode, $primaryKey) {
        $status = API_ANSWER_STATUS_CONFLICT;
        $until = "until " . date_format(date_create($recordDirty[COLUMN_EXPIRE]), "d.m.Y H:i:s");

        // Compare modified timestamp: in case there is a lock conflict and current form is based on outdated data: force reload.
        if ($this->isRecordModified($recordDirty[DIRTY_TABLE_NAME], $primaryKey, $recordDirty[DIRTY_RECORD_ID], $recordDirty[DIRTY_RECORD_HASH_MD5], $dummy)) {
            return [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => 'The record has been modified in the meantime (your changes are lost). Please reload the form, edit and save again.'];
        }

        // Conflict for same user / same QFQ Session: the user can force aquire lock.
        // Hint: after this, the form with the first lock still thinks it has the lock - that one will get a 'record modified in the meantime' on save.
        $userMatch = ($recordDirty[DIRTY_FE_USER] != '' && $recordDirty[DIRTY_FE_USER] == $this->store->getVar(TYPO3_FE_USER, STORE_TYPO3));
        if ($userMatch || $this->client[CLIENT_COOKIE_QFQ] == $recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE]) {

            $msg = "Record already locked (by you)";
            $status = API_ANSWER_STATUS_CONFLICT_ALLOW_FORCE;
        } else {

            if (empty($recordDirty[DIRTY_FE_USER])) {
                $msgUser = "another user";
            } else {
                $msgUser = "user '" . $recordDirty[DIRTY_FE_USER] . "'";
            }

            $msg = "Record already locked by $msgUser $until.";

            // Mandatory lock on Record or current Form?
            if ($recordDirty[F_DIRTY_MODE] == DIRTY_MODE_EXCLUSIVE || $currentFormDirtyMode == DIRTY_MODE_EXCLUSIVE) {
                $status = API_ANSWER_STATUS_CONFLICT;
            } else {
                $status = API_ANSWER_STATUS_CONFLICT_ALLOW_FORCE;
            }
        }

        return [API_STATUS => $status, API_MESSAGE => $msg];
    }

    /**
     * Write a 'Dirty'-Record.
     *
     * @param string $s SIP given by URL GET
     * @param int $recordId extracted from SIP
     * @param array $tableVars columns: F_TABLE_NAME, F_DIRTY_MODE, F_RECORD_LOCK_TIMEOUT_SECONDS
     * @param string $feUser
     * @param string $recordHashMd5
     *
     * @param $tabUniqId
     * @return array
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function writeDirty($s, $recordId, array $tableVars, $feUser, $recordHashMd5, $tabUniqId) {

        $tableName = $tableVars[F_TABLE_NAME];
        $primaryKey = $tableVars[F_PRIMARY_KEY];
        $formDirtyMode = $tableVars[F_DIRTY_MODE];

        $record = $this->dbArray[$this->dbIndexData]->sql("SELECT * FROM `$tableName` WHERE `$primaryKey`=?", ROW_EXPECT_1, [$recordId], "Record to lock not found. "
            . (FEATURE_FORM_FILE_SYNC ? FormAsFile::errorHintFormImport($tableName) : ''));

        # Dirty workaround: setting the 'expired timestamp' minus 1 second guarantees that the client ask for relock always if the timeout is expired.
        $expire = date('Y-m-d H:i:s', strtotime("+" . $tableVars[F_RECORD_LOCK_TIMEOUT_SECONDS] - 1 . " seconds"));
        // Write 'dirty' record

        $userAgent = $this->store->getVar(CLIENT_HTTP_USER_AGENT, STORE_CLIENT, SANITIZE_ALLOW_ALNUMX);
        $this->dbArray[$this->dbIndexQfq]->sql("INSERT INTO " . TABLE_NAME_DIRTY . " (`sip`, `tableName`, `recordId`, `expire`, `recordHashMd5`, `tabUniqId`, `feUser`, `qfqUserSessionCookie`, `dirtyMode`, `remoteAddress`, `created`) " .
            "VALUES ( ?,?,?,?,?,?,?,?,?,?,? )", ROW_REGULAR,
            [$s, $tableName, $recordId, $expire, $recordHashMd5, $tabUniqId, $feUser, $this->client[CLIENT_COOKIE_QFQ], $formDirtyMode,
                $this->client[CLIENT_REMOTE_ADDRESS], date('YmdHis')]);

        return [API_STATUS => API_ANSWER_STATUS_SUCCESS, API_MESSAGE => '',
            API_LOCK_TIMEOUT => $tableVars[F_RECORD_LOCK_TIMEOUT_SECONDS]];

    }

    /**
     * Get MD5 from tableName/recordId and compare with $recordHashMd5.
     *
     * @param string $tableName
     * @param string $primaryKey
     * @param int $recordId
     * @param string $recordHashMd5 - timestamp e.g. '2017-07-27 14:06:56'
     * @param $rcMd5
     * @return bool true if $recordHashMd5 is different from current record md5 hash.
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function isRecordModified($tableName, $primaryKey, $recordId, $recordHashMd5, &$rcMd5) {

        if ($recordHashMd5 == '') {
            return false; // If there is no recordHashMd5, the check is not possible. Always return 'not modified' (=ok)
        }

        $record = $this->dbArray[$this->dbIndexData]->sql("SELECT * FROM `$tableName` WHERE `$primaryKey`=?", ROW_EXPECT_1, [$recordId], "Record to lock not found. "
            . (FEATURE_FORM_FILE_SYNC ? FormAsFile::errorHintFormImport($tableName) : ''));

        if (isset($record[F_FILE_STATS])) {
            // why: The column "fileStats" in the Form table is modified when a form is exported to a file but nothing else changes.
            unset($record[F_FILE_STATS]);
        }

        $rcMd5 = OnArray::getMd5($record);

        return ($recordHashMd5 != $rcMd5);
    }

    /**
     * Check if a lock exist for the current table, recordId and session.
     *
     * @param string $tableName
     * @param int $recordId
     * @param array $recordDirty - return dirty record if one exist.
     * @param string $msg - return preformatted message in case of conflict
     *
     * @return int LOCK_NOT_FOUND | LOCK_FOUND_OWNER | LOCK_FOUND_CONFLICT,
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    public function getCheckDirty($tableName, $recordId, array &$recordDirty, &$msg) {

        $msg = '';

        if ($recordId == 0) {
            return LOCK_NOT_FOUND; // New records never have a recordDirty nor a conflict.
        }

        $recordDirty = $this->getRecordDirty($tableName, $recordId);

        if (empty($recordDirty)) {
            return LOCK_NOT_FOUND;
        }

        // 'Reload Tab' don't send a tab ID - Possible lock conflict will be skipped here and a) pops up later, or b) is not a conflict if it's the same tab.
        if (!isset($this->client[TAB_UNIQ_ID]) && $recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE] == $this->client[CLIENT_COOKIE_QFQ]) {
            return LOCK_NOT_FOUND;
        }

        $msgUser = (empty($recordDirty[DIRTY_FE_USER])) ? "another user" : "user '" . $recordDirty[DIRTY_FE_USER] . "'";
        $rc = LOCK_FOUND_CONFLICT;

        if (empty($recordDirty[DIRTY_FE_USER])) {
            $userMatch = ($this->client[CLIENT_COOKIE_QFQ] == $recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE]);
        } else {
            $userMatch = ($recordDirty[DIRTY_FE_USER] == $this->store->getVar(TYPO3_FE_USER, STORE_TYPO3));
        }

        if ($userMatch) {
            $msgUser = "you";
            $rc = LOCK_FOUND_OWNER;
        }
        $until = "until " . date_format(date_create($recordDirty[COLUMN_EXPIRE]), "d.m.Y H:i:s");
        $msg = "The record has been locked by $msgUser $until.";

        return $rc;
    }

    /**
     * Release a dirtyRecord. This is only possible if the current user owns the dirtyRecord.
     * In case of not owner, throws an exception and the save should break.
     *
     * @param string $formMode FORM_DELETE, FORM_SAVE
     * @param int $lockTimeout
     * @param string $dirtyMode DIRTY_MODE_EXCLUSIVE, DIRTY_MODE_ADVISORY, DIRTY_MODE_NONE
     * @param string $tableName
     * @param $primaryKey
     * @param int $recordId
     *
     * @param bool $flagCheckModifiedFirst
     * @return array
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    public function checkDirtyAndRelease($formMode, $lockTimeout, $dirtyMode, $tableName, $primaryKey, $recordId, $flagCheckModifiedFirst = false) {

        $rcRecordDirty = array();
        $rcMsg = '';

        $answer = [API_STATUS => API_ANSWER_STATUS_SUCCESS, API_MESSAGE => ''];

        if ($recordId == 0 || // New records never have a recordDirty nor a conflict.
            $dirtyMode == DIRTY_MODE_NONE) { // mode none -> no lock checking
            return $answer;
        }

        // Check if the record has changed in the meantime.
        if ($flagCheckModifiedFirst && $this->isRecordModified($tableName, $primaryKey, $recordId, $this->client[DIRTY_RECORD_HASH_MD5], $dummy)) {
            throw new \InfoException('The record has been modified in the meantime. Please reload the form, edit and save again. [3]', ERROR_DIRTY_RECORD_MODIFIED);
        }

        $lockStatus = $this->getCheckDirty($tableName, $recordId, $rcRecordDirty, $rcMsg);

        if (empty($rcRecordDirty)) {
            if ($formMode == FORM_DELETE) {
                return $answer;
            }
            // This is pessimistic, but secure.
//            throw new \UserFormException("Missing record lock: please reload the form, edit and save again.", ERROR_DIRTY_MISSING_LOCK);

            // New logic: a missing lock is not a problem. Reason: it's easy to delete an expired lock, but than it's hard to verify a release for an expired lock.
            return $answer;
        }

        if ($formMode == FORM_DELETE) {
            // Check if the lock is timed out
            if ($lockTimeout > 0 && $rcRecordDirty[DIRTY_EXPIRE] < date('Y-m-d H:i:s')) {
                $this->deleteDirtyRecord($rcRecordDirty[COLUMN_ID]);

                return $answer;
            }

            $answer = [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => $rcMsg];

            return $answer;
        }

        // Is the dirtyRecord mine?
        if ($lockStatus == LOCK_FOUND_OWNER) {
            // Check if the record has changed in the meantime.
            if ($this->isRecordModified($tableName, $primaryKey, $recordId, $rcRecordDirty[DIRTY_RECORD_HASH_MD5], $dummy)) {
                return [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => 'The record has been modified in the meantime. Please reload the form, edit and save again. [4]'];
            }

            // Clear the lock
            $this->deleteDirtyRecord($rcRecordDirty[COLUMN_ID]);

            return $answer;
        }

        //----------------------------------------
        // From here: there is a foreign lock!

        // Check if overwrite is allowed
        if ($dirtyMode == DIRTY_MODE_ADVISORY && $rcRecordDirty[F_DIRTY_MODE] == DIRTY_MODE_ADVISORY) {
            return $answer;
        }

        // Check if the record is timed out
        if ($lockTimeout > 0 && $rcRecordDirty[DIRTY_EXPIRE] < date('Y-m-d H:i:s')) {
            $this->deleteDirtyRecord($rcRecordDirty[COLUMN_ID]);

            return $answer;
        }

        throw new \UserFormException($rcMsg, ERROR_DIRTY_ALREADY_LOCKED);
    }

    /**
     * Delete the dirtyRecord with $recordDirtyId. Throw an exception if the record has not been deleted.
     *
     * @param int $recordDirtyId
     *
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function deleteDirtyRecord($recordDirtyId) {

        $cnt = $this->dbArray[$this->dbIndexQfq]->sql("DELETE FROM " . TABLE_NAME_DIRTY . " WHERE `id`=? LIMIT 1", ROW_REGULAR, [$recordDirtyId]);
        if ($cnt != 1) {
            throw new \CodeException("Failed to delete dirty record id=" . $recordDirtyId, ERROR_DIRTY_DELETE_RECORD);
        }
    }
}