<?php
/**
 * Created by PhpStorm.
 * User: crose
 * Date: 1/12/16
 * Time: 4:36 PM
 */

namespace IMATHUZH\Qfq\Core;

use IMATHUZH\Qfq\Core\Database\Database;
use IMATHUZH\Qfq\Core\Helper\EncryptDecrypt;
use IMATHUZH\Qfq\Core\Helper\KeyValueStringParser;
use IMATHUZH\Qfq\Core\Helper\OnString;
use IMATHUZH\Qfq\Core\Helper\Path;
use IMATHUZH\Qfq\Core\Helper\Support;
use IMATHUZH\Qfq\Core\Report\ColumnScript;
use IMATHUZH\Qfq\Core\Report\Link;
use IMATHUZH\Qfq\Core\Report\Report;
use IMATHUZH\Qfq\Core\Report\Tablesorter;
use IMATHUZH\Qfq\Core\Store\Sip;
use IMATHUZH\Qfq\Core\Store\Store;

const EVALUATE_DB_INDEX_DEFAULT = 0;
/**
 * Class Evaluate
 * @package qfq
 */
class Evaluate {
    /**
     * @var Store
     */
    private $store = null;

    /**
     * @var Database[] - Array of Database instantiated class
     */
    private $dbArray = array();

    /**
     * @var Link
     */
    private $link = null;

    /**
     * @var Tablesorter
     */
    private $tablesorter = null;

    private $dbIndex = EVALUATE_DB_INDEX_DEFAULT;
    private $startDelimiter = '';
    private $startDelimiterLength = 0;
    private $endDelimiter = '';
    private $endDelimiterLength = 0;
    private $sqlKeywords = array('SELECT ', 'INSERT ', 'DELETE ', 'UPDATE ', 'SHOW ', 'REPLACE ', 'TRUNCATE ', 'DESCRIBE ', 'EXPLAIN ', 'SET ', 'WITH ');
    private $escapeTypeDefault = '';
    private $report = null;

//    private $debugStack = array();


    /**
     * @param Store $store
     * @param Database $db
     * @param string $startDelimiter
     * @param string $endDelimiter
     * @throws \CodeException
     * @throws \UserFormException
     */
    public function __construct(Store $store, Database $db, $startDelimiter = '{{', $endDelimiter = '}}') {
        $this->store = $store;

        $this->dbArray[EVALUATE_DB_INDEX_DEFAULT] = $db;
        $this->startDelimiter = $startDelimiter;
        $this->startDelimiterLength = strlen($startDelimiter);
        $this->endDelimiter = $endDelimiter;
        $this->endDelimiterLength = strlen($endDelimiter);
        $this->escapeTypeDefault = $this->store::getVar(F_ESCAPE_TYPE_DEFAULT, STORE_SYSTEM);
        if (empty($this->escapeTypeDefault) || $this->escapeTypeDefault == TOKEN_ESCAPE_CONFIG) {
            $this->escapeTypeDefault = $this->store::getVar(SYSTEM_ESCAPE_TYPE_DEFAULT, STORE_SYSTEM);
        }
    }

    /**
     * Evaluate a whole array or an array of arrays.
     *
     * @param       $tokenArray
     * @param array $skip Optional Array with keynames, which will not be evaluated.
     * @param array $debugStack
     *
     * @return array
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function parseArray($tokenArray, array $skip = array(), &$debugStack = array()) {
        $arr = array();

        // In case there is an Element 'fillStoreVar', process that first (if not defined to skip).
        $flagSkipFillStoreVar = (array_search(FE_FILL_STORE_VAR, $skip) !== false);
        if (!$flagSkipFillStoreVar && !empty($tokenArray[FE_FILL_STORE_VAR]) && is_string($tokenArray[FE_FILL_STORE_VAR])) {

            $arr = $this->parse($tokenArray[FE_FILL_STORE_VAR], ROW_REGULAR, 0, $debugStack);
            if (!empty($arr)) {
                $this->store::appendToStore($arr[0], STORE_VAR);
            }
            unset($tokenArray[FE_FILL_STORE_VAR]);
        }

        foreach ($tokenArray as $key => $value) {

            if (array_search($key, $skip) !== false) {
                $arr[$key] = $value;
                continue;
            }

            if (is_array($value)) {
                $arr[] = $this->parseArray($value, $skip);
            } else {
                $value = Support::handleEscapeSpaceComment($value);

                $arr[$key] = $this->parse($value, ROW_IMPLODE_ALL, 0, $debugStack);
            }
        }

        return $arr;
    }

    /**
     * Recursive evaluation of 'line'. Constant string, Variables or SQL Query or all of them. All queries will be
     * fired. In case of an 'INSERT' statement, return the last_insert_id().
     *
     * Token to replace have to be enclosed by '{{' and '}}'
     *
     * @param string $line
     * @param string $sqlMode ROW_IMPLODE | ROW_REGULAR | ... - might be overwritten in $line by '{{!...'
     * @param int $recursion
     * @param array $debugStack
     * @param string $foundInStore
     *
     * @return array|mixed|null|string - in case of INSERT: last_insert_id()
     *
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function parse($line, $sqlMode = ROW_IMPLODE_ALL, $recursion = 0, &$debugStack = array(), &$foundInStore = '', $frCmd = '') {

        if ($line === '') {
            return '';
        }

        $flagTokenReplaced = false;

        if ($recursion > 4) {
            throw new \UserFormException(
                json_encode([ERROR_MESSAGE_TO_USER => 'Recursion too deep', ERROR_MESSAGE_TO_DEVELOPER => "Level: $recursion, Line: $line"]),
                ERROR_RECURSION_TOO_DEEP);
        }

        $result = $line;

        $recursion = ($recursion === null) ? 0 : $recursion;
        $debugIndent = str_repeat(' ', $recursion);
        $debugLocal[] = $debugIndent . "Parse: $result";

        $posFirstClose = strpos($result, $this->endDelimiter);
        $posLastClose = strrpos($result, $this->endDelimiter);

        // Variables like 'fillStoreVar' might contain SQL statements. Put them in store in case a DB exception is thrown.
        $this->store::setVar(SYSTEM_SQL_RAW, $line, STORE_SYSTEM);

        while ($posFirstClose !== false) {

            $posMatchOpen = strrpos(substr($result, 0, $posFirstClose), $this->startDelimiter);
            if ($posMatchOpen === false) {
                throw new \UserFormException(
                    json_encode([ERROR_MESSAGE_TO_USER => 'Missing open delimiter', ERROR_MESSAGE_TO_DEVELOPER => "Text: $result"]),
                    ERROR_MISSING_OPEN_DELIMITER);
            }

            $pre = substr($result, 0, $posMatchOpen);
            $post = substr($result, $posFirstClose + $this->endDelimiterLength);
            $match = substr($result, $posMatchOpen + $this->startDelimiterLength, $posFirstClose - $posMatchOpen - $this->startDelimiterLength);

            $tmpSqlMode = ($posFirstClose == $posLastClose) ? $sqlMode : ROW_IMPLODE_ALL;
            $evaluated = $this->substitute($match, $foundInStore, $tmpSqlMode, $frCmd);

            // newline
            $debugLocal[] = '';

            $debugLocal[] = $debugIndent . "Replace: $match";

            if ($foundInStore === '') {
                // Encode the non replaceable part as preparation not to process again. Recode them at the end.
                $evaluated = Support::encryptDoubleCurlyBraces($this->startDelimiter . $match . $this->endDelimiter);
                $debugLocal[] = $debugIndent . "BY: <nothing found - not replaced>";

            } else {

                $flagTokenReplaced = true;

                // If an array is returned, break everything and return this assoc array.
                if (is_array($evaluated)) {
                    $result = $evaluated;
                    $debugLocal[] = $debugIndent . "BY: array(" . count($result) . ")";
                    break;
                }

                $debugLocal[] = $debugIndent . "BY: $evaluated";

                // More to substitute in the new evaluated result? Start recursion just with the new result..
                if ($foundInStore === TOKEN_FOUND_STOP_REPLACE) {
                    $evaluated = Support::encryptDoubleCurlyBraces($evaluated);
                } else {
                    if (strpos($evaluated, $this->endDelimiter) !== false) {
                        $evaluated = $this->parse($evaluated, ROW_IMPLODE_ALL, $recursion + 1, $debugLocal, $foundInStore, $frCmd);
                    }
                }
            }
            $result = $pre . $evaluated . $post;

            $posFirstClose = strpos($result, $this->endDelimiter);
        }

        $result = Support::decryptDoubleCurlyBraces($result);

        if ($flagTokenReplaced === true) {
            if (is_array($result)) {
                $str = "array(" . count($result) . ")";
            } else {
                $str = "$result";
            }
            $debugLocal[] = $debugIndent . "FINAL: " . $str;

            $debugStack = $debugLocal;
        }

        return $result;
    }

    /**
     * @param $token // string
     * @param $dbIndex
     * @param $foundInStore
     * @return string
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function inlineLink($token, $dbIndex, &$foundInStore): string {

        $token = OnString::trimQuote($token);

        if ($this->link === null) {
            $this->link = new Link($this->store::getSipInstance(), $dbIndex);
        }

        $foundInStore = TOKEN_FOUND_AS_COLUMN;

        return $this->link->renderLink($token);
    }

    /**
     * @param $token
     * @param $dbIndex
     * @param $foundInStore
     * @return string
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function inlineDataDndApi($token, $dbIndex, &$foundInStore): string {

        $token = OnString::trimQuote($token);

        # Include current SIP store, to fetch SIP parameter later.
        $token .= '&' . DND_FORM_SIP_VALUES . '=' . $this->store::getVar(SIP_SIP, STORE_SIP);
        if (empty($token)) {
            throw new \UserReportException('Missing form name for "data-dnd-api"', ERROR_MISSING_FORM);
        }

        if ($this->link === null) {
            $this->link = new Link($this->store::getSipInstance(), $dbIndex);
        }

        $foundInStore = TOKEN_FOUND_AS_COLUMN;

        $s = $this->link->renderLink('U:' . $token . '|s|r:8');

        // Flag to add DND JS code later on.
        $this->store::setVar(SYSTEM_DRAG_AND_DROP_JS, 'true', STORE_SYSTEM);

        // data-dnd-api="typo3conf/ext/qfq/qfq/Api/dragAndDrop.php?s={{'U:form=<form name>[&paramX=<any value>]|s|r:8' AS _link}}"
        return DND_DATA_DND_API . '="' . Path::urlApi(API_DRAG_AND_DROP_PHP) . '?s=' . $s . '"';
    }

    /** Execute qfqFunction and output value. Content is accessible in record store
     *
     * @param $token
     * @param $foundInStore
     * @return string
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     * @throws \DbException
     */
    private function inlineFunction($token, &$foundInStore): string {
        $output = '';
        $token = OnString::trimQuote($token);
        if ($this->report === null) {
            $this->report = new Report(array(), $this);
        }

        $this->report->doQfqFunction($token);
        $foundInStore = TOKEN_FOUND_AS_COLUMN;

        // Check for => in qfqFunction. If not given then output content.
        if (!strpos($token, '=>')) {
            $output = $this->store::getVar(COLUMN_FUNCTION_OUTPUT, STORE_RECORD);
        }

        return $output;
    }

    /**
     * Tries to substitute $token.
     * Token might be:
     *   a) a SQL statement to fire
     *   b) fetch from a store. Syntax: '\[db index\]form:[store]:[sanitize]:[escape]:[default]:[type violate message]', ''
     *   c) a special column name such as link or script
     *   d) another column name such as function, data-dnd-api, tablesorter-view-saver
     *
     * The token have to be *without* Delimiter '{{' , '}}'
     * If neither a), b), c) or d) match, return the token itself.
     *
     * @param string $token
     * @param string $foundInStore Returns the name of the store where $key has been found. If $key is not found, return ''.
     * @param string $sqlMode - ROW_IMPLODE | ROW_REGULAR | ... - might be overwritten in $line by '{{!...'
     *
     * @return array|null|string
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function substitute($token, &$foundInStore = '', $sqlMode = ROW_IMPLODE_ALL, $frCmd = '') {

        $token = trim($token);
        $dbIndex = $this->dbIndex;
        $rcFlagWipe = false;

        // Check if the $token starts with '[<int>]...' - yes: open the necessary database.
        if (strlen($token) > 2 && $token[0] === '[') {
            if ($token[2] !== ']') {
                throw new \UserFormException(json_encode(
                    [ERROR_MESSAGE_TO_USER => "Missing token ']' on position 3",
                        ERROR_MESSAGE_TO_DEVELOPER => "In string '$token'"]), ERROR_TOKEN_MISSING);
            }
            $dbIndex = $token[1];
            $token = trim(substr($token, 3));

            if (empty($this->dbArray[$dbIndex])) {
                $this->dbArray[$dbIndex] = new Database($dbIndex);
            }
        }

        if ($token === '') {
            return '';
        }

        // Get SQL column / row separated
        if ($token[0] === '!') {
            $token = trim(substr($token, 1));
            $sqlMode = ROW_REGULAR;
        }

        // In case there is a statement starting with '(' (multiple): remove them just to detect if the following is a SQL statement.
        // E.g. original:  "{{[1] ! ( SELECT name FROM person ORDER BY name UNION SELECT 'new'}}"
        $tokenClean = OnString::removeLeadingBrace($token);

        // Extract token: check if this is a 'variable', 'SQL Statement', 'link', 'data-dnd-api', 'copy to store'.
        // Indicated by '... AS ...' at the end of the string.
        [$first, $second, $third] = OnString::split2LastToken($tokenClean);

        // Variable Type 'SQL Statement'. $first might contain a complete sql statement.
        $tokenClean = strtoupper(strtok($tokenClean, ' '));
        if (in_array($tokenClean . ' ', $this->sqlKeywords)) {
            $foundInStore = TOKEN_FOUND_IN_STORE_QUERY;

            return $this->dbArray[$dbIndex]->sql($token, $sqlMode);
        }

        if ($third !== false && $second !== false && strtolower($second) == 'as') {

            $third = OnString::stripFirstCharIf(TOKEN_COLUMN_CTRL, OnString::trimQuote($third));

            switch (strtolower($third)) {
                case COLUMN_LINK:
                    return ($this->inlineLink($first, $dbIndex, $foundInStore));
                    break;

                case DND_DATA_DND_API:
                    return ($this->inlineDataDndApi($first, $dbIndex, $foundInStore));
                    break;

                case TABLESORTER_VIEW_SAVER:
                    if ($this->tablesorter === null) {

                        // Read settings always from dbIndexQfq
                        $dbIndexQfq = $this->store::getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM);
                        if (empty($this->dbArray[$dbIndexQfq])) {
                            $this->dbArray[$dbIndexQfq] = new Database($dbIndexQfq);
                        }

                        $this->tablesorter = new Tablesorter($dbIndexQfq);
                    }
                    // create baseUrl attribute for tablesorter api
                    $baseUrlAttribute = DATA_TABLESORTER_BASE_URL . "='" . $this->store->getVar(SYSTEM_BASE_URL, STORE_SYSTEM) . "'";
                    return ($this->tablesorter->inlineTablesorterView($first, $foundInStore, $frCmd)) . $baseUrlAttribute;
                    break;

                case COLUMN_FUNCTION:
                    return ($this->inlineFunction($first, $foundInStore));
                    break;

                case COLUMN_SCRIPT:
                    return ColumnScript::render(OnString::trimQuote($first));
                    break;

                default:
                    // Check if there is "{{ 'hello world' AS _varnme:U}}"
                    [$varname, $store] = OnString::checkAndSplitForVarStoreSyntax($third);
                    if ($store !== false) {
                        $this->store::setVar($varname, OnString::trimQuote($first), $store);
                        return '';
                    }
                    break;
            }
        }

        // explode for: <key>:<store priority>:<sanitize class>:<escape>:<default>:<type violate message>
        $arrToken = array_merge(KeyValueStringParser::explodeEscape(PARAM_VAR_DELIMITER, $token, 6), [null, null, null, null, null, null]);

        $escapeTypes = (empty($arrToken[VAR_INDEX_ESCAPE])) ? $this->escapeTypeDefault : $arrToken[VAR_INDEX_ESCAPE];
        $typeMessageViolate = ($arrToken[VAR_INDEX_MESSAGE] === null || $arrToken[VAR_INDEX_MESSAGE] === '') ? SANITIZE_TYPE_MESSAGE_VIOLATE_CLASS : $arrToken[VAR_INDEX_MESSAGE];

        // search for value in stores
        $value = $this->store::getVar($arrToken[VAR_INDEX_VALUE], $arrToken[VAR_INDEX_STORE], $arrToken[VAR_INDEX_SANITIZE],
            $foundInStore, $typeMessageViolate, $arrToken[VAR_INDEX_DEFAULT]);

        $value = OnString::escape($escapeTypes, $value, $rcFlagWipe);

        if (($foundInStore == '' || $value === '') && $arrToken[VAR_INDEX_DEFAULT] != '') {
            $foundInStore = TOKEN_FOUND_AS_DEFAULT;
            $value = str_replace('\\:', ':', $arrToken[VAR_INDEX_DEFAULT]);
        }

        if ($rcFlagWipe) {
            switch ($foundInStore) {
                case STORE_SIP:
                    $this->store::unsetVar($arrToken[VAR_INDEX_VALUE], STORE_SIP);

                    $sip = new Sip();
                    $sip->removeKeyFromSip($this->store::getVar(SIP_SIP, STORE_SIP), $arrToken[VAR_INDEX_VALUE]);
                    break;
                case STORE_EMPTY:
                case STORE_ZERO:
                    break;
                default:
                    throw new \UserReportException("Wipe not implemented for store $foundInStore", ERROR_WIPE_NOT_IMPLEMENTED_FOR_STORE);
            }
        }

        return $value;
    }

}